/** * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results. */ import { AsyncLocalStorage } from "node:async_hooks"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, resolve } from "node:path"; import type { Model } from "@earendil-works/pi-ai"; import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent"; import { type AgentSession, type AgentSessionEvent, createAgentSession, DefaultResourceLoader, type ExtensionAPI, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent"; import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js"; import { buildParentContext, extractText } from "./context.js"; import { DEFAULT_AGENTS } from "./default-agents.js"; import { detectEnv } from "./env.js"; import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js"; import { buildAgentPrompt, type PromptExtras } from "./prompts.js"; import { preloadSkills } from "./skill-loader.js"; import type { SubagentType, ThinkingLevel } from "./types.js"; /** * Tool names registered by THIS extension. Single source of truth so the * registration sites (index.ts) and the subagent exclusion list below can't * drift apart. These are our own tools, not pi built-ins, so they can't be * derived from pi — but they only need defining once. */ export const SUBAGENT_TOOL_NAMES = { AGENT: "Agent", GET_RESULT: "get_subagent_result", STEER: "steer_subagent", } as const; /** Names of tools registered by this extension that subagents must NOT inherit. */ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES); /** * LOCAL PATCH (pi-pi): subagent sessions load the same extensions in the same * process as the root session, so pi-pi's extensions need to know which of the * two they are being instantiated for. A plain global flag cannot express that — * the host also re-instantiates every extension when the root session is * switched (/new, /resume, fork), which is indistinguishable from a subagent * load. An async scope is: extension factories only ever run inside the * `loader.reload()` below for a subagent, and never inside it for the root. */ function subagentSessionScope(): AsyncLocalStorage<{ depth: number }> { const registry = globalThis as Record; const key = Symbol.for("pi-pi:subagent-session-scope"); return (registry[key] ??= new AsyncLocalStorage<{ depth: number }>()) as AsyncLocalStorage<{ depth: number }>; } /** * Canonical name of an extension for `extensions: [...]` allowlist matching. * Lowercased — extension names match case-insensitively so `extensions: [Mcp]` * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected. * Directory extensions (`foo/index.ts`) resolve to the parent directory name; * single-file extensions to the basename minus `.ts`/`.js`. */ export function extensionCanonicalName(extPath: string): string { const base = basename(extPath); const name = base === "index.ts" || base === "index.js" ? basename(dirname(extPath)) : base.replace(/\.(ts|js)$/, ""); return name.toLowerCase(); } /** * Classify `extensions: string[]` frontmatter entries for the loader-level filter. * * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions). * * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths` * — and their canonical name is also added to `names`. The loader override matches * everything by canonical name, so path-loaded extensions are matched via their name * rather than their post-staging `Extension.path`. */ export function parseExtensionsSpec( entries: string[], cwd: string, ): { names: Set; paths: string[]; wildcard: boolean } { const names = new Set(); const paths: string[] = []; let wildcard = false; for (const entry of entries) { if (!entry) continue; if (entry === "*") { wildcard = true; continue; } const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~"); if (!isPathEntry) { names.add(entry.toLowerCase()); continue; } let p = entry; if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { p = homedir() + p.slice(1); } const abs = isAbsolute(p) ? p : resolve(cwd, p); paths.push(abs); names.add(extensionCanonicalName(abs)); } return { names, paths, wildcard }; } /** * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of * extension names to keep loaded and a per-extension tool-narrowing map. * * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools). * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`). * A name lands in `narrowing` only when a `/tool` form is seen, so a bare * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins). * The split is on the first `/`; extension canonical names never contain `/`. */ export function parseExtSelectors(entries: string[]): { extNames: Set; narrowing: Map>; } { const extNames = new Set(); const narrowing = new Map>(); for (const raw of entries) { if (!raw) continue; const body = raw.slice("ext:".length); const slash = body.indexOf("/"); // Extension name matches case-insensitively (matches the loader-side canonical // name). Tool names are case-preserved — they're matched against pi-mono's // registered identifiers, which are case-sensitive. const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase(); if (!name) continue; extNames.add(name); if (slash === -1) continue; const tool = body.slice(slash + 1).trim(); if (!tool) continue; let set = narrowing.get(name); if (!set) { set = new Set(); narrowing.set(name, set); } set.add(tool); } return { extNames, narrowing }; } /** Default max turns. undefined = unlimited (no turn limit). */ let defaultMaxTurns: number | undefined; /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */ export function normalizeMaxTurns(n: number | undefined): number | undefined { if (n == null || n === 0) return undefined; return Math.max(1, n); } /** Get the default max turns value. undefined = unlimited. */ export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; } /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */ export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); } /** * Additional turns allowed after the soft limit steer message. * * LOCAL PATCH (pi-pi): upstream defaults this to 5. pi-pi's reviewers must still * write their output file after tripping the soft limit, which 5 turns is not * always enough for — an abort mid-writeup surfaces as an INCOMPLETE stub. * A subtree update reverts this silently; re-apply after upgrading. */ let graceTurns = 10; /** Get the grace turns value. */ export function getGraceTurns(): number { return graceTurns; } /** Set the grace turns value (minimum 1). */ export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); } /** * Try to find the right model for an agent type. * Priority: explicit option > config.model > parent model. */ function resolveDefaultModel( parentModel: Model | undefined, registry: { find(provider: string, modelId: string): Model | undefined; getAvailable?(): Model[] }, configModel?: string, ): Model | undefined { if (configModel) { const slashIdx = configModel.indexOf("/"); if (slashIdx !== -1) { const provider = configModel.slice(0, slashIdx); const modelId = configModel.slice(slashIdx + 1); // Build a set of available model keys for fast lookup const available = registry.getAvailable?.(); const availableKeys = available ? new Set(available.map((m: any) => `${m.provider}/${m.id}`)) : undefined; const isAvailable = (p: string, id: string) => !availableKeys || availableKeys.has(`${p}/${id}`); const found = registry.find(provider, modelId); if (found && isAvailable(provider, modelId)) return found; } } return parentModel; } /** Info about a tool event in the subagent. */ export interface ToolActivity { type: "start" | "end"; toolName: string; } export interface RunOptions { /** ExtensionAPI instance — used for pi.exec() instead of execSync. */ pi: ExtensionAPI; /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */ agentId?: string; model?: Model; maxTurns?: number; signal?: AbortSignal; isolated?: boolean; inheritContext?: boolean; thinkingLevel?: ThinkingLevel; /** Override working directory (e.g. for worktree isolation). */ cwd?: string; /** * Where .pi config is discovered (project extensions, skills, pi settings, * agent memory). Default: same as the working directory. The manager sets * this to the parent session's cwd when `SpawnOptions.cwd` points the * working directory elsewhere — the agent works *there* but carries the * parent project's config (the target's `.pi` extensions never execute). * * WARNING for future callers: if you pass `cwd` pointing at a directory the * user didn't open, you almost certainly must pass `configCwd` too — * omitting it makes the target's `.pi` extensions execute in this process. * (Worktree isolation is the one intentional exception: its copy IS the * parent's repo, so config resolving inside it is correct.) */ configCwd?: string; /** Called on tool start/end with activity info. */ onToolActivity?: (activity: ToolActivity) => void; /** Called on streaming text deltas from the assistant response. */ onTextDelta?: (delta: string, fullText: string) => void; onSessionCreated?: (session: AgentSession) => void; /** Called at the end of each agentic turn with the cumulative count. */ onTurnEnd?: (turnCount: number) => void; /** * Called after the agent finishes but before the run is considered complete. * Return a non-empty string to re-prompt the agent with that message; return * undefined to accept completion. Called up to `maxValidationRetries` times. * * LOCAL PATCH (pi-pi): not part of upstream pi-subagents. A subtree update * silently reverted it once, leaving every caller's callback dead while the * option was still forwarded — re-apply it after upgrading. See * test/agent-runner.test.ts "validateCompletion". */ validateCompletion?: () => string | undefined; maxValidationRetries?: number; /** * Called once per assistant message_end with that message's usage delta. * Lets callers maintain a lifetime accumulator that survives compaction * (which replaces session.state.messages and resets stats-derived sums). */ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; /** * Called when the session successfully compacts. `tokensBefore` is upstream's * pre-compaction context size estimate. Aborted compactions don't fire. */ onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void; } export interface RunResult { responseText: string; session: AgentSession; /** True if the agent was hard-aborted (max_turns + grace exceeded). */ aborted: boolean; /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */ steered: boolean; } /** * 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) { 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; } }); return { getText: () => text, unsubscribe }; } /** * Total attempts (initial + retries) for a turn that comes back empty. * The upstream gateway intermittently answers a tool-calling request with a * successful-but-empty completion (no text, no tool calls, 0 completion tokens), * independently per request — so re-prompting the same session almost always * recovers, and three attempts leave a negligible residual failure rate. */ const EMPTY_TURN_ATTEMPTS = 3; const EMPTY_TURN_NUDGE = "Your last turn produced no output. Provide your final answer now."; /** * LOCAL PATCH (pi-pi): a compaction started from an extension aborts the run it * fires in (AgentSession.compact() disconnects from the agent and awaits an * abort), so a worker whose context is compacted between two tool-calling turns * would otherwise return the narration it had streamed so far as its answer. * The cut is waited out and the run re-prompted, bounded because each resume * costs a full request and a wedged compaction must not spin. */ const COMPACTION_RESUME_NUDGE = "Your context was compacted mid-task, which cut your tool loop short. Continue exactly where you left off; do not restart the work or re-report what is already done."; const MAX_COMPACTION_RESUMES = 10; const COMPACTION_WAIT_MS = 120_000; const COMPACTION_POLL_MS = 100; /** * LOCAL PATCH (pi-pi): track extension-triggered compactions across one * `session.prompt()`. `prompt()` resolves as soon as the aborted loop settles, * which is before the compaction it raced has finished, so the caller has to be * able to wait for the cut. A compaction that FAILS still aborted the run, so * the resume hangs on the compaction having started, not on its result — but a * compaction still RUNNING would replace the message array out from under * anything prompted now, so it is not a cut to resume from either. Polled, not * awaited outright: a stop the user asked for has to land now, not after the * compaction it interrupted. */ function watchCompactionCuts(session: AgentSession): { cutRun: (isStopped: () => boolean) => Promise; unsubscribe: () => void } { let started = false; let running = false; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "compaction_start" && event.reason === "manual") { started = true; running = true; return; } if (event.type === "compaction_end" && event.reason === "manual") running = false; }); return { cutRun: async (isStopped: () => boolean) => { const deadline = Date.now() + COMPACTION_WAIT_MS; while (running && !isStopped() && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, COMPACTION_POLL_MS)); } const cut = started && !running; started = false; return cut; }, unsubscribe, }; } /** * Message prefix consumers match to distinguish an exhausted empty-turn retry * from a genuine model/API error. The class name does not survive the RPC * boundary (only `error` as a string does), so the prefix is the contract. */ export const EMPTY_RESPONSE_ERROR_PREFIX = "Agent produced no output"; /** * Thrown when every attempt came back empty. The manager's existing `.catch` * maps a thrown error to `status: "error"` + `record.error`, so the failure * renders through the error path instead of looking like a successful run with * an empty result. Both runAgent and resumeAgent throw this, which is why the * boundary is an exception rather than widened result metadata: resumeAgent * returns a bare string and has no room for a flag. */ export class EmptyResponseError extends Error { constructor(attempts: number, tokensSpent: number) { super( `${EMPTY_RESPONSE_ERROR_PREFIX} after ${attempts} attempts (${tokensSpent} tokens spent). ` + "The model returned a successful but empty response each time — no text and no tool calls.", ); this.name = "EmptyResponseError"; } } /** * Prompt a session, retrying while the terminal turn comes back empty. * * A fresh collector is subscribed per attempt: collectResponseText accumulates * into a closure, so reusing one across attempts would concatenate them or lose * the retry's text. An external stop/abort is never retried — a user-initiated * halt legitimately produces no answer. */ async function promptWithEmptyRetry( session: AgentSession, prompt: string, isStopped: () => boolean, ): Promise { let tokensSpent = 0; const unsubUsage = session.subscribe((event: AgentSessionEvent) => { if (event.type === "message_end" && event.message.role === "assistant") { const u = (event.message as any).usage; if (u) tokensSpent += (u.input ?? 0) + (u.output ?? 0); } }); const compactions = watchCompactionCuts(session); try { for (let attempt = 1; attempt <= EMPTY_TURN_ATTEMPTS; attempt++) { let collector = collectResponseText(session); let historyBefore = session.messages.length; try { await session.prompt(attempt === 1 ? prompt : EMPTY_TURN_NUDGE); // LOCAL PATCH (pi-pi): a compaction aborted the run mid tool loop, and // the answer is still owed. Each resume gets its own collector and // history mark: the compaction replaced the message array, so both the // streamed text and the index captured before it describe a run that no // longer exists. for (let resume = 0; resume < MAX_COMPACTION_RESUMES; resume++) { if (isStopped() || !await compactions.cutRun(isStopped)) break; collector.unsubscribe(); collector = collectResponseText(session); historyBefore = session.messages.length; await session.prompt(COMPACTION_RESUME_NUDGE); } } finally { collector.unsubscribe(); } const terminalMessage = [...session.messages.slice(historyBefore)] .reverse() .find((message: any) => message?.role === "assistant") as any; if (terminalMessage?.stopReason === "error") { throw new Error(terminalMessage.errorMessage || "The model provider returned an unspecified error."); } const text = collector.getText().trim() || terminalAssistantText(session, historyBefore); if (text) return text; if (isStopped()) return ""; } throw new EmptyResponseError(EMPTY_TURN_ATTEMPTS, tokensSpent); } finally { unsubUsage(); compactions.unsubscribe(); } } /** * Text of the FINAL assistant message appended at or after `fromIndex`. * * One `prompt()` spans many turns, so an attempt appends several assistant * messages and only its last one is the answer: an earlier turn's narration * ("I've initiated a data retrieval request...") must not satisfy a terminal * turn that came back empty, or the retry is silently defeated. Bounding by * `fromIndex` likewise excludes a resumed session's previous answer. */ function terminalAssistantText(session: AgentSession, fromIndex: number): string { for (let i = session.messages.length - 1; i >= fromIndex; i--) { if (session.messages[i].role !== "assistant") continue; return extractText(session.messages[i].content).trim(); } 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 = () => session.abort(); signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined { if (!sessionDir) return undefined; if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2)); if (isAbsolute(sessionDir)) return sessionDir; return resolve(cwd, sessionDir); } export async function runAgent( ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions, ): Promise { const config = getConfig(type); const agentConfig = getAgentConfig(type); // Resolve working directory: worktree override > parent cwd const effectiveCwd = options.cwd ?? ctx.cwd; // Filesystem work happens in effectiveCwd; config discovery in configCwd. // They differ only for SpawnOptions.cwd spawns (config stays with the parent). const configCwd = options.configCwd ?? effectiveCwd; const env = await detectEnv(options.pi, effectiveCwd); // Get parent system prompt for append-mode agents const parentSystemPrompt = ctx.getSystemPrompt(); // Build prompt extras (memory, skill preloading) const extras: PromptExtras = {}; // Resolve extensions/skills: isolated overrides to false const extensions = options.isolated ? false : config.extensions; // Nulling excludes under isolated also suppresses the orphaned-exclude warning — // isolation is an intentional override, not a misconfiguration. const excludeExtensions = options.isolated ? undefined : config.excludeExtensions; const skills = options.isolated ? false : config.skills; // Skill preloading: when skills is string[], preload their content into prompt if (Array.isArray(skills)) { const loaded = preloadSkills(skills, configCwd); if (loaded.length > 0) { extras.skillBlocks = loaded; } } let toolNames = getToolNamesForType(type); // Persistent memory: detect write capability and branch accordingly. // Account for disallowedTools — a tool in the base set but on the denylist is not truly available. if (agentConfig?.memory) { const existingNames = new Set(toolNames); const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined; const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name); const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit"); if (hasWriteTools) { // Read-write memory: add any missing memory tool names (read/write/edit) const extraNames = getMemoryToolNames(existingNames); if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames]; extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd); } else { // Read-only memory: only add read tool name, use read-only prompt const extraNames = getReadOnlyMemoryToolNames(existingNames); if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames]; extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd); } } // Build system prompt from agent config let systemPrompt: string; if (agentConfig) { systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras); } else { // Unknown type fallback: spread the canonical general-purpose config (defensive — // unreachable in practice since index.ts resolves unknown types before calling runAgent). const fallback = DEFAULT_AGENTS.get("general-purpose"); if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`); systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras); } // When skills is string[], we've already preloaded them into the prompt. // Still pass noSkills: true since we don't need the skill loader to load them again. const noSkills = skills === false || Array.isArray(skills); const agentDir = getAgentDir(); // Extension loading: // - true → all default-discovered extensions // - false → none (noExtensions) // - string[] → loader-level allowlist. Bare names keep the matching // default-discovered extension; path entries load that extension fresh; // "*" keeps all default-discovered extensions. Excluded extensions never // bind handlers or register tools (their factory still runs once). // // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which // would defeat prompt_mode: replace and isolated: true. Parent context, if // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt // is embedded in systemPromptOverride) or inherit_context (conversation). // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to // the LLM. They do NOT control loading — `extensions:` is the sole authority for // which extensions load. `ext:foo` against an extension that `extensions:` excluded // is an orphan and warns after reload. `isolated` means no extension tools at all. const { extNames, narrowing } = parseExtSelectors( options.isolated ? [] : (agentConfig?.extSelectors ?? []), ); const noExtensions = extensions === false; const extensionsSpec = Array.isArray(extensions) ? parseExtensionsSpec(extensions, configCwd) : undefined; const keepNames = extensionsSpec?.names ?? new Set(); // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins. // Plain canonical names only (case-insensitive). Note: excluded extensions' // factories still run once during reload() (see comment above) — exclusion // suppresses handler binding and tool registration; it is not a sandbox. const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase())); const hasExcludes = excludeNames.size > 0; // The override filters loaded extensions down to `keepNames` minus `excludeNames`. // It's only needed when we're neither loading everything without excludes // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`). const loadAll = extensions === true || extensionsSpec?.wildcard === true; const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined; // Pre-filter discovered set, captured by the override — the exclude-typo warning // must compare against this, not the surviving set (absence from survivors is // an exclude *succeeding*). let discoveredNames: Set | undefined; const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined = noExtensions || (loadAll && !hasExcludes) ? undefined : (base) => { discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path))); return { ...base, extensions: base.extensions.filter((e) => { const name = extensionCanonicalName(e.path); if (excludeNames.has(name)) return false; // exclude wins return loadAll || keepNames.has(name); }), }; }; const loader = new DefaultResourceLoader({ cwd: configCwd, agentDir, noExtensions, additionalExtensionPaths, extensionsOverride, noSkills, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => systemPrompt, appendSystemPromptOverride: () => [], }); const scope = subagentSessionScope(); await scope.run({ depth: (scope.getStore()?.depth ?? 0) + 1 }, () => loader.reload()); // Plain entries in `tools:` are expected to be built-in names (extension tools // go through `ext:`), so an unknown name there is unambiguously a typo. Previously // this produced a silently broken agent (#75) — pi-mono accepted the bogus name // into the allowlist, then dropped it at registration with no signal back. if (agentConfig?.builtinToolNames?.length) { const knownBuiltins = new Set(BUILTIN_TOOL_NAMES); for (const name of agentConfig.builtinToolNames) { if (!knownBuiltins.has(name)) { options.onToolActivity?.({ type: "end", toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`, }); } } } // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather // than aborts. Two distinct misconfigurations to catch: // - `extensions: [foo]` but no extension named foo was discovered (typo or // path that failed to load — path entries fold their canonical name into // `keepNames`, so this covers them too). // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:` // didn't include it). Since v0.9, `ext:` no longer pulls extensions in; // loading is `extensions:`-authoritative. // An exclude_extensions: alongside extensions: false is contradictory — nothing // loads, so there is nothing to exclude. if (hasExcludes && noExtensions) { options.onToolActivity?.({ type: "end", toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`, }); } // Exclude typo check: compares against the PRE-filter discovered set (an excluded // name absent from the surviving set is the exclude working as intended). Also // flags path-like and "*" entries — excludes are plain names only. if (hasExcludes && discoveredNames) { for (const name of excludeNames) { if (!discoveredNames.has(name)) { options.onToolActivity?.({ type: "end", toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`, }); } } } if (keepNames.size > 0 || extNames.size > 0) { const survivingNames = new Set( loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)), ); for (const name of keepNames) { if (!survivingNames.has(name)) { options.onToolActivity?.({ type: "end", toolName: excludeNames.has(name) ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins` : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`, }); } } for (const name of extNames) { if (!survivingNames.has(name)) { options.onToolActivity?.({ type: "end", toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`, }); } } } // Resolve model: explicit option > config.model > parent model const model = options.model ?? resolveDefaultModel( ctx.model, ctx.modelRegistry, agentConfig?.model, ); // Resolve thinking level: explicit option > agent config > undefined (inherit) const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking; const disallowedSet = agentConfig?.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined; // Enumerate extension-registered tool names from the loaded resource loader. // Extensions populate `extension.tools` during `loader.reload()` and the set // is stable afterwards — `bindExtensions` does not register new tools. // // Opt-in flip: when any `ext:` selector is present, extension tools become an // explicit allowlist — a loaded extension not named by a selector contributes // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`. const extensionToolNames: string[] = []; if (!noExtensions) { const optInActive = extNames.size > 0; for (const extension of loader.getExtensions().extensions) { const canon = extensionCanonicalName(extension.path); if (optInActive && !extNames.has(canon)) continue; const narrowed = narrowing.get(canon); for (const toolName of extension.tools.keys()) { if (narrowed && !narrowed.has(toolName)) continue; extensionToolNames.push(toolName); } } } // Build the master tool allowlist applied at session construction. // pi-mono's `allowedToolNames` gates BOTH registration and the initial active // set, so listing the exact final set here means the session is correctly // scoped from the first instant — no post-construction narrowing required. const builtinToolNameSet = new Set(toolNames); const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => { if (EXCLUDED_TOOL_NAMES.includes(t)) return false; if (disallowedSet?.has(t)) return false; if (builtinToolNameSet.has(t)) return true; // Reached only for extension tools. The extension set was already filtered // at the loader (extensionsOverride / noExtensions) and at enumeration // (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed. return !noExtensions; }); const settingsManager = SettingsManager.create(configCwd, agentDir); const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd); const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.(); const sessionManager = agentConfig?.persistSession ? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir) : SessionManager.inMemory(effectiveCwd); const sessionOpts: Parameters[0] = { cwd: effectiveCwd, agentDir, sessionManager, settingsManager, modelRegistry: ctx.modelRegistry, model, tools: allowedTools, resourceLoader: loader, }; if (thinkingLevel) { sessionOpts.thinkingLevel = thinkingLevel; } const { session } = await createAgentSession(sessionOpts); const baseSessionName = agentConfig?.name ?? type; session.setSessionName( options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName, ); // Bind extensions so that session_start fires and extensions can initialize // (e.g. loading credentials, setting up state). Tool gating already happened // at session construction via the `tools:` allowlist above — no separate // post-bind filter is needed. All ExtensionBindings fields are optional. await session.bindExtensions({ onError: (err) => { options.onToolActivity?.({ type: "end", toolName: `extension-error:${err.extensionPath}`, }); }, }); options.onSessionCreated?.(session); // Track turns for graceful max_turns enforcement let turnCount = 0; const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns); let softLimitReached = false; let aborted = false; let currentMessageText = ""; const unsubTurns = session.subscribe((event: AgentSessionEvent) => { if (event.type === "turn_end") { turnCount++; options.onTurnEnd?.(turnCount); if (maxTurns != null) { 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(); } } } if (event.type === "message_start") { currentMessageText = ""; } if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { currentMessageText += event.assistantMessageEvent.delta; options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText); } 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 u = (event.message as any).usage; if (u) options.onAssistantUsage?.({ input: u.input ?? 0, output: u.output ?? 0, cacheWrite: u.cacheWrite ?? 0, }); } if (event.type === "compaction_end" && !event.aborted && event.result) { options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore }); } }); const cleanupAbort = forwardAbortSignal(session, options.signal); // Build the effective prompt: optionally prepend parent context let effectivePrompt = prompt; if (options.inheritContext) { const parentContext = buildParentContext(ctx); if (parentContext) { effectivePrompt = parentContext + prompt; } } let responseText: string; try { // A hard abort (max_turns + grace) or an external signal is a legitimate // reason to have no answer, so those are not retried. Hitting the SOFT turn // limit is not: a steered wrap-up that comes back empty is the same silent // failure as any other empty turn and must not surface as an empty success. const isStopped = () => aborted || options.signal?.aborted === true; responseText = await promptWithEmptyRetry(session, effectivePrompt, isStopped); if (options.validateCompletion) { const maxRetries = options.maxValidationRetries ?? 2; for (let attempt = 0; attempt < maxRetries; attempt++) { if (isStopped()) break; const error = options.validateCompletion(); if (!error) break; responseText = await promptWithEmptyRetry(session, error, isStopped); } } } finally { unsubTurns(); cleanupAbort(); } return { responseText, session, aborted, steered: softLimitReached }; } /** * Send a new prompt to an existing session (resume). */ export async function resumeAgent( session: AgentSession, prompt: string, options: { onToolActivity?: (activity: ToolActivity) => void; onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void; signal?: AbortSignal; } = {}, ): Promise { const cleanupAbort = forwardAbortSignal(session, options.signal); const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction) ? 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 u = (event.message as any).usage; if (u) options.onAssistantUsage?.({ input: u.input ?? 0, output: u.output ?? 0, cacheWrite: u.cacheWrite ?? 0, }); } if (event.type === "compaction_end" && !event.aborted && event.result) { options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore }); } }) : () => {}; try { return await promptWithEmptyRetry(session, prompt, () => options.signal?.aborted === true); } finally { unsubEvents(); cleanupAbort(); } } /** * Send a steering message to a running subagent. * The message will interrupt the agent after its current tool execution. */ export async function steerAgent( session: AgentSession, message: string, ): Promise { await session.steer(message); } /** * Get the subagent's conversation messages as formatted text. */ export function getAgentConversation(session: AgentSession): string { const parts: string[] = []; for (const msg of session.messages) { if (msg.role === "user") { const text = typeof msg.content === "string" ? msg.content : extractText(msg.content); if (text.trim()) parts.push(`[User]: ${text.trim()}`); } else if (msg.role === "assistant") { const textParts: string[] = []; const toolCalls: string[] = []; for (const c of msg.content) { if (c.type === "text" && c.text) textParts.push(c.text); else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`); } if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`); if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`); } else if (msg.role === "toolResult") { const text = extractText(msg.content); const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text; parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`); } } return parts.join("\n\n"); }