/** * Host-side handler for the eval `agent()` helper. */ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { prompt, Snowflake } from "@oh-my-pi/pi-utils"; import * as z from "zod/v4"; import { resolveAgentModelPatterns } from "../config/model-resolver"; import type { LocalProtocolOptions } from "../internal-urls"; import { MCPManager } from "../mcp/manager"; import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" }; import * as taskDiscovery from "../task/discovery"; import * as taskExecutor from "../task/executor"; import { AgentOutputManager } from "../task/output-manager"; import type { AgentDefinition, AgentProgress, SingleResult } from "../task/types"; import type { ToolSession } from "../tools"; import { ToolError } from "../tools/tool-errors"; import { withBridgeTimeoutPause } from "./bridge-timeout"; import type { JsStatusEvent } from "./js/shared/types"; // Import review tools for side effects (registers subagent tool handlers). import "../tools/review"; /** Synthetic bridge name reserved for the `agent()` helper across both runtimes. */ export const EVAL_AGENT_BRIDGE_NAME = "__agent__"; /** Hard recursion limit for eval-driven subagents. */ export const EVAL_AGENT_MAX_DEPTH = 3; const DEFAULT_AGENT_TYPE = "task"; const DEFAULT_AGENT_LABEL = "EvalAgent"; const agentArgsSchema = z.object({ prompt: z.string().min(1, "prompt must be a non-empty string"), agentType: z.string().min(1).optional(), model: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), label: z.string().optional(), schema: z.unknown().optional(), }); interface EvalAgentArgs { prompt: string; agentType?: string; model?: string | string[]; label?: string; schema?: unknown; } export interface EvalAgentBridgeOptions { session: ToolSession; signal?: AbortSignal; emitStatus?: (event: JsStatusEvent) => void; } export interface EvalAgentResult { text: string; details: { agent: string; id: string; model?: string | string[]; structured: boolean; }; } function parseAgentArgs(args: unknown): EvalAgentArgs { const parsed = agentArgsSchema.safeParse(args); if (!parsed.success) { const issue = parsed.error.issues[0]; const where = issue?.path.length ? `${issue.path.join(".")}: ` : ""; throw new ToolError(`agent() received invalid arguments: ${where}${issue?.message ?? "bad input"}`); } return parsed.data; } function assertDepthAllowed(session: ToolSession): void { const taskDepth = session.taskDepth ?? 0; if (taskDepth >= EVAL_AGENT_MAX_DEPTH) { throw new ToolError( `agent() cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${EVAL_AGENT_MAX_DEPTH}.`, ); } } function assertSpawnAllowed(session: ToolSession, agentName: string): void { const parentSpawns = session.getSessionSpawns() ?? "*"; if (parentSpawns === "*") return; if (parentSpawns === "") { throw new ToolError(`Cannot spawn '${agentName}'. Allowed: none (spawns disabled for this agent)`); } const allowedSpawns = parentSpawns.split(",").map(spawn => spawn.trim()); if (!allowedSpawns.includes(agentName)) { throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${parentSpawns}`); } } function assertAgentEnabled(session: ToolSession, agentName: string, agents: AgentDefinition[]): void { const disabledAgents = session.settings.get("task.disabledAgents") as string[]; if (!disabledAgents.includes(agentName)) return; const enabled = agents.filter(agent => !disabledAgents.includes(agent.name)).map(agent => agent.name); throw new ToolError( `Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`, ); } function assertNotPlanMode(session: ToolSession): void { if (session.getPlanModeState?.()?.enabled) { throw new ToolError("agent() is unavailable in plan mode."); } } function renderSubagentPrompt(assignment: string): string { return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim(), independentMode: false }); } function trimToUndefined(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } function outputIdBase(label: string | undefined, agentName: string): string { const source = trimToUndefined(label) ?? agentName ?? DEFAULT_AGENT_LABEL; const sanitized = source.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 48); return sanitized || DEFAULT_AGENT_LABEL; } function getOutputManager(session: ToolSession): AgentOutputManager { if (session.agentOutputManager) return session.agentOutputManager; const manager = new AgentOutputManager(session.getArtifactsDir ?? (() => null)); session.agentOutputManager = manager; return manager; } async function getArtifacts(session: ToolSession): Promise<{ sessionFile: string | null; artifactsDir: string; }> { const sessionFile = session.getSessionFile(); const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null; const artifactsDir = sessionArtifactsDir ?? path.join(os.tmpdir(), `omp-eval-agent-${Snowflake.next()}`); await fs.mkdir(artifactsDir, { recursive: true }); return { sessionFile, artifactsDir }; } function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undefined, progress: AgentProgress): void { if (!emitStatus) return; const preview = (progress.assignment ?? progress.task ?? "").split("\n")[0]?.slice(0, 120); emitStatus({ op: "agent", id: progress.id, agent: progress.agent, status: progress.status, lastIntent: progress.lastIntent, currentTool: progress.currentTool, currentToolArgs: progress.currentToolArgs, taskPreview: preview || undefined, toolCount: progress.toolCount, tokens: progress.tokens, contextTokens: progress.contextTokens, contextWindow: progress.contextWindow, cost: progress.cost, durationMs: progress.durationMs, model: progress.resolvedModel, }); } /** * Coalesce a subagent failure into a non-empty, human-meaningful error message. * * When the executor aborts a subagent (runtime limit, parent cancellation, …) * the actionable explanation lives on `abortReason`, while `error`/`stderr` * are routinely empty strings. Plain `??` coalescing stops at the empty string * and ships an empty error through the bridge — Python then surfaces only the * generic `bridge call '__agent__' failed`. See #2006. */ function buildSubagentFailureMessage(agentName: string, result: SingleResult): string { const abortReason = trimToUndefined(result.abortReason); if (result.aborted && abortReason) return abortReason; return ( trimToUndefined(result.error) ?? trimToUndefined(result.stderr) ?? abortReason ?? `agent() subagent '${agentName}' failed.` ); } /** * Run a single subagent on behalf of an eval cell's `agent()` call. */ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise { const parsed = parseAgentArgs(args); const agentName = parsed.agentType ?? DEFAULT_AGENT_TYPE; const structured = Object.hasOwn(parsed, "schema"); assertNotPlanMode(options.session); assertDepthAllowed(options.session); assertSpawnAllowed(options.session, agentName); const turnBudget = options.session.getTurnBudget?.(); if (turnBudget?.hard && turnBudget.total !== null && turnBudget.spent >= turnBudget.total) { throw new ToolError( `agent() blocked: turn token budget exhausted (${turnBudget.spent}/${turnBudget.total} output tokens). Raise or drop the +Nk! ceiling to continue.`, ); } const { agents } = await taskDiscovery.discoverAgents(options.session.cwd); const agent = taskDiscovery.getAgent(agents, agentName); if (!agent) { const available = agents.map(candidate => candidate.name).join(", ") || "none"; throw new ToolError(`Unknown agent "${agentName}". Available: ${available}`); } assertAgentEnabled(options.session, agentName, agents); const effectiveAgent = agent; const parentActiveModelPattern = options.session.getActiveModelString?.(); const agentModelOverrides = options.session.settings.get("task.agentModelOverrides"); const modelOverride = resolveAgentModelPatterns({ settingsOverride: parsed.model ?? agentModelOverrides[agentName], agentModel: effectiveAgent.model, settings: options.session.settings, activeModelPattern: parentActiveModelPattern, fallbackModelPattern: options.session.getModelString?.(), }); const availableSkills = [...(options.session.skills ?? [])]; const resolvedAutoloadSkills = effectiveAgent.autoloadSkills?.length && availableSkills.length > 0 ? effectiveAgent.autoloadSkills .map(name => availableSkills.find(skill => skill.name === name)) .filter((skill): skill is NonNullable => skill !== undefined) : []; const contextFiles = options.session.contextFiles?.filter( file => path.basename(file.path).toLowerCase() !== "agents.md", ); const localProtocolOptions: LocalProtocolOptions = options.session.localProtocolOptions ?? { getArtifactsDir: options.session.getArtifactsDir ?? (() => null), getSessionId: options.session.getSessionId ?? (() => null), }; const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined; const mcpManager = options.session.mcpManager ?? MCPManager.instance(); const { sessionFile, artifactsDir } = await getArtifacts(options.session); const outputManager = getOutputManager(options.session); const id = await outputManager.allocate(outputIdBase(parsed.label, agentName)); const assignment = parsed.prompt.trim(); // Suspend eval timeout accounting while the subagent owns control. The // timeout clock restarts once the bridge returns to the cell runtime. const result = await withBridgeTimeoutPause(options.emitStatus, () => taskExecutor.runSubprocess({ cwd: options.session.cwd, agent: effectiveAgent, task: renderSubagentPrompt(assignment), assignment, description: trimToUndefined(parsed.label), index: 0, id, taskDepth: options.session.taskDepth ?? 0, modelOverride, parentActiveModelPattern, thinkingLevel: effectiveAgent.thinkingLevel, outputSchema: structured ? parsed.schema : undefined, sessionFile, persistArtifacts: Boolean(sessionFile), artifactsDir, // Eval `agent()` subagents are short-lived programmatic helpers (data // collection, structured output, parallel() fan-out). LSP server // cold-start costs tens of seconds and is pure overhead here, so it is // forced off regardless of the `task.enableLsp` setting — that knob only // governs LSP-aware delegation through the `task` tool. enableLsp: false, signal: options.signal, eventBus: options.session.eventBus, onProgress: progress => emitProgressStatus(options.emitStatus, progress), authStorage: options.session.authStorage, modelRegistry: options.session.modelRegistry, settings: options.session.settings, // Eval `agent()` subagents are never wall-clock capped: the parent // cell's idle watchdog is suspended for the whole bridge call // (withBridgeTimeoutPause), so a long-running phase/recovery workflow // must not be killed by `task.maxRuntimeMs`. Force the limit off // regardless of the inherited session setting. maxRuntimeMs: 0, mcpManager, contextFiles, skills: availableSkills, autoloadSkills: resolvedAutoloadSkills, workspaceTree: options.session.workspaceTree, promptTemplates: options.session.promptTemplates, localProtocolOptions, parentArtifactManager, parentHindsightSessionState: options.session.getHindsightSessionState?.(), parentMnemopiSessionState: options.session.getMnemopiSessionState?.(), parentTelemetry: options.session.getTelemetry?.(), // Deliberately omit parentEvalSessionId: the parent's Python kernel is // blocked on this bridge call, so sharing the eval session would deadlock // (subagent queues behind the parent's in-flight execution, parent waits // for subagent → circular). Each bridge-spawned subagent gets its own // eval session with an independent kernel. }), ); if (result.exitCode !== 0 || result.error || result.aborted) { throw new ToolError(buildSubagentFailureMessage(agentName, result)); } options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0); // The final `onProgress` flush from `runSubprocess` already emits a // status:"completed" event carrying full stats (toolCount, cost, context), // so we don't emit a second, sparser completion event here — it would // coalesce over the richer one and drop those stats. return { text: result.output, details: { agent: result.agent, id: result.id, model: result.resolvedModel ?? modelOverride, structured, }, }; }