/** * @pi-unipi/subagents — Agent runner * * Creates sessions, runs agents, collects results. * Forwards abort signals for ESC propagation. */ import type { Model } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type AgentSession, type AgentSessionEvent, type CreateAgentSessionOptions, createAgentSession, DefaultResourceLoader, type ExtensionAPI, getAgentDir, type ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent"; import { BUILTIN_CONFIGS, type AgentConfig, type AgentType, type ThinkingLevel } from "./types.js"; /** Coerce frontmatter thinking (string | false | ThinkingLevel) to ThinkingLevel. */ export function coerceThinkingLevel(raw: AgentConfig["thinking"]): ThinkingLevel | undefined { if (raw === false || raw === undefined || raw === null) return undefined; if (typeof raw === "string") { const valid: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; return valid.includes(raw as ThinkingLevel) ? (raw as ThinkingLevel) : undefined; } return raw; } /** Tools excluded from subagents to prevent nesting. Legacy names remain for older hosts. */ const EXCLUDED_TOOL_NAMES = ["spawn_helper", "get_helper_result", "Agent", "get_result"]; /** All known built-in tool names. */ const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"]; /** Grace turns after the soft turn limit before aborting. */ const GRACE_TURNS = 5; /** Tool activity info. */ export interface ToolActivity { type: "start" | "end"; toolName: string; } /** Options for running an agent. */ export interface RunOptions { pi: ExtensionAPI; model?: Model; agentConfig?: AgentConfig; maxTurns?: number; signal?: AbortSignal; isolated?: boolean; thinkingLevel?: ThinkingLevel; cwd?: string; onToolActivity?: (activity: ToolActivity) => void; onTextDelta?: (delta: string, fullText: string) => void; onSessionCreated?: (session: AgentSession) => void; onTurnEnd?: (turnCount: number) => void; } /** Result from running an agent. */ export interface RunResult { responseText: string; session: AgentSession; aborted: boolean; steered: boolean; } /** Collect last assistant message text. */ 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 last assistant text from 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 = msg.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("") .trim(); if (text) return text; } return ""; } /** Wire abort signal to session. */ 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); } /** Get tool names for agent type. */ function getToolNamesForType(type: AgentType, config?: AgentConfig): string[] { if (config?.builtinToolNames?.length) { return [...config.builtinToolNames]; } return [...BUILTIN_TOOL_NAMES]; } /** Resolve agent config for a type. * Priority: explicit config > builtin config > default */ function resolveAgentConfig(type: AgentType, explicitConfig?: AgentConfig): AgentConfig | undefined { if (explicitConfig) return explicitConfig; return BUILTIN_CONFIGS[type]; } /** * Run an agent session. */ export async function runAgent( ctx: ExtensionContext, type: AgentType, prompt: string, options: RunOptions, ): Promise { const effectiveCwd = options.cwd ?? ctx.cwd; // Resolve agent config const agentConfig = resolveAgentConfig(type, options.agentConfig); const parentSystemPrompt = ctx.getSystemPrompt(); // Build system prompt using config or defaults let systemPrompt: string; if (agentConfig?.systemPrompt && agentConfig.promptMode === "replace") { systemPrompt = agentConfig.systemPrompt; } else if (options.isolated) { const base = agentConfig?.systemPrompt ?? `You are a ${type} agent.`; systemPrompt = `${base} Follow the task instructions precisely. Do not ask questions.`; } else { const agentPrompt = agentConfig?.systemPrompt ?? `You are a ${type} agent.`; systemPrompt = parentSystemPrompt + `\n\n${agentPrompt}`; } // Get tool names from config let toolNames = getToolNamesForType(type, agentConfig); // Create resource loader // Respect agentConfig.extensions/skills flags: if explicitly false, skip loading. // This prevents explore/work agents from loading all parent extensions. const agentDir = getAgentDir(); const skipExtensions = options.isolated || agentConfig?.extensions === false; const skipSkills = options.isolated || agentConfig?.skills === false; const loader = new DefaultResourceLoader({ cwd: effectiveCwd, agentDir, noExtensions: skipExtensions, noSkills: skipSkills, noPromptTemplates: true, noThemes: true, noContextFiles: true, systemPromptOverride: () => systemPrompt, appendSystemPromptOverride: () => [], }); await loader.reload(); // Resolve model const model = options.model ?? ctx.model; // Create session // SDK >=0.84: CreateAgentSessionOptions takes `modelRuntime` (the canonical // ModelRuntime) instead of the removed `modelRegistry` option. The extension // context exposes only the ModelRegistry facade, whose `runtime` field is the // same ModelRuntime the parent session runs on. const sessionOpts: CreateAgentSessionOptions = { cwd: effectiveCwd, agentDir, sessionManager: SessionManager.inMemory(effectiveCwd), settingsManager: SettingsManager.create(effectiveCwd, agentDir), modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime, model, tools: toolNames, resourceLoader: loader, }; if (options.thinkingLevel) { sessionOpts.thinkingLevel = options.thinkingLevel; } const { session } = await createAgentSession(sessionOpts); // Filter out our tools to prevent nesting const activeTools = session.getActiveToolNames().filter((t) => { if (EXCLUDED_TOOL_NAMES.includes(t)) return false; return true; }); session.setActiveToolsByName(activeTools); // Bind extensions — only if extensions were loaded. // Skipping for agents with extensions: false avoids firing session_start // on an empty extension set, preventing unnecessary MODULE_READY cascade. if (!skipExtensions) { await session.bindExtensions({ onError: (err) => { options.onToolActivity?.({ type: "end", toolName: `extension-error:${err.extensionPath}`, }); }, }); } options.onSessionCreated?.(session); // Track turns let turnCount = 0; const maxTurns = options.maxTurns; 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 + GRACE_TURNS) { 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); try { await session.prompt(prompt); } finally { unsubTurns(); collector.unsubscribe(); cleanupAbort(); } const responseText = collector.getText().trim() || getLastAssistantText(session); return { responseText, session, aborted, steered: softLimitReached }; }