/** * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results. */ import type { Api, Model } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type AgentSession, type AgentSessionEvent, createAgentSession, DefaultResourceLoader, type ExtensionAPI, getAgentDir, SessionManager, SettingsManager, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { applyCavemanRpc } from "./caveman-rpc.js"; import { buildParentContext, extractText } from "./context.js"; import { DEFAULT_PARENT_SESSION_ID, parentBridge } from "./parent-bridge.js"; import { buildSelectedAgentLaunchConfig } from "./selected-agent-launch-config.js"; import type { SubagentType, ThinkingLevel } from "./types.js"; const NOOP = () => { /* noop */ }; function sdkExpectsToolAllowlist(): boolean { return SettingsManager.create.length >= 1; } interface ToolCallContentBlock { type: "toolCall"; name?: string; toolName?: string; } function getToolCallName(content: ToolCallContentBlock): string { return content.name ?? content.toolName ?? "unknown"; } /** 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. */ let graceTurns = 5; /** 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); } /** 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; /** Stable runtime ID for parent bridge routing. */ agentId?: string; /** Parent session affinity for bridge delivery. */ parentSessionId?: string; /** Whether the subagent may block on ask_parent. */ allowAskParent?: boolean; /** Extra tools available only for this run. */ customTools?: ToolDefinition[]; model?: Model; maxTurns?: number; signal?: AbortSignal; isolated?: boolean; inheritContext?: boolean; thinkingLevel?: ThinkingLevel; /** Override working directory (e.g. for worktree isolation). */ cwd?: 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 when a notable run tag is resolved. */ onRunTag?: (tag: string) => void; /** Called when non-fatal warnings are collected. */ onWarning?: (warnings: string[]) => void; /** Whether warnings should surface as transient UI notifications. */ notifyWarnings?: boolean; } 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; /** Non-fatal warnings produced while preparing or running the agent. */ warnings: string[]; } /** * 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 }; } /** 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 NOOP; } const onAbort = () => session.abort(); signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } function notifyWarning(ctx: ExtensionContext, message: string): void { if (ctx.hasUI) { ctx.ui.notify(message, "warning"); } } function createParentBridgeTools( agentId: string, parentSessionId = DEFAULT_PARENT_SESSION_ID, allowAskParent = true ) { const tools = [ { name: "message_parent", label: "Message Parent", description: "Queue a one-way message for the parent agent.", parameters: Type.Object({ message: Type.String({ description: "The message to send to the parent agent.", }), }), execute(_toolCallId: string, params: unknown) { const { message } = params as { message: string }; const queued = parentBridge.messageParent(agentId, message, { sessionId: parentSessionId, }); return Promise.resolve({ content: [ { type: "text" as const, text: `Queued message for parent (${queued.requestId}).`, }, ], details: { requestId: queued.requestId }, }); }, }, ]; if (!allowAskParent) { return tools; } tools.push({ name: "ask_parent", label: "Ask Parent", description: "Ask the parent agent a question and wait for a reply.", parameters: Type.Object({ message: Type.String({ description: "The question or request for the parent agent.", }), timeout_ms: Type.Optional( Type.Number({ description: "Optional timeout in milliseconds while waiting for the parent reply.", minimum: 1, }) ), }), async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) { const { message, timeout_ms } = params as { message: string; timeout_ms?: number; }; const reply = await parentBridge.askParent(agentId, message, { sessionId: parentSessionId, signal, timeoutMs: timeout_ms, }); return { content: [{ type: "text" as const, text: reply.text }], details: { requestId: reply.requestId }, }; }, }); return tools; } export async function runAgent( ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions ): Promise { const launchConfig = await buildSelectedAgentLaunchConfig(type, { pi: options.pi, cwd: options.cwd, parentCwd: ctx.cwd, parentSystemPrompt: ctx.getSystemPrompt(), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, model: options.model, maxTurns: options.maxTurns, isolated: options.isolated, thinkingLevel: options.thinkingLevel, }); const { agentConfig, effectiveCwd, extensions, noSkills, model, thinkingLevel, } = launchConfig; let { systemPrompt } = launchConfig; const builtinTools = launchConfig.builtinTools; const customTools: ToolDefinition[] = options.agentId ? createParentBridgeTools( options.agentId, options.parentSessionId, options.allowAskParent ) : []; if (options.customTools) { customTools.push(...options.customTools); } const warnings: string[] = []; const shouldNotifyWarnings = options.notifyWarnings !== false; const addWarning = (warning: string) => { warnings.push(warning); options.onWarning?.([...warnings]); if (shouldNotifyWarnings) { notifyWarning(ctx, warning); } }; for (const warning of agentConfig.frontmatterWarnings ?? []) { addWarning(warning); } if (agentConfig.caveman !== undefined) { const caveman = await applyCavemanRpc( options.pi.events, systemPrompt, agentConfig.caveman ); systemPrompt = caveman.systemPrompt; options.onRunTag?.(caveman.tag); if (caveman.warning) { addWarning(caveman.warning); } } // When skills is string[], buildSelectedAgentLaunchConfig preloads them into the prompt. // Still pass noSkills: true since we don't need the skill loader to load them again. const agentDir = getAgentDir(); // Load extensions/skills: true or string[] → load; false → don't. // Explicit agentDir works around pi 0.68.x DefaultResourceLoader passing an // undefined user-scope base dir into DefaultPackageManager for local packages. // 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 forked context (conversation). const loader = new DefaultResourceLoader({ cwd: effectiveCwd, agentDir, noExtensions: extensions === false, noSkills, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => systemPrompt, appendSystemPromptOverride: () => [], }); await loader.reload(); const localToolNames = [ ...new Set([...builtinTools, ...customTools].map((tool) => tool.name)), ]; const sessionOpts: Record = { cwd: effectiveCwd, sessionManager: SessionManager.inMemory(effectiveCwd), settingsManager: SettingsManager.create(effectiveCwd), modelRegistry: ctx.modelRegistry, model, customTools: customTools.length > 0 ? customTools : undefined, resourceLoader: loader, }; sessionOpts.tools = sdkExpectsToolAllowlist() ? localToolNames : builtinTools; if (thinkingLevel) { sessionOpts.thinkingLevel = thinkingLevel; } // createAgentSession's type signature may not include thinkingLevel yet const { session } = await createAgentSession( sessionOpts as Parameters[0] ); // Build disallowed tools set from agent config const disallowedSet = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined; // Filter active tools: remove our own tools to prevent nesting, // apply extension allowlist if specified, and apply disallowedTools denylist if (extensions !== false) { const localToolNameSet = new Set(localToolNames); const activeTools = session.getActiveToolNames().filter((t) => { if (launchConfig.excludedToolNames.includes(t)) { return false; } if (disallowedSet?.has(t)) { return false; } if (localToolNameSet.has(t)) { return true; } if (Array.isArray(extensions)) { return extensions.some((ext) => t.startsWith(ext) || t.includes(ext)); } return true; }); session.setActiveToolsByName(activeTools); } else if (disallowedSet) { // Even with extensions disabled, apply denylist to built-in tools const activeTools = session .getActiveToolNames() .filter((t) => !disallowedSet.has(t)); session.setActiveToolsByName(activeTools); } // Bind extensions so that session_start fires and extensions can initialize // (e.g. loading credentials, setting up state). Placed after tool filtering // so extension-provided skills/prompts from extendResourcesFromExtensions() // respect the active tool set. 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( launchConfig.explicitMaxTurns ? launchConfig.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 }); } }); const collector = collectResponseText(session); 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; } } try { await session.prompt(effectivePrompt); } finally { unsubTurns(); collector.unsubscribe(); cleanupAbort(); } const responseText = collector.getText().trim() || getLastAssistantText(session); return { responseText, session, aborted, steered: softLimitReached, warnings, }; } /** * Send a new prompt to an existing session (resume). */ export async function resumeAgent( session: AgentSession, prompt: string, options: { onToolActivity?: (activity: ToolActivity) => void; signal?: AbortSignal; } = {} ): Promise { const collector = collectResponseText(session); const cleanupAbort = forwardAbortSignal(session, options.signal); const unsubToolUse = options.onToolActivity ? 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 }); } }) : NOOP; try { await session.prompt(prompt); } finally { collector.unsubscribe(); unsubToolUse(); cleanupAbort(); } return collector.getText().trim() || getLastAssistantText(session); } /** * 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: ${getToolCallName(c)}`); } } 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"); }