/** * 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 { AssistantMessage, 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 { resolveSessionAllowedTools, resolveVisibleTools, } from "./agent-types.js"; import { extractText } from "../prompt/context.js"; import type { LifetimeUsage } from "./usage.js"; import { GIT_EXEC_TIMEOUT_MS } from "../utils.js"; import { missingSubagentModelError, scopedThinkingLevel } from "../models/model-scope.js"; import { buildAgentPrompt, type PromptExtras } from "../prompt/prompts.js"; import { preloadSkills, loadSkillMeta } from "../prompt/skill-loader.js"; import { type AcceptedRunPolicy, type EnvInfo, type RunCallbacks, type RunTunables, SHORT_ID_LENGTH } from "../types.js"; import type { SubagentType } from "./types.js"; import { withSubagentSpawn } from "../shell.js"; import { DEFAULT_GRACE_TURNS, CUSTOM_PROMPT_PATH } from "../config/config-io.js"; import { PENDING_RESULT_ENTRY, RESULT_ACK_ENTRY } from "../spawn/result-inbox.js"; import { debugFaultMessage, type DebugFaultKind } from "./debug-fault.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 { acceptedPolicy: AcceptedRunPolicy; /** 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; /** Debug-only one-shot failure injected after a real session is configured. */ debugFault?: DebugFaultKind; } 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 TRANSIENT_TRANSPORT_ERROR_PATTERN = /\b(?:stream|socket|network|transport)(?:[_\s-]+(?:read|write|connect(?:ion)?|disconnect(?:ed|ion)?|closed?|reset|lost|timeout))(?:[_\s-]+error)?\b|\b(?:EOF|ECONNRESET|ETIMEDOUT|EPIPE)\b|invalid SSE data JSON/i; /** * Pi has no public hook for extending per-session retry classification, so wrap * its private classifier while preserving the original result and `this` binding. * Missing or renamed internals degrade to Pi's default behavior instead of breaking sessions. */ function enableTransientTransportErrorRetry(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" && TRANSIENT_TRANSPORT_ERROR_PATTERN.test(message.errorMessage)); } /** * Capture the final assistant message for the current prompt. * * Pi reports provider failures as terminal assistant messages instead of * rejecting prompt(). Keeping that metadata prevents failures from becoming * completed runs with empty output. */ function collectFinalAssistantMessage(session: AgentSession) { let message: AssistantMessage | undefined; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "message_end" && event.message.role === "assistant") { message = event.message as AssistantMessage; } }); return { getMessage: () => message, unsubscribe }; } function resolveAssistantOutcome( message: AssistantMessage | undefined, aborted: boolean, turnLimited: boolean, ): { responseText: string; aborted: boolean } { if (message?.stopReason === "error") { throw new Error(message.errorMessage?.trim() || "Subagent failed without an error message"); } const responseText = message ? extractText(message.content).trim() : ""; const wasAborted = aborted || message?.stopReason === "aborted"; if (!responseText && !wasAborted && !turnLimited) { throw new Error("Subagent completed without final assistant text"); } return { responseText, aborted: wasAborted }; } /** * 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 () => {}; // Same guard as wireTurnTracking: abort() returns a promise and this fires // from an event listener, so a rejection escapes the run rather than failing // it. The parent is already going down when this runs. const onAbort = () => { void session.abort().catch(() => {}); }; if (signal.aborted) { onAbort(); return () => {}; } signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } /** Extract a LifetimeUsage from an assistant, tool, or compaction result. */ function usageFromResult(msg: Record): LifetimeUsage | 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, 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, ): () => void { if (!options.onToolUse && !options.onAssistantUsage && !options.onCompaction) { return () => {}; } return session.subscribe((event: AgentSessionEvent) => { if (event.type === "tool_execution_end") { options.onToolUse?.(); } if ( event.type === "message_end" && (event.message.role === "assistant" || event.message.role === "toolResult") ) { const usage = usageFromResult(event.message as unknown as Record); if (usage) options.onAssistantUsage?.(usage); } if (event.type === "compaction_end" && !event.aborted && event.result) { const usage = usageFromResult(event.result as unknown as Record); if (usage) options.onAssistantUsage?.(usage); options.onCompaction?.(); } }); } /** * 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, policy: AcceptedRunPolicy, notify: (msg: string) => void, ): Pick { const extras: Pick = {}; // Inherit snapshots the mode, not the parent's generated prompt text. The // latter remains a runtime value supplied by Pi when the queued run starts. if (policy.systemPromptMode === "inherit") { try { extras.parentSystemPrompt = ctx.getSystemPrompt(); } catch (err) { notify(`Failed to get parent system prompt: ${err}. Falling back to replace mode.`); } } if (policy.systemPromptMode === "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.`); } } } if (policy.includeContextFiles) { try { extras.contextFiles = loadProjectContextFiles({ cwd, agentDir: getAgentDir() }); } catch { // Non-fatal: context files are supplementary } } return 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( policy: AcceptedRunPolicy, cwd: string, env: EnvInfo, resolverExtras: Pick = {}, ): string { const agentConfig = policy.definition; const extras: PromptExtras = { ...resolverExtras }; if (Array.isArray(agentConfig.preloadSkills)) { extras.skillBlocks = preloadSkills(agentConfig.preloadSkills, cwd); } if (Array.isArray(policy.skills)) { extras.skillMetas = loadSkillMeta(policy.skills, cwd); } return buildAgentPrompt(agentConfig, cwd, env, extras, policy.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 its explicit reload step. */ function createResourceLoader( policy: AcceptedRunPolicy, cwd: string, systemPrompt: string, ) { const agentConfig = policy.definition; const noSkills = policy.skills === false || Array.isArray(policy.skills) || Array.isArray(agentConfig.preloadSkills); const agentDir = getAgentDir(); const loaderOpts: ConstructorParameters[0] = { cwd, agentDir, noExtensions: policy.extensions === false, noSkills, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => systemPrompt, appendSystemPromptOverride: () => [], extensionsOverride: buildExtOverride(policy.extensions, agentConfig.excludeExtensions), }; const loader = new DefaultResourceLoader(loaderOpts); return { loader, reload: () => loader.reload() }; } /** * 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. */ 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() || entry.customType === PENDING_RESULT_ENTRY || entry.customType === RESULT_ACK_ENTRY ) { 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, cwd: string, loader: DefaultResourceLoader, ) { const policy = options.acceptedPolicy; const sourceModel = options.model ?? ctx.model; if (!sourceModel) throw new Error(missingSubagentModelError()); const maxTokens = policy.definition.maxTokens; const model = maxTokens != null && maxTokens > 0 ? { ...sourceModel, maxTokens } : sourceModel; // Agent-tool calls pass invocation snapshots. The fallback keeps direct // internal runAgent callers working without weakening accepted-call locks. const scopedModels = options.scopedModels ? [...options.scopedModels] : [...ctx.scopedModels]; const thinkingLevel = options.thinkingResolved ? options.thinkingLevel : options.thinkingLevel ?? scopedThinkingLevel(scopedModels, model) ?? policy.definition.thinkingLevel; const agentDir = getAgentDir(); const sessionManager = SessionManager.inMemory(cwd); inheritCustomSessionEntries(ctx.sessionManager.getBranch(), sessionManager); const sessionOpts: Parameters[0] = { cwd, agentDir, sessionManager, settingsManager: SettingsManager.create(cwd, agentDir), model, tools: resolveSessionAllowedTools({ registeredTools: policy.registeredTools, restrictToRegisteredTools: policy.restrictToRegisteredTools, tools: policy.tools, }), resourceLoader: loader, // Use the exact scope snapshot validated against the initial model above. 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); enableTransientTransportErrorRetry(result.session); return result; } /** * Phase 3: Create session, bind extensions, filter tools. */ async function createAndConfigureSession( ctx: ExtensionContext, options: RunOptions, type: SubagentType, cwd: string, loader: DefaultResourceLoader, notify: (msg: string) => void, ): Promise { const policy = options.acceptedPolicy; const agentConfig = policy.definition; const { session } = await initSession(ctx, options, cwd, loader); const baseName = agentConfig.name ?? type; session.setSessionName( options.agentId ? `${baseName}#${options.agentId.slice(0, SHORT_ID_LENGTH)}` : baseName, ); let setupAborted = options.signal?.aborted === true; let abortDisposal: Promise | undefined; const disposeOnAbort = () => { setupAborted = true; if (abortDisposal) return; try { abortDisposal = Promise.resolve(session.dispose()).catch(() => {}); } catch { abortDisposal = Promise.resolve(); } }; if (setupAborted) disposeOnAbort(); else options.signal?.addEventListener("abort", disposeOnAbort, { once: true }); try { await session.bindExtensions({}); } catch (error) { if (!setupAborted) throw error; } finally { options.signal?.removeEventListener("abort", disposeOnAbort); } if (setupAborted) { await abortDisposal; throw new Error("Agent session setup aborted"); } const extToolMap = buildExtToolMap(loader.getExtensions().extensions); const filteredTools = resolveVisibleTools({ activeTools: session.getAllTools().map(tool => tool.name), tools: policy.tools, excludeTools: agentConfig.excludeTools, extToolMap, notify, }); if (filteredTools) session.setActiveToolsByName(filteredTools); await 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; // Turns the agent actually gets after the steer. The hard abort fires at the // first turn_end with turnCount >= maxTurns + graceTurns, so graceTurns of // 0 or 1 both leave exactly one turn. Quote the real number, not the config. const remainingTurns = Math.max(1, graceTurns); const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type !== "turn_end") return; turnCount++; options.onTurnEnd?.(turnCount); if (maxTurns == null) return; // steer() and abort() both return promises and both fire from inside this // subscribe callback, so an unhandled rejection escapes the run instead of // failing it. Rejection is realistic here — both target a session that may // already be tearing down. if (!softLimitReached && turnCount >= maxTurns) { softLimitReached = true; // Quantify the deadline. "Wrap up immediately" carries no budget, and the // models that overrun the grace window are the small local ones that need // the number most — an agent that ignores this gets hard-aborted with no // final text, which reaches the parent as a bare status note. // A rejected steer only costs the graceful wrap-up; the abort still fires. void session .steer( `You have reached your turn limit of ${maxTurns}. You have ${remainingTurns} turn(s) left before you are terminated. Stop calling tools and write your final answer now — if the task is unfinished, report what you completed and what remains.`, ) .catch(() => {}); } else if (softLimitReached && turnCount >= maxTurns + graceTurns) { aborted = true; // `aborted` is already set, so a rejected abort() cannot change the // reported outcome — only swallow the rejection. void session.abort().catch(() => {}); } }); 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, ): Promise { const unsubEvents = subscribeToSessionEvents(session, options); const collector = collectFinalAssistantMessage(session); const cleanupAbort = forwardAbortSignal(session, options.signal); try { if (!options.signal?.aborted) { await session.prompt(prompt); } } finally { unsubTurns(); unsubEvents(); collector.unsubscribe(); cleanupAbort(); } return collector.getMessage(); } /** * 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 = collectFinalAssistantMessage(session); try { const promptOptions = options.images?.length ? { images: options.images } : undefined; await session.prompt(prompt, promptOptions); } finally { turnTracking.unsubscribe(); unsubscribeEvents(); collector.unsubscribe(); } const turnLimited = turnTracking.getTurnLimited(); const outcome = resolveAssistantOutcome( collector.getMessage(), turnTracking.getAborted(), turnLimited, ); return { ...outcome, turnLimited }; } // ── main entry ───────────────────────────────────────────────────── export async function runAgent( ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions, ): Promise { // Keep the marker in this async chain across Jiti reloads and cwd cache swaps // without making unrelated parent reloads inert. return withSubagentSpawn(() => runAgentImpl(ctx, type, prompt, options)); } async function runAgentImpl( ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions, ): Promise { const policy = options.acceptedPolicy; const agentConfig = policy.definition; // 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; options.onSessionSetupStarted?.(); let session: AgentSession; try { const env = await detectEnv(options.pi, effectiveCwd); const promptExtras = resolveSystemPromptSources( ctx, effectiveCwd, policy, bufferNotify, ); const systemPrompt = buildPrompt(policy, effectiveCwd, env, promptExtras); const { loader, reload } = createResourceLoader(policy, effectiveCwd, systemPrompt); await reload(); session = await createAndConfigureSession( ctx, options, type, effectiveCwd, loader, bufferNotify, ); } finally { options.onSessionSetupFinished?.(); } if (options.debugFault) { throw new Error(debugFaultMessage(options.debugFault)); } const { unsubscribe: unsubTurns, getAborted, getTurnLimited } = wireTurnTracking(session, { ...options, maxTurns: options.maxTurns ?? agentConfig.maxTurns, }); const finalMessage = await runTurnLoop(session, prompt, options, unsubTurns); const turnLimited = getTurnLimited(); const outcome = resolveAssistantOutcome( finalMessage, getAborted() || options.signal?.aborted === true, turnLimited, ); // 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 { ...outcome, session, turnLimited }; }