Files
oh-my-pi/packages/coding-agent/src/task/executor.ts
T
can1357 cf60e6df51 feat(coding-agent): implemented eval framework and replaced python tool
- Added a unified eval framework with parser grammar, backend interfaces, and JS/Python execution result types.
- Added eval tool docs and updated prompts for fenced cells, `eval.py`/`eval.js`, and fallback behavior.
- Replaced the built-in `python` tool with `eval` across registry, rendering, interactive modes, and tool settings.
- Migrated Python execution runtime from `src/ipy` to `src/eval/py`, renamed state fields, and removed legacy introspection.
- Refactored browser tooling from in-process VM helpers to worker-managed tab supervisors and protocol transport.
- Added eval parser fallback and JS tool-bridge tests, updated imports, and removed obsolete python-mode suites.
2026-04-30 18:08:37 +02:00

1288 lines
40 KiB
TypeScript

/**
* In-process execution for subagents.
*
* Runs each subagent on the main thread and forwards AgentEvents for progress tracking.
*/
import path from "node:path";
import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import type { TSchema } from "@sinclair/typebox";
import Ajv, { type ValidateFunction } from "ajv";
import { ModelRegistry } from "../config/model-registry";
import { resolveModelOverride } from "../config/model-resolver";
import type { PromptTemplate } from "../config/prompt-templates";
import { Settings } from "../config/settings";
import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
import type { CustomTool } from "../extensibility/custom-tools/types";
import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
import type { Skill } from "../extensibility/skills";
import type { LocalProtocolOptions } from "../internal-urls";
import { callTool } from "../mcp/client";
import type { MCPManager } from "../mcp/manager";
import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
import { AgentRegistry } from "../registry/agent-registry";
import { createAgentSession, discoverAuthStorage } from "../sdk";
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
import type { AuthStorage } from "../session/auth-storage";
import { SessionManager } from "../session/session-manager";
import { type ContextFileEntry, truncateTail } from "../tools";
import { jtdToJsonSchema, normalizeSchema } from "../tools/jtd-to-json-schema";
import { ToolAbortError } from "../tools/tool-errors";
import type { EventBus } from "../utils/event-bus";
import { buildNamedToolChoice } from "../utils/tool-choice";
import { subprocessToolRegistry } from "./subprocess-tool-registry";
import {
type AgentDefinition,
type AgentProgress,
MAX_OUTPUT_BYTES,
MAX_OUTPUT_LINES,
type ReviewFinding,
type SingleResult,
TASK_SUBAGENT_EVENT_CHANNEL,
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
TASK_SUBAGENT_PROGRESS_CHANNEL,
} from "./types";
const MCP_CALL_TIMEOUT_MS = 60_000;
const ajv = new Ajv({ allErrors: true, strict: false, logger: false });
/** Agent event types to forward for progress tracking. */
const agentEventTypes = new Set<AgentEvent["type"]>([
"agent_start",
"agent_end",
"turn_start",
"turn_end",
"message_start",
"message_update",
"message_end",
"tool_execution_start",
"tool_execution_update",
"tool_execution_end",
]);
const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent =>
agentEventTypes.has(event.type as AgentEvent["type"]);
function normalizeModelPatterns(value: string | string[] | undefined): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value.map(entry => entry.trim()).filter(Boolean);
}
return value
.split(",")
.map(entry => entry.trim())
.filter(Boolean);
}
function renderIrcPeerRoster(selfId: string): string {
const peers = AgentRegistry.global()
.list()
.filter(ref => ref.id !== selfId && (ref.status === "running" || ref.status === "idle"));
if (peers.length === 0) return "- (no other live agents)";
return peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`).join("\n");
}
function withAbortTimeout<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
if (signal?.aborted) {
return Promise.reject(new ToolAbortError());
}
const { promise: wrappedPromise, resolve, reject } = Promise.withResolvers<T>();
let settled = false;
const timeoutId = setTimeout(() => {
if (settled) return;
settled = true;
reject(new Error(`MCP tool call timed out after ${timeoutMs}ms`));
}, timeoutMs);
const onAbort = () => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
reject(new ToolAbortError());
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
promise.then(resolve, reject).finally(() => {
if (signal) signal.removeEventListener("abort", onAbort);
clearTimeout(timeoutId);
});
return wrappedPromise;
}
function getReportFindingKey(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const title = typeof record.title === "string" ? record.title : null;
const filePath = typeof record.file_path === "string" ? record.file_path : null;
const lineStart = typeof record.line_start === "number" ? record.line_start : null;
const lineEnd = typeof record.line_end === "number" ? record.line_end : null;
const priority = typeof record.priority === "string" ? record.priority : null;
if (!title || !filePath || lineStart === null || lineEnd === null) {
return null;
}
return `${filePath}:${lineStart}:${lineEnd}:${priority ?? ""}:${title}`;
}
/** Options for subagent execution */
export interface ExecutorOptions {
cwd: string;
worktree?: string;
agent: AgentDefinition;
task: string;
assignment?: string;
description?: string;
index: number;
id: string;
modelOverride?: string | string[];
thinkingLevel?: ThinkingLevel;
outputSchema?: unknown;
/** Parent task recursion depth (0 = top-level, 1 = first child, etc.) */
taskDepth?: number;
enableLsp?: boolean;
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
sessionFile?: string | null;
persistArtifacts?: boolean;
artifactsDir?: string;
/** Path to parent conversation context file */
contextFile?: string;
eventBus?: EventBus;
contextFiles?: ContextFileEntry[];
skills?: Skill[];
promptTemplates?: PromptTemplate[];
mcpManager?: MCPManager;
authStorage?: AuthStorage;
modelRegistry?: ModelRegistry;
settings?: Settings;
/** Override local:// protocol options so subagent shares parent's local:// root */
localProtocolOptions?: LocalProtocolOptions;
}
function parseStringifiedJson(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (!trimmed) return value;
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return value;
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function buildOutputValidator(schema: unknown): { validate?: ValidateFunction; error?: string } {
const { normalized, error } = normalizeSchema(schema);
if (error) return { error };
if (normalized === undefined) return {};
const jsonSchema = jtdToJsonSchema(normalized);
try {
return { validate: ajv.compile(jsonSchema as any) };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
function tryParseJsonOutput(text: string): unknown | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
function extractCompletionData(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object") return parsed;
const record = parsed as Record<string, unknown>;
if ("data" in record) {
return record.data;
}
return parsed;
}
function normalizeCompleteData(data: unknown, reportFindings?: ReviewFinding[]): unknown {
let normalized = parseStringifiedJson(data ?? null);
if (
Array.isArray(reportFindings) &&
reportFindings.length > 0 &&
normalized &&
typeof normalized === "object" &&
!Array.isArray(normalized)
) {
const record = normalized as Record<string, unknown>;
if (!("findings" in record)) {
normalized = { ...record, findings: reportFindings };
}
}
return normalized;
}
function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): { data: unknown } | null {
const parsed = tryParseJsonOutput(rawOutput);
if (parsed === undefined) return null;
const candidate = parseStringifiedJson(extractCompletionData(parsed));
if (candidate === undefined) return null;
const { validate, error } = buildOutputValidator(outputSchema);
if (error) return null;
if (validate && !validate(candidate)) return null;
return { data: candidate };
}
export interface YieldItem {
data?: unknown;
status?: "success" | "aborted";
error?: string;
}
interface FinalizeSubprocessOutputArgs {
rawOutput: string;
exitCode: number;
stderr: string;
doneAborted: boolean;
signalAborted: boolean;
yieldItems?: YieldItem[];
reportFindings?: ReviewFinding[];
outputSchema: unknown;
}
interface FinalizeSubprocessOutputResult {
rawOutput: string;
exitCode: number;
stderr: string;
abortedViaYield: boolean;
hasYield: boolean;
}
export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yield with null data.";
export const SUBAGENT_WARNING_MISSING_YIELD =
"SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
let { rawOutput, exitCode, stderr } = args;
const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
let abortedViaYield = false;
const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;
if (hasYield) {
const lastYield = yieldItems[yieldItems.length - 1];
if (lastYield?.status === "aborted") {
abortedViaYield = true;
exitCode = 0;
stderr = lastYield.error || "Subagent aborted task";
try {
rawOutput = JSON.stringify({ aborted: true, error: lastYield.error }, null, 2);
} catch {
rawOutput = `{"aborted":true,"error":"${lastYield.error || "Unknown error"}"}`;
}
} else {
const submitData = lastYield?.data;
if (submitData === null || submitData === undefined) {
rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
} else {
const completeData = normalizeCompleteData(submitData, reportFindings);
try {
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
}
exitCode = 0;
stderr = "";
}
}
} else {
const allowFallback = exitCode === 0 && !doneAborted && !signalAborted;
const { normalized: normalizedSchema, error: schemaError } = normalizeSchema(outputSchema);
const hasOutputSchema = normalizedSchema !== undefined && !schemaError;
const fallback = allowFallback ? resolveFallbackCompletion(rawOutput, outputSchema) : null;
if (fallback) {
const completeData = normalizeCompleteData(fallback.data, reportFindings);
try {
rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
}
exitCode = 0;
stderr = "";
} else if (!hasOutputSchema && allowFallback && rawOutput.trim().length > 0) {
exitCode = 0;
stderr = "";
} else if (exitCode === 0) {
const hasRawOutput = rawOutput.trim().length > 0;
rawOutput = rawOutput ? `${SUBAGENT_WARNING_MISSING_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_MISSING_YIELD;
if (hasOutputSchema || !hasRawOutput) {
exitCode = 1;
stderr = SUBAGENT_WARNING_MISSING_YIELD;
}
}
}
return { rawOutput, exitCode, stderr, abortedViaYield, hasYield };
}
/**
* Extract a short preview from tool args for display.
*/
function extractToolArgsPreview(args: Record<string, unknown>): string {
// Priority order for preview
const previewKeys = ["command", "file_path", "path", "pattern", "query", "url", "task", "prompt"];
for (const key of previewKeys) {
if (args[key] && typeof args[key] === "string") {
const value = args[key] as string;
return value.length > 60 ? `${value.slice(0, 59)}…` : value;
}
}
return "";
}
function getNumberField(record: Record<string, unknown>, key: string): number | undefined {
if (!Object.hasOwn(record, key)) return undefined;
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function firstNumberField(record: Record<string, unknown>, keys: string[]): number | undefined {
for (const key of keys) {
const value = getNumberField(record, key);
if (value !== undefined) return value;
}
return undefined;
}
/**
* Normalize usage objects from different event formats.
*/
function getUsageTokens(usage: unknown): number {
if (!usage || typeof usage !== "object") return 0;
const record = usage as Record<string, unknown>;
const totalTokens = firstNumberField(record, ["totalTokens", "total_tokens"]);
if (totalTokens !== undefined && totalTokens > 0) return totalTokens;
const input = firstNumberField(record, ["input", "input_tokens", "inputTokens"]) ?? 0;
const output = firstNumberField(record, ["output", "output_tokens", "outputTokens"]) ?? 0;
const cacheRead = firstNumberField(record, ["cacheRead", "cache_read", "cacheReadTokens"]) ?? 0;
const cacheWrite = firstNumberField(record, ["cacheWrite", "cache_write", "cacheWriteTokens"]) ?? 0;
return input + output + cacheRead + cacheWrite;
}
/**
* Create proxy tools that reuse the parent's MCP connections.
*/
function createMCPProxyTools(mcpManager: MCPManager): CustomTool<TSchema>[] {
return mcpManager.getTools().map(tool => {
const mcpTool = tool as { mcpToolName?: string; mcpServerName?: string };
return {
name: tool.name,
label: tool.label ?? tool.name,
description: tool.description ?? "",
parameters: tool.parameters as TSchema,
execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
if (signal?.aborted) {
throw new ToolAbortError();
}
const serverName = mcpTool.mcpServerName ?? "";
const mcpToolName = mcpTool.mcpToolName ?? "";
try {
const result = await withAbortTimeout(
(async () => {
const connection = await mcpManager.waitForConnection(serverName);
return callTool(connection, mcpToolName, params as Record<string, unknown>, { signal });
})(),
MCP_CALL_TIMEOUT_MS,
signal,
);
return {
content: (result.content ?? []).map(item =>
item.type === "text"
? { type: "text" as const, text: item.text ?? "" }
: { type: "text" as const, text: JSON.stringify(item) },
),
details: { serverName, mcpToolName, isError: result.isError },
};
} catch (error) {
if (error instanceof ToolAbortError) {
throw error;
}
return {
content: [
{
type: "text" as const,
text: `MCP error: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: { serverName, mcpToolName, isError: true },
};
}
},
};
});
}
function createSubagentSettings(baseSettings: Settings): Settings {
const snapshot: Partial<Record<SettingPath, unknown>> = {};
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
snapshot[key] = baseSettings.get(key);
}
return Settings.isolated({
...snapshot,
"async.enabled": false,
"bash.autoBackground.enabled": false,
});
}
/**
* Run a single agent in-process.
*/
export async function runSubprocess(options: ExecutorOptions): Promise<SingleResult> {
const {
cwd,
agent,
task,
assignment,
index,
id,
worktree,
modelOverride,
thinkingLevel,
outputSchema,
enableLsp,
signal,
onProgress,
} = options;
const startTime = Date.now();
// Initialize progress
const progress: AgentProgress = {
index,
id,
agent: agent.name,
agentSource: agent.source,
status: "running",
task,
assignment,
description: options.description,
lastIntent: undefined,
recentTools: [],
recentOutput: [],
toolCount: 0,
tokens: 0,
durationMs: 0,
modelOverride,
};
// Check if already aborted
if (signal?.aborted) {
return {
index,
id,
agent: agent.name,
agentSource: agent.source,
task,
assignment,
description: options.description,
exitCode: 1,
output: "",
stderr: "Cancelled before start",
truncated: false,
durationMs: 0,
tokens: 0,
modelOverride,
error: "Cancelled before start",
aborted: true,
abortReason: "Cancelled before start",
};
}
// Set up artifact paths and write input file upfront if artifacts dir provided
let subtaskSessionFile: string | undefined;
if (options.artifactsDir) {
subtaskSessionFile = path.join(options.artifactsDir, `${id}.jsonl`);
}
const settings = options.settings ?? Settings.isolated();
const subagentSettings = createSubagentSettings(settings);
const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
const parentDepth = options.taskDepth ?? 0;
const childDepth = parentDepth + 1;
const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
// Add tools if specified
let toolNames: string[] | undefined;
if (agent.tools && agent.tools.length > 0) {
toolNames = agent.tools;
// Auto-include task tool if spawns defined but task not in tools
if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
toolNames = [...toolNames, "task"];
}
}
if (atMaxDepth && toolNames?.includes("task")) {
toolNames = toolNames.filter(name => name !== "task");
}
if (toolNames?.includes("exec")) {
const allowEvalPy = settings.get("eval.py") ?? true;
const allowEvalJs = settings.get("eval.js") ?? true;
const expanded = toolNames.filter(name => name !== "exec");
if (allowEvalPy || allowEvalJs) expanded.push("eval");
expanded.push("bash");
toolNames = Array.from(new Set(expanded));
}
const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
const sessionFile = subtaskSessionFile ?? null;
const spawnsEnv = atMaxDepth
? ""
: agent.spawns === undefined
? ""
: agent.spawns === "*"
? "*"
: agent.spawns.join(",");
const lspEnabled = enableLsp ?? true;
const ircEnabled = subagentSettings.get("irc.enabled") === true;
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
const outputChunks: string[] = [];
const finalOutputChunks: string[] = [];
const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
let recentOutputTail = "";
let stderr = "";
let resolved = false;
type AbortReason = "signal" | "terminate";
let abortSent = false;
let abortReason: AbortReason | undefined;
const listenerController = new AbortController();
const listenerSignal = listenerController.signal;
const abortController = new AbortController();
const abortSignal = abortController.signal;
let activeSession: AgentSession | null = null;
let unsubscribe: (() => void) | null = null;
let yieldCalled = false;
// Accumulate usage incrementally from message_end events (no memory for streaming events)
const accumulatedUsage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
let hasUsage = false;
const requestAbort = (reason: AbortReason) => {
if (abortSent) {
if (reason === "signal" && abortReason !== "signal") {
abortReason = "signal";
}
return;
}
if (resolved) return;
abortSent = true;
abortReason = reason;
abortController.abort();
if (activeSession) {
void activeSession.abort();
}
};
// Handle abort signal
const onAbort = () => {
if (!resolved) requestAbort("signal");
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
}
const resolveSignalAbortReason = (): string => {
const reason = signal?.reason;
if (reason instanceof Error) {
const message = reason.message.trim();
if (message.length > 0) return message;
} else if (typeof reason === "string") {
const message = reason.trim();
if (message.length > 0) return message;
}
return "Cancelled by caller";
};
const PROGRESS_COALESCE_MS = 150;
let lastProgressEmitMs = 0;
let progressTimeoutId: NodeJS.Timeout | null = null;
const emitProgressNow = () => {
progress.durationMs = Date.now() - startTime;
onProgress?.({ ...progress });
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
index,
agent: agent.name,
agentSource: agent.source,
task,
assignment,
progress: { ...progress },
sessionFile: subtaskSessionFile,
});
}
lastProgressEmitMs = Date.now();
};
const scheduleProgress = (flush = false) => {
if (flush) {
if (progressTimeoutId) {
clearTimeout(progressTimeoutId);
progressTimeoutId = null;
}
emitProgressNow();
return;
}
const now = Date.now();
const elapsed = now - lastProgressEmitMs;
if (lastProgressEmitMs === 0 || elapsed >= PROGRESS_COALESCE_MS) {
if (progressTimeoutId) {
clearTimeout(progressTimeoutId);
progressTimeoutId = null;
}
emitProgressNow();
return;
}
if (progressTimeoutId) return;
progressTimeoutId = setTimeout(() => {
progressTimeoutId = null;
emitProgressNow();
}, PROGRESS_COALESCE_MS - elapsed);
};
const getMessageContent = (message: unknown): unknown => {
if (message && typeof message === "object" && "content" in message) {
return (message as { content?: unknown }).content;
}
return undefined;
};
const getMessageUsage = (message: unknown): unknown => {
if (message && typeof message === "object" && "usage" in message) {
return (message as { usage?: unknown }).usage;
}
return undefined;
};
const updateRecentOutputLines = () => {
const lines = recentOutputTail.split("\n").filter(line => line.trim());
progress.recentOutput = lines.slice(-8).reverse();
};
const appendRecentOutputTail = (text: string) => {
if (!text) return;
recentOutputTail += text;
if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
}
updateRecentOutputLines();
};
const replaceRecentOutputFromContent = (content: unknown[]) => {
recentOutputTail = "";
for (const block of content) {
if (!block || typeof block !== "object") continue;
const record = block as { type?: unknown; text?: unknown };
if (record.type !== "text" || typeof record.text !== "string") continue;
if (!record.text) continue;
recentOutputTail += record.text;
if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
}
}
updateRecentOutputLines();
};
const resetRecentOutput = () => {
recentOutputTail = "";
progress.recentOutput = [];
};
const processEvent = (event: AgentEvent) => {
if (resolved) return;
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
index,
agent: agent.name,
agentSource: agent.source,
task,
assignment,
event,
});
}
const now = Date.now();
let flushProgress = false;
switch (event.type) {
case "message_start":
if (event.message?.role === "assistant") {
resetRecentOutput();
}
break;
case "tool_execution_start": {
progress.toolCount++;
progress.currentTool = event.toolName;
progress.currentToolArgs = extractToolArgsPreview(
(event as { toolArgs?: Record<string, unknown> }).toolArgs || event.args || {},
);
progress.currentToolStartMs = now;
const intent = event.intent?.trim();
if (intent) {
progress.lastIntent = intent;
}
break;
}
case "tool_execution_end": {
if (progress.currentTool) {
progress.recentTools.unshift({
tool: progress.currentTool,
args: progress.currentToolArgs || "",
endMs: now,
});
// Keep only last 5
if (progress.recentTools.length > 5) {
progress.recentTools.pop();
}
}
progress.currentTool = undefined;
progress.currentToolArgs = undefined;
progress.currentToolStartMs = undefined;
// Check for registered subagent tool handler
const handler = subprocessToolRegistry.getHandler(event.toolName);
const eventArgs = (event as { args?: Record<string, unknown> }).args ?? {};
if (handler) {
// Extract data using handler
if (handler.extractData) {
const data = handler.extractData({
toolName: event.toolName,
toolCallId: event.toolCallId,
args: eventArgs,
result: event.result,
isError: event.isError,
});
if (data !== undefined) {
progress.extractedToolData = progress.extractedToolData || {};
const existing = progress.extractedToolData[event.toolName] || [];
const findingKey = event.toolName === "report_finding" ? getReportFindingKey(data) : null;
if (findingKey) {
const existingIndex = existing.findIndex(item => getReportFindingKey(item) === findingKey);
if (existingIndex >= 0) {
existing[existingIndex] = data;
} else {
existing.push(data);
}
} else {
existing.push(data);
}
progress.extractedToolData[event.toolName] = existing;
if (event.toolName === "yield") {
yieldCalled = true;
}
}
}
// Check if handler wants to terminate the session
if (
handler.shouldTerminate?.({
toolName: event.toolName,
toolCallId: event.toolCallId,
args: eventArgs,
result: event.result,
isError: event.isError,
})
) {
requestAbort("terminate");
}
}
flushProgress = true;
break;
}
case "message_update": {
if (event.message?.role !== "assistant") break;
const assistantEvent = (
event as AgentEvent & {
assistantMessageEvent?: { type?: string; delta?: string };
}
).assistantMessageEvent;
if (assistantEvent?.type === "text_delta" && typeof assistantEvent.delta === "string") {
appendRecentOutputTail(assistantEvent.delta);
break;
}
if (assistantEvent && assistantEvent.type !== "text_delta") {
break;
}
const updateContent =
getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
if (updateContent && Array.isArray(updateContent)) {
replaceRecentOutputFromContent(updateContent);
}
break;
}
case "message_end": {
// Extract text from assistant and toolResult messages (not user prompts)
const role = event.message?.role;
if (role === "assistant") {
const messageContent =
getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
if (messageContent && Array.isArray(messageContent)) {
for (const block of messageContent) {
if (block.type === "text" && block.text) {
outputChunks.push(block.text);
}
}
}
}
// Extract and accumulate usage (prefer message.usage, fallback to event.usage)
const messageUsage = getMessageUsage(event.message) || (event as AgentEvent & { usage?: unknown }).usage;
if (messageUsage && typeof messageUsage === "object") {
// Only count assistant messages (not tool results, etc.)
if (role === "assistant") {
const usageRecord = messageUsage as Record<string, unknown>;
const costRecord = (messageUsage as { cost?: Record<string, unknown> }).cost;
hasUsage = true;
accumulatedUsage.input += getNumberField(usageRecord, "input") ?? 0;
accumulatedUsage.output += getNumberField(usageRecord, "output") ?? 0;
accumulatedUsage.cacheRead += getNumberField(usageRecord, "cacheRead") ?? 0;
accumulatedUsage.cacheWrite += getNumberField(usageRecord, "cacheWrite") ?? 0;
accumulatedUsage.totalTokens += getNumberField(usageRecord, "totalTokens") ?? 0;
if (costRecord) {
accumulatedUsage.cost.input += getNumberField(costRecord, "input") ?? 0;
accumulatedUsage.cost.output += getNumberField(costRecord, "output") ?? 0;
accumulatedUsage.cost.cacheRead += getNumberField(costRecord, "cacheRead") ?? 0;
accumulatedUsage.cost.cacheWrite += getNumberField(costRecord, "cacheWrite") ?? 0;
accumulatedUsage.cost.total += getNumberField(costRecord, "total") ?? 0;
}
}
// Accumulate tokens for progress display
progress.tokens += getUsageTokens(messageUsage);
}
break;
}
case "agent_end":
// Extract final content from assistant messages only (not user prompts)
if (event.messages && Array.isArray(event.messages)) {
for (const msg of event.messages) {
if ((msg as { role?: string })?.role !== "assistant") continue;
const messageContent = getMessageContent(msg);
if (messageContent && Array.isArray(messageContent)) {
for (const block of messageContent) {
if (block.type === "text" && block.text) {
finalOutputChunks.push(block.text);
}
}
}
}
}
flushProgress = true;
break;
}
scheduleProgress(flushProgress);
};
const runSubagent = async (): Promise<{
exitCode: number;
error?: string;
aborted?: boolean;
abortReason?: string;
durationMs: number;
}> => {
const sessionAbortController = new AbortController();
let exitCode = 0;
let error: string | undefined;
let aborted = false;
let abortReasonText: string | undefined;
const checkAbort = () => {
if (abortSignal.aborted) {
aborted = abortReason === "signal" || abortReason === undefined;
if (aborted) {
abortReasonText ??= resolveSignalAbortReason();
}
exitCode = 1;
throw new ToolAbortError();
}
};
try {
checkAbort();
const authStorage = options.authStorage ?? (await discoverAuthStorage());
checkAbort();
const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage);
await modelRegistry.refresh();
checkAbort();
const {
model,
thinkingLevel: resolvedThinkingLevel,
explicitThinkingLevel,
} = resolveModelOverride(modelPatterns, modelRegistry, settings);
const effectiveThinkingLevel = explicitThinkingLevel
? resolvedThinkingLevel
: (thinkingLevel ?? resolvedThinkingLevel);
const sessionManager = sessionFile
? await SessionManager.open(sessionFile)
: SessionManager.inMemory(worktree ?? cwd);
const mcpProxyTools = options.mcpManager ? createMCPProxyTools(options.mcpManager) : [];
const enableMCP = !options.mcpManager;
const { normalized: normalizedOutputSchema } = normalizeSchema(outputSchema);
const { session } = await createAgentSession({
cwd: worktree ?? cwd,
authStorage,
modelRegistry,
settings: subagentSettings,
model,
thinkingLevel: effectiveThinkingLevel,
toolNames,
outputSchema,
requireYieldTool: true,
contextFiles: options.contextFiles,
skills: options.skills,
promptTemplates: options.promptTemplates,
systemPrompt: defaultPrompt =>
prompt.render(subagentSystemPromptTemplate, {
base: defaultPrompt,
agent: agent.systemPrompt,
worktree: worktree ?? "",
outputSchema: normalizedOutputSchema,
contextFile: options.contextFile,
ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
ircSelfId: ircEnabled ? id : "",
}),
sessionManager,
hasUI: false,
spawns: spawnsEnv,
taskDepth: childDepth,
parentTaskPrefix: id,
agentId: id,
agentDisplayName: agent.name,
enableLsp: lspEnabled,
skipPythonPreflight,
enableMCP,
mcpManager: options.mcpManager,
customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
localProtocolOptions: options.localProtocolOptions,
});
activeSession = session;
// Emit lifecycle start event
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id,
agent: agent.name,
agentSource: agent.source,
description: options.description,
status: "started",
sessionFile: subtaskSessionFile,
index,
});
}
const subagentToolNames = session.getActiveToolNames();
const parentOwnedToolNames = new Set(["todo_write"]);
const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
if (filteredSubagentTools.length !== subagentToolNames.length) {
await session.setActiveToolsByName(filteredSubagentTools);
}
session.sessionManager.appendSessionInit({
systemPrompt: session.agent.state.systemPrompt,
task,
tools: session.getActiveToolNames(),
outputSchema,
});
abortSignal.addEventListener(
"abort",
() => {
void session.abort();
},
{ once: true, signal: sessionAbortController.signal },
);
const extensionRunner = session.extensionRunner;
if (extensionRunner) {
extensionRunner.initialize(
{
sendMessage: (message, options) => {
session.sendCustomMessage(message, options).catch(e => {
logger.error("Extension sendMessage failed", {
error: e instanceof Error ? e.message : String(e),
});
});
},
sendUserMessage: (content, options) => {
session.sendUserMessage(content, options).catch(e => {
logger.error("Extension sendUserMessage failed", {
error: e instanceof Error ? e.message : String(e),
});
});
},
appendEntry: (customType, data) => {
session.sessionManager.appendCustomEntry(customType, data);
},
setLabel: (targetId, label) => {
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) =>
session.setActiveToolsByName(toolNames.filter(name => !parentOwnedToolNames.has(name))),
getCommands: () => [],
setModel: model => runExtensionSetModel(session, model),
getThinkingLevel: () => session.thinkingLevel,
setThinkingLevel: level => session.setThinkingLevel(level),
getSessionName: () => session.sessionManager.getSessionName(),
setSessionName: async name => {
await session.sessionManager.setSessionName(name, "user");
},
},
{
getModel: () => session.model,
isIdle: () => !session.isStreaming,
abort: () => session.abort(),
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
},
);
extensionRunner.onError(err => {
logger.error("Extension error", { path: err.extensionPath, error: err.error });
});
await extensionRunner.emit({ type: "session_start" });
}
const MAX_YIELD_RETRIES = 3;
unsubscribe = session.subscribe(event => {
if (isAgentEvent(event)) {
try {
processEvent(event);
} catch (err) {
logger.error("Subagent event processing failed", {
error: err instanceof Error ? err.message : String(err),
});
requestAbort("terminate");
}
}
});
await session.prompt(task, { attribution: "agent" });
await session.waitForIdle();
const reminderToolChoice = buildNamedToolChoice("yield", session.model);
let retryCount = 0;
while (!yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
try {
retryCount++;
const reminder = prompt.render(submitReminderTemplate, {
retryCount,
maxRetries: MAX_YIELD_RETRIES,
});
await session.prompt(reminder, {
attribution: "agent",
...(reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
});
await session.waitForIdle();
} catch (err) {
logger.error("Subagent prompt failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
await session.waitForIdle();
if (!yieldCalled && !abortSignal.aborted) {
exitCode = 0;
}
const lastAssistant = session.getLastAssistantMessage();
if (lastAssistant) {
if (lastAssistant.stopReason === "aborted") {
aborted = abortReason === "signal" || abortReason === undefined;
if (aborted) {
abortReasonText ??= resolveSignalAbortReason();
}
exitCode = 1;
} else if (lastAssistant.stopReason === "error") {
exitCode = 1;
error ??= lastAssistant.errorMessage || "Subagent failed";
}
}
} catch (err) {
exitCode = 1;
if (!abortSignal.aborted) {
error = err instanceof Error ? err.stack || err.message : String(err);
}
} finally {
if (abortSignal.aborted) {
aborted = abortReason === "signal" || abortReason === undefined;
if (aborted) {
abortReasonText ??= resolveSignalAbortReason();
}
if (exitCode === 0) exitCode = 1;
}
sessionAbortController.abort();
if (unsubscribe) {
try {
unsubscribe();
} catch {
// Ignore unsubscribe errors
}
unsubscribe = null;
}
if (activeSession) {
const session = activeSession;
activeSession = null;
try {
await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
} catch {
// Ignore cleanup errors
}
}
}
return {
exitCode,
error,
aborted,
abortReason: aborted ? abortReasonText : undefined,
durationMs: Date.now() - startTime,
};
};
const done = await runSubagent();
resolved = true;
listenerController.abort();
if (progressTimeoutId) {
clearTimeout(progressTimeoutId);
progressTimeoutId = null;
}
let exitCode = done.exitCode;
if (done.error) {
stderr = done.error;
}
// Use final output if available, otherwise accumulated output
let rawOutput = finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join("");
const yieldItems = progress.extractedToolData?.yield as YieldItem[] | undefined;
const reportFindings = progress.extractedToolData?.report_finding as ReviewFinding[] | undefined;
const finalized = finalizeSubprocessOutput({
rawOutput,
exitCode,
stderr,
doneAborted: Boolean(done.aborted),
signalAborted: Boolean(signal?.aborted),
yieldItems,
reportFindings,
outputSchema,
});
rawOutput = finalized.rawOutput;
exitCode = finalized.exitCode;
stderr = finalized.stderr;
const lastYield = yieldItems?.[yieldItems.length - 1];
const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
const { abortedViaYield, hasYield } = finalized;
const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
maxBytes: MAX_OUTPUT_BYTES,
maxLines: MAX_OUTPUT_LINES,
});
// Write output artifact (input and jsonl already written in real-time)
// Compute output metadata for agent:// URL integration
let outputMeta: { lineCount: number; charCount: number } | undefined;
let outputPath: string | undefined;
if (options.artifactsDir) {
outputPath = path.join(options.artifactsDir, `${id}.md`);
try {
await Bun.write(outputPath, rawOutput);
outputMeta = {
lineCount: rawOutput.split("\n").length,
charCount: rawOutput.length,
};
} catch {
// Non-fatal
}
}
// Update final progress
const wasAborted = abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
const finalAbortReason = wasAborted
? abortedViaYield
? yieldAbortReason
: (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
: undefined;
progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
scheduleProgress(true);
// Emit lifecycle end event after finalization so yield status is reflected
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id,
agent: agent.name,
agentSource: agent.source,
description: options.description,
status: progress.status as "completed" | "failed" | "aborted",
sessionFile: subtaskSessionFile,
index,
});
}
return {
index,
id,
agent: agent.name,
agentSource: agent.source,
task,
assignment,
description: options.description,
lastIntent: progress.lastIntent,
exitCode,
output: truncatedOutput,
stderr,
truncated: Boolean(truncated),
durationMs: Date.now() - startTime,
tokens: progress.tokens,
modelOverride,
error: exitCode !== 0 && stderr ? stderr : undefined,
aborted: wasAborted,
abortReason: finalAbortReason,
usage: hasUsage ? accumulatedUsage : undefined,
outputPath,
extractedToolData: progress.extractedToolData,
outputMeta,
};
}