/** * Core execution engine: creates sessions, runs agents, collects results. * * Tool visibility policy is owned by agent-types.ts (resolveVisibleTools). */ import fs from "node:fs"; import path from "node:path"; import type { ImageContent } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type AgentSession, type AgentSessionEvent, createAgentSession, DefaultResourceLoader, type ExtensionAPI, getAgentDir, loadProjectContextFiles, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent"; import { getAgentConfig, getConfig, getToolNamesForType, resolveVisibleTools, } from "./agent-types.js"; import { extractText } from "../prompt/context.js"; import type { AgentUsage } from "./usage.js"; import { findModelInRegistry, GIT_EXEC_TIMEOUT_MS } from "../utils.js"; import { getActiveScopedModels } from "../models/model-scope.js"; import { DEFAULT_AGENTS } from "./default-agents.js"; import { buildAgentPrompt, type PromptExtras } from "../prompt/prompts.js"; import { preloadSkills, loadSkillMeta } from "../prompt/skill-loader.js"; import { type EnvInfo, type RunCallbacks, type RunTunables, SHORT_ID_LENGTH, } from "../types.js"; import type { SubagentType, SystemPromptMode } from "./types.js"; import { getStore, enterSubagentSpawn, exitSubagentSpawn } from "../shell.js"; import { DEFAULT_GRACE_TURNS, CUSTOM_PROMPT_PATH, } from "../config/config-io.js"; /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */ function normalizeMaxTurns(n: number | undefined): number | undefined { if (n == null || n === 0) return undefined; return Math.max(1, n); } /** Info about a tool event in the subagent. */ interface RunOptions extends RunTunables, RunCallbacks { /** ExtensionAPI instance — used for pi.exec() for git detection. */ pi: ExtensionAPI; /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */ agentId?: string; /** Override working directory (resolved worktree path). */ cwd?: string; /** Parent abort signal — when aborted, the subagent is also stopped. */ signal?: AbortSignal; } interface RunResult { responseText: string; session: AgentSession; /** True if the agent was hard-aborted (max_turns + grace exceeded). */ aborted: boolean; /** True if the agent hit the soft turn limit and wrapped up within grace turns. */ turnLimited: boolean; } /** Options for prompting an already-created subagent session. */ export interface ContinueAgentOptions extends RunCallbacks { images?: ImageContent[]; maxTurns?: number; graceTurns?: number; } export interface ContinueAgentResult { responseText: string; aborted: boolean; turnLimited: boolean; } interface RetryClassifierMessage { stopReason?: string; errorMessage?: string; } const CODEX_RETRYABLE_STREAM_ERROR_PATTERN = /stream disconnected before completion|stream closed before response\.completed|invalid SSE data JSON/i; /** Extend upstream auto-retry classification for transient Codex stream errors. */ function enableCodexStreamErrorRetry(session: AgentSession): void { const retrySession = session as unknown as { _isRetryableError?: (message: RetryClassifierMessage) => boolean; }; const classifyRetryableError = retrySession._isRetryableError; if (typeof classifyRetryableError !== "function") return; retrySession._isRetryableError = (message) => classifyRetryableError.call(retrySession, message) || (message.stopReason === "error" && typeof message.errorMessage === "string" && CODEX_RETRYABLE_STREAM_ERROR_PATTERN.test(message.errorMessage)); } /** * Subscribe to a session and collect the last assistant message text. * Returns an object with a `getText()` getter and an `unsubscribe` function. */ function collectResponseText( session: AgentSession, onTextDelta?: (delta: string, fullText: string) => void, ) { let text = ""; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "message_start") { text = ""; } if ( event.type === "message_update" && event.assistantMessageEvent.type === "text_delta" ) { text += event.assistantMessageEvent.delta; onTextDelta?.(event.assistantMessageEvent.delta, text); } }); return { getText: () => text, unsubscribe }; } /** Get the last assistant text from the completed session history. */ function getLastAssistantText(session: AgentSession): string { for (let i = session.messages.length - 1; i >= 0; i--) { const msg = session.messages[i]; if (msg.role !== "assistant") continue; const text = extractText(msg.content).trim(); if (text) return text; } return ""; } /** * Wire an AbortSignal to abort a session. * Returns a cleanup function to remove the listener. */ function forwardAbortSignal( session: AgentSession, signal?: AbortSignal, ): () => void { if (!signal) return () => {}; const onAbort = () => { void session.abort(); }; if (signal.aborted) { onAbort(); return () => {}; } signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } /** * Extract a LifetimeUsage from a runtime assistant message_end event. * pi-ai attaches `usage: { input, output, cacheWrite, cost: { total } }` to * assistant messages at runtime, but this shape isn't reflected in the * AgentSessionEvent public types. */ function usageFromAssistantMessage( msg: Record, ): AgentUsage | undefined { const usage = msg.usage as Record | undefined; if (!usage) return undefined; return { input: (usage.input as number) ?? 0, output: (usage.output as number) ?? 0, cacheWrite: (usage.cacheWrite as number) ?? 0, cacheRead: (usage.cacheRead as number) ?? 0, cost: ((usage.cost as Record)?.total as number) ?? 0, }; } /** * Subscribe to shared session events (tool activity, usage, compaction) * used by runAgent. Returns an unsubscribe function. */ export function subscribeToSessionEvents( session: AgentSession, options: Pick< RunOptions, "onToolActivity" | "onAssistantUsage" | "onCompaction" >, ): () => void { if ( !options.onToolActivity && !options.onAssistantUsage && !options.onCompaction ) { return () => {}; } return session.subscribe((event: AgentSessionEvent) => { if (event.type === "tool_execution_start") { options.onToolActivity?.({ type: "start", toolName: event.toolName }); } if (event.type === "tool_execution_end") { options.onToolActivity?.({ type: "end", toolName: event.toolName }); } if (event.type === "message_end" && event.message.role === "assistant") { const msg = event.message as unknown as Record; const usage = usageFromAssistantMessage(msg); if (usage) { options.onAssistantUsage?.(usage); } } if (event.type === "compaction_end" && !event.aborted && event.result) { options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore, }); } }); } /** * Extract the extension name from an extension's file path. * * Handles all distribution methods: * - git packages: `.../git/github.com///...` → "" * - npm packages: `.../node_modules/[...]pkg/...` → "pkg" * - local extensions: `~/.pi/agent/extensions//...` → "" * - direct files: `extensions/.ts` → "" * * Does NOT depend on internal directory structure (dist/, lib/, src/, etc). * Only cares about the package root, which is determined by distribution method. */ function extractExtensionName(extPath: string): string { const parts = extPath.split(path.sep); // 1. Git package: .../git/github.com///... // Package name is 3 dirs after 'git' (github.com/user/pkg) const gitIdx = parts.indexOf("git"); if (gitIdx !== -1 && gitIdx + 3 < parts.length) { return parts[gitIdx + 3]; } // 2. npm package: .../node_modules/[...]pkg/... const nmIdx = parts.lastIndexOf("node_modules"); if (nmIdx !== -1 && nmIdx + 1 < parts.length) { const next = parts[nmIdx + 1]; if (next.startsWith("@") && nmIdx + 2 < parts.length) { return parts[nmIdx + 2]; // @scope/pkg → pkg } return next; } // 3. Local extension: .../extensions//... or .../extensions/.ts const extIdx = parts.lastIndexOf("extensions"); if (extIdx !== -1 && extIdx + 1 < parts.length) { const afterExt = parts[extIdx + 1]; // Subdirectory: extensions/tavily/index.ts → tavily if (afterExt && !afterExt.includes(".")) { return afterExt; } // Direct file: extensions/review.ts → review const file = parts[parts.length - 1]; return path.basename(file, path.extname(file)); } // Fallback: parent dir name return path.basename(path.dirname(extPath)); } /** Run a git command via pi.exec, returning stdout on success or null on failure. */ async function execGit( pi: ExtensionAPI, args: string[], cwd: string, ): Promise { try { const result = await pi.exec("git", args, { cwd, timeout: GIT_EXEC_TIMEOUT_MS, }); return result.code === 0 ? result.stdout.trim() : null; } catch { return null; } } /** * Detect environment info using pi.exec() for git detection. * Inline replacement for upstream's detectEnv from env.ts. */ async function detectEnv(pi: ExtensionAPI, cwd: string): Promise { const gitRoot = await execGit( pi, ["rev-parse", "--is-inside-work-tree"], cwd, ); const isGitRepo = gitRoot === "true"; const branch = isGitRepo ? await execGit(pi, ["branch", "--show-current"], cwd) : null; return { isGitRepo, branch, platform: process.platform, }; } // ── runAgent phases ──────────────────────────────────────────────── /** * Resolve system prompt mode, fetch the appropriate source prompt, and * load project context files. Returns everything buildPrompt needs. */ function resolveSystemPromptSources( ctx: ExtensionContext, cwd: string, notify: (msg: string) => void, ): { mode: SystemPromptMode; extras: Pick< PromptExtras, "parentSystemPrompt" | "customSystemPrompt" | "contextFiles" >; } { const store = getStore(); const mode = store.agent.systemPromptMode; const extras: Pick< PromptExtras, "parentSystemPrompt" | "customSystemPrompt" | "contextFiles" > = {}; // Fetch parent system prompt for inherit mode if (mode === "inherit") { try { extras.parentSystemPrompt = ctx.getSystemPrompt(); } catch (err) { notify( `Failed to get parent system prompt: ${err}. Falling back to replace mode.`, ); } } // Read custom prompt file for custom mode if (mode === "custom") { try { const content = fs.readFileSync(CUSTOM_PROMPT_PATH, "utf-8").trim(); if (content) { extras.customSystemPrompt = content; } else { notify( `Custom prompt file is empty: ${CUSTOM_PROMPT_PATH}. Falling back to replace mode.`, ); } } catch (err: any) { if (err.code === "ENOENT") { notify( `Custom prompt file not found: ${CUSTOM_PROMPT_PATH}. Falling back to replace mode.`, ); } else { notify( `Failed to read custom prompt file: ${err.message}. Falling back to replace mode.`, ); } } } // Load AGENTS.md context files when the setting is enabled if (store.agent.includeContextFiles) { try { extras.contextFiles = loadProjectContextFiles({ cwd, agentDir: getAgentDir(), }); } catch { // Non-fatal: context files are supplementary } } return { mode, extras }; } /** * Phase 1: Resolve system prompt from agent config, skills, and env info. * * @param resolverExtras Partial extras from resolveSystemPromptSources (mode-specific prompts + context files). */ function buildPrompt( type: SubagentType, agentConfig: ReturnType, config: ReturnType, cwd: string, env: EnvInfo, systemPromptMode: SystemPromptMode = "replace", resolverExtras: Pick< PromptExtras, "parentSystemPrompt" | "customSystemPrompt" | "contextFiles" > = {}, ): string { const extras: PromptExtras = { ...resolverExtras }; if (Array.isArray(agentConfig?.preloadSkills)) { extras.skillBlocks = preloadSkills(agentConfig.preloadSkills, cwd); } if (Array.isArray(config.skills)) { extras.skillMetas = loadSkillMeta(config.skills, cwd); } if (agentConfig) { return buildAgentPrompt(agentConfig, cwd, env, extras, systemPromptMode); } const fallback = DEFAULT_AGENTS.get("general-purpose"); if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`); return buildAgentPrompt( { ...fallback, name: type }, cwd, env, extras, systemPromptMode, ); } /** Build extension name → tool names map from loaded extensions. */ function buildExtToolMap( extensions: Array<{ path: string; tools: Map }>, ) { const map = new Map(); for (const ext of extensions) { const name = extractExtensionName(ext.path); const tools = [...ext.tools.keys()]; if (tools.length > 0) map.set(name, tools); } return map; } /** Build extension override for whitelist or blacklist filtering. */ function buildExtOverride( extensions: true | string[] | false | undefined, excludeExtensions?: string[], ) { if (Array.isArray(extensions)) { const allowedNames = new Set( extensions.map((ext) => { const slashIdx = ext.indexOf("/"); return slashIdx !== -1 ? ext.slice(0, slashIdx) : ext; }), ); return (result: any) => ({ ...result, extensions: result.extensions.filter((ext: { path: string }) => allowedNames.has(extractExtensionName(ext.path)), ), }); } if (excludeExtensions) { const excludeSet = new Set(excludeExtensions); return (result: any) => ({ ...result, extensions: result.extensions.filter( (ext: { path: string }) => !excludeSet.has(extractExtensionName(ext.path)), ), }); } return undefined; } /** * Phase 2: Build DefaultResourceLoader with extension filtering. * Returns the loader and a function that reloads it and builds the ext→tool map. */ function createResourceLoader( config: ReturnType, agentConfig: ReturnType, cwd: string, systemPrompt: string, ) { const extensions = config.extensions; const noSkills = config.skills === false || Array.isArray(config.skills) || Array.isArray(agentConfig?.preloadSkills); const agentDir = getAgentDir(); const loaderOpts: ConstructorParameters[0] = { cwd, agentDir, noExtensions: extensions === false, noSkills, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => systemPrompt, appendSystemPromptOverride: () => [], extensionsOverride: buildExtOverride( extensions, agentConfig?.excludeExtensions, ), }; const loader = new DefaultResourceLoader(loaderOpts); return { loader, reloadAndMap: async () => { await loader.reload(); const extResult = loader.getExtensions(); return { extResult, extToolMap: buildExtToolMap(extResult.extensions) }; }, }; } /** * Copy the latest state entry for every extension into an isolated child session. * * Custom entries are explicitly for extension session state and never enter LLM * context. Copying them before extensions bind lets child sessions restore parent * preferences such as CLIProxyAPI Fast mode without inheriting conversation data. */ export function inheritCustomSessionEntries( parentEntries: Iterable<{ type: string; customType?: string; data?: unknown; }>, childSessionManager: Pick, ): void { const latestEntries = new Map(); for (const entry of parentEntries) { if ( entry.type !== "custom" || typeof entry.customType !== "string" || !entry.customType.trim() ) { continue; } latestEntries.delete(entry.customType); latestEntries.set(entry.customType, entry.data); } for (const [customType, data] of latestEntries) { childSessionManager.appendCustomEntry(customType, structuredClone(data)); } } /** Create an agent session with the resolved model and thinking level. */ async function initSession( ctx: ExtensionContext, options: RunOptions, agentConfig: ReturnType, type: SubagentType, cwd: string, loader: DefaultResourceLoader, ) { const model = options.model ?? findModelInRegistry(agentConfig?.model, ctx.modelRegistry, ctx.model); // Prefer spawn-time override, then agent frontmatter. When still unset, // createAgentSession falls back to settings defaultThinkingLevel. const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinkingLevel; const agentDir = getAgentDir(); const sessionManager = SessionManager.inMemory(cwd); inheritCustomSessionEntries(ctx.sessionManager.getBranch(), sessionManager); // Inherit parent Model scope so subagent sessions cannot cycle outside it. const scopedModels = getActiveScopedModels(ctx.modelRegistry, ctx.cwd); const sessionOpts: Parameters[0] = { cwd, agentDir, sessionManager, settingsManager: SettingsManager.create(cwd, agentDir), model, tools: getToolNamesForType(type), resourceLoader: loader, ...(scopedModels ? { scopedModels } : {}), }; // Always pass when set — including "off" — so settings default cannot override. // Free-form thinking strings are allowed; cast for pi's narrower ThinkingLevel type. if (thinkingLevel !== undefined) { sessionOpts.thinkingLevel = thinkingLevel as typeof sessionOpts.thinkingLevel; } const result = await createAgentSession(sessionOpts); enableCodexStreamErrorRetry(result.session); // Inject max_tokens into provider request payloads. // Spawn-time value wins over agent config (frontmatter). const maxTokens = options.maxTokens ?? agentConfig?.maxTokens; if (maxTokens != null && maxTokens > 0 && model) { const field = (model.compat as any)?.maxTokensField ?? "max_tokens"; const origOnPayload = result.session.agent.onPayload; result.session.agent.onPayload = async (payload, m) => { const applied = origOnPayload ? ((await origOnPayload(payload, m)) ?? payload) : payload; const obj = typeof applied === "object" && applied && !Array.isArray(applied) ? applied : {}; return { ...obj, [field]: maxTokens }; }; } return result; } /** * Phase 3: Create session, bind extensions, filter tools. */ async function createAndConfigureSession( ctx: ExtensionContext, options: RunOptions, agentConfig: ReturnType, type: SubagentType, cwd: string, loader: DefaultResourceLoader, extResult: { extensions: Array<{ path: string; tools: Map }>; }, notify: (msg: string) => void, ): Promise { const { session } = await initSession( ctx, options, agentConfig, type, cwd, loader, ); const baseName = agentConfig?.name ?? type; session.setSessionName( options.agentId ? `${baseName}#${options.agentId.slice(0, SHORT_ID_LENGTH)}` : baseName, ); await session.bindExtensions({ onError: (err) => options.onToolActivity?.({ type: "end", toolName: `extension-error:${err.extensionPath}`, }), }); const filteredTools = resolveVisibleTools({ activeTools: session.getActiveToolNames(), tools: agentConfig?.tools, excludeTools: agentConfig?.excludeTools, extToolMap: buildExtToolMap(extResult.extensions), notify, }); if (filteredTools) session.setActiveToolsByName(filteredTools); options.onSessionCreated?.(session); return session; } /** * Phase 4: Subscribe to turn_end events for graceful max_turns enforcement. * Returns an unsubscribe function and state getters. */ function wireTurnTracking( session: AgentSession, options: Pick, ) { let turnCount = 0; const maxTurns = normalizeMaxTurns(options.maxTurns); let softLimitReached = false; let aborted = false; const graceTurns = options.graceTurns ?? DEFAULT_GRACE_TURNS; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type !== "turn_end") return; turnCount++; options.onTurnEnd?.(turnCount); if (maxTurns == null) return; if (!softLimitReached && turnCount >= maxTurns) { softLimitReached = true; session.steer( "You have reached your turn limit. Wrap up immediately — provide your final answer now.", ); } else if (softLimitReached && turnCount >= maxTurns + graceTurns) { aborted = true; session.abort(); } }); return { unsubscribe, getAborted: () => aborted, getTurnLimited: () => softLimitReached, }; } /** * Phase 5: Execute the prompt turn loop with event wiring and cleanup. */ async function runTurnLoop( session: AgentSession, prompt: string, options: RunOptions, unsubTurns: () => void, ) { const unsubEvents = subscribeToSessionEvents(session, options); const collector = collectResponseText(session, options.onTextDelta); const cleanupAbort = forwardAbortSignal(session, options.signal); try { if (!options.signal?.aborted) { await session.prompt(prompt); } } finally { unsubTurns(); unsubEvents(); collector.unsubscribe(); cleanupAbort(); } return collector.getText().trim() || getLastAssistantText(session); } /** * Prompt an existing subagent session after its original task has settled. * This preserves the child conversation and tool configuration while wiring * the same usage/activity callbacks used by the initial run. */ export async function continueAgentSession( session: AgentSession, prompt: string, options: ContinueAgentOptions = {}, ): Promise { const turnTracking = wireTurnTracking(session, options); const unsubscribeEvents = subscribeToSessionEvents(session, options); const collector = collectResponseText(session, options.onTextDelta); try { const promptOptions = options.images?.length ? { images: options.images } : undefined; await session.prompt(prompt, promptOptions); } finally { turnTracking.unsubscribe(); unsubscribeEvents(); collector.unsubscribe(); } return { responseText: collector.getText().trim() || getLastAssistantText(session), aborted: turnTracking.getAborted(), turnLimited: turnTracking.getTurnLimited(), }; } // ── main entry ───────────────────────────────────────────────────── export async function runAgent( ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions, ): Promise { // Bracket the whole subagent lifecycle so the extension factory can detect // it's being re-loaded inside a subagent and avoid clobbering the parent shell. enterSubagentSpawn(); try { return await runAgentImpl(ctx, type, prompt, options); } finally { exitSubagentSpawn(); } } async function runAgentImpl( ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions, ): Promise { const store = getStore(); const config = getConfig( type, store.agent.loadSkillsImplicitly, store.agent.loadExtensionsImplicitly, ); const agentConfig = getAgentConfig(type); // Buffer warnings during setup to avoid inserting custom_message entries // between tool_use and tool_result in the session tree (causes Anthropic 400). // Flushed after runTurnLoop completes. const warnings: string[] = []; const bufferNotify = (msg: string) => { warnings.push(msg); }; if (agentConfig?.excludeTools && Array.isArray(agentConfig.tools)) { bufferNotify( `agent "${type}": both tools and exclude_tools set — tools (whitelist) wins`, ); } if (agentConfig?.excludeExtensions && Array.isArray(agentConfig.extensions)) { bufferNotify( `agent "${type}": both extensions and exclude_extensions set — extensions (whitelist) wins`, ); } const effectiveCwd = options.cwd ?? ctx.cwd; const env = await detectEnv(options.pi, effectiveCwd); // Resolve system prompt mode + source prompts + context files const { mode, extras: promptExtras } = resolveSystemPromptSources( ctx, effectiveCwd, bufferNotify, ); const systemPrompt = buildPrompt( type, agentConfig, config, effectiveCwd, env, mode, promptExtras, ); const { loader, reloadAndMap } = createResourceLoader( config, agentConfig, effectiveCwd, systemPrompt, ); const { extResult } = await reloadAndMap(); const session = await createAndConfigureSession( ctx, options, agentConfig, type, effectiveCwd, loader, extResult, bufferNotify, ); const { unsubscribe: unsubTurns, getAborted, getTurnLimited, } = wireTurnTracking(session, { ...options, maxTurns: options.maxTurns ?? agentConfig?.maxTurns, }); const responseText = await runTurnLoop(session, prompt, options, unsubTurns); // Flush buffered warnings now that tool_result is in the session tree. for (const msg of warnings) { if (ctx.ui?.notify) ctx.ui.notify(`[pi-subagents-lite] ${msg}`, "warning"); else console.warn(`[pi-subagents-lite] ${msg}`); } return { responseText, session, aborted: getAborted(), turnLimited: getTurnLimited(), }; }