import { subprocessToolRegistry } from "./subprocess-tool-registry"; import { isTaskToolDetails } from "@oh-my-pi/pi-tui/tools/task"; import { taskSubprocessRenderer } from "@oh-my-pi/pi-tui/tools/subprocess"; /** * Task tool - Delegate tasks to specialized agents. * * Discovers agent definitions from: * - Bundled agents (shipped with omp-coding-agent) * - ~/.omp/agent/agents/*.md (user-level) * - .omp/agents/*.md (project-level) * * Supports: * - Single agent spawn per call (parallelism = parallel task calls) * - Batch spawning + shared context per call when `task.batch` is enabled * - Background execution through AsyncJobManager when `async.enabled` is enabled * - Progress tracking via JSON events * - Session artifacts for debugging */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback, ToolSpeculationPolicy, } from "@oh-my-pi/pi-agent-core"; import type { Usage } from "@oh-my-pi/pi-ai"; import { $env, hasFsCode, isRecord, logger, prompt, sanitizeText } from "@oh-my-pi/pi-utils"; import type { ToolSession } from ".."; import type { EffectiveExtensionRoots } from "../capability/types"; import type { Settings } from "../config/settings"; import type { Theme } from "@oh-my-pi/pi-tui/theme"; import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" }; import taskAsyncContractTemplate from "../prompts/tools/task-async-contract.md" with { type: "text" }; import taskCoordinationAdvisoryTemplate from "../prompts/tools/task-coordination-advisory.md" with { type: "text" }; import taskSpawnFeedbackTemplate from "../prompts/tools/task-spawn-feedback.md" with { type: "text" }; import taskSpecializationAdvisoryTemplate from "../prompts/tools/task-specialization-advisory.md" with { type: "text" }; import taskFollowUpTemplate from "../prompts/tools/task-follow-up.md" with { type: "text" }; import { TASK_EFFORTS, type TaskEffort } from "@oh-my-pi/pi-tui/thinking"; import { truncateForPrompt } from "../tools/approval"; import { hasWaitTool } from "../tools/wait"; import { isIrcEnabled } from "../irc/messaging"; import { isReadOnlyAgent } from "./read-only-policy"; import { formatTaskResultSummary } from "./result-summary"; import { isScoutSpawnable, type ResolvedSpawnPolicy, resolveSpawnPolicy } from "./spawn-policy"; import { isBundledSeance, seanceTaskGuidance, seanceTaskSchemaFields, validateSeanceParams } from "./seance"; import { SEANCE_AGENT_NAME } from "./seance-policy"; import { type AgentDefinition, canSpawnAtDepth, getTaskSchema, MAX_TASK_FILE_BYTES, type TaskToolSchemaInstance, } from "./types"; import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails } from "@oh-my-pi/pi-tui/tools/task"; import { AsyncJobError, type AsyncJobManagerApi } from "../async"; import { hasResolvableTranscript } from "../internal-urls/registry-helpers"; import { sessionResolveContext } from "../internal-urls/context"; import { InternalUrlRouter } from "../internal-urls/router"; import { AgentRegistry, isResumable } from "../registry/agent-registry"; import { type DiscoveryResult, discoverAgents } from "./discovery"; import { createEvalCustomTools, describeEvalTools, evalToolsEnabled } from "./eval-tools"; import { approvedTaskFile, bindTaskFileApproval, taskFileApprovalPath, taskFileMatchesApproval, taskFileRestrictionPaths, } from "./file-approval"; import { getPerCallModelAdvertisement, type PerCallModelAdvertisement } from "./model-approval"; import { generateTaskName } from "./name-generator"; import { AgentOutputManager } from "./output-manager"; import { mapWithConcurrencyLimitAllSettled, Semaphore } from "./parallel"; import { renderResult, renderCall as renderTaskCall } from "@oh-my-pi/pi-tui/tools/task"; import { repairTaskParams } from "@oh-my-pi/pi-tui/tools/task-repair-args"; import { invalidModelSelectorReason, normalizeModelSelector, renderSubagentUserPrompt, resolveEffectiveSubagentPolicy, runStructuredSubagent, StructuredSubagentError, type StructuredSubagentRequest, } from "./structured-subagent"; import { SpawnRun, type SpawnPermit } from "./spawn-run"; import { type TaskLauncher, TaskLaunchSession } from "./speculative-launch"; import type { TextTap } from "../job-widgets/text-tap"; import type { SubagentToolResult } from "./executor"; import { appendToolResult, createTaskWidgetInputs } from "./widget-inputs"; import { cfgAsyncEnabled } from "../tools/settings"; import { cfgTaskBatch, cfgTaskDisabledAgents, cfgTaskEnableEffort, cfgTaskEnableLsp, cfgTaskFile, cfgTaskIsolationApply, cfgTaskIsolationEnabled, cfgTaskMaxConcurrency, cfgTaskMaxRecursionDepth, cfgTaskMaxRuntimeMs, cfgTaskSpeculativeLaunch, cfgTaskStructured, perCallModelEnabled, } from "./settings"; function createUsageTotals(): Usage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } function addUsageTotals(target: Usage, usage: Partial): void { const input = usage.input ?? 0; const output = usage.output ?? 0; const cacheRead = usage.cacheRead ?? 0; const cacheWrite = usage.cacheWrite ?? 0; const totalTokens = usage.totalTokens ?? input + output + cacheRead + cacheWrite; const cost = usage.cost ?? ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, } satisfies Usage["cost"]); target.input += input; target.output += output; target.cacheRead += cacheRead; target.cacheWrite += cacheWrite; target.totalTokens += totalTokens; target.cost.input += cost.input; target.cost.output += cost.output; target.cost.cacheRead += cost.cacheRead; target.cost.cacheWrite += cost.cacheWrite; target.cost.total += cost.total; } // Re-export types and utilities export { loadBundledAgents as BUNDLED_AGENTS } from "./agents"; export { discoverCommands, expandCommand, getCommand } from "./commands"; export { discoverAgents, getAgent } from "./discovery"; export { AgentOutputManager } from "./output-manager"; export * from "./read-only-policy"; export type { AgentDefinition, SubagentEventPayload, SubagentLifecyclePayload, SubagentProgressPayload } from "./types"; export type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "@oh-my-pi/pi-tui/tools/task"; export * from "./result-summary"; export { TASK_SUBAGENT_EVENT_CHANNEL, TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL, taskSchema, } from "./types"; interface TaskDescriptionOptions { agents: AgentDefinition[]; sessionAgents: readonly AgentDefinition[]; isolationEnabled: boolean; applyIsolatedChanges: boolean; disabledAgents: string[]; batchEnabled: boolean; effortEnabled: boolean; perCallModelEnabled: boolean; perCallModelAdvertisement?: PerCallModelAdvertisement; evalToolsEnabled: boolean; structured: boolean; file: boolean; asyncEnabled: boolean; ircEnabled: boolean; planMode: boolean; asyncGuidance: string; parentSpawns: string; } /** Plan mode hides seance; a `spawns` list led by it defaults to the next permitted agent. */ function defaultAgentFor(spawnPolicy: ResolvedSpawnPolicy, planMode: boolean): string { if (!planMode || spawnPolicy.defaultAgent !== SEANCE_AGENT_NAME) return spawnPolicy.defaultAgent; return spawnPolicy.allowedAgents?.find(agent => agent !== SEANCE_AGENT_NAME) ?? spawnPolicy.defaultAgent; } /** Render the tool description from a cached agent list and current settings. */ function renderDescription(options: TaskDescriptionOptions): string { const spawnPolicy = resolveSpawnPolicy(options.parentSpawns); const spawningDisabled = !spawnPolicy.enabled; const agents = [...options.agents, ...options.sessionAgents]; let filteredAgents = options.disabledAgents.length > 0 ? agents.filter(agent => !options.disabledAgents.includes(agent.name)) : agents; if (spawningDisabled) { filteredAgents = []; } else if (spawnPolicy.allowedAgents !== null) { const allowed = new Set(spawnPolicy.allowedAgents); filteredAgents = filteredAgents.filter(agent => allowed.has(agent.name)); } if (options.planMode) { filteredAgents = filteredAgents.filter(agent => !isBundledSeance(agent)); } const renderedAgents = filteredAgents.map(agent => ({ name: agent.name, description: agent.description, readOnly: isReadOnlyAgent(agent), blocking: agent.blocking === true, // Marked only while `task.structured` is off; with it on, every agent's result is structured. structured: !options.structured && agent.structured === true, })); const seanceAgent = filteredAgents.find(agent => agent.name === SEANCE_AGENT_NAME); const scoutAvailable = isScoutSpawnable(options.disabledAgents, options.parentSpawns); return prompt.render(taskDescriptionTemplate, { agents: renderedAgents, scoutAvailable, spawningDisabled, defaultAgent: defaultAgentFor(spawnPolicy, options.planMode), isolationEnabled: options.isolationEnabled, applyIsolatedChanges: options.applyIsolatedChanges, batchEnabled: options.batchEnabled, effortEnabled: options.effortEnabled, perCallModelEnabled: options.perCallModelEnabled, perCallModelAdvertisement: options.perCallModelAdvertisement, evalToolsEnabled: options.evalToolsEnabled, structured: options.structured, file: options.file, asyncEnabled: options.asyncEnabled, hasBlockingAgents: renderedAgents.some(agent => agent.blocking), hasStructuredAgents: renderedAgents.some(agent => agent.structured), hasModelMentions: options.sessionAgents.length > 0, seanceGuidance: seanceAgent && isBundledSeance(seanceAgent) ? seanceTaskGuidance : "", ircEnabled: options.ircEnabled, asyncGuidance: options.asyncGuidance, }); } function createTaskModeError(text: string): AgentToolResult { return { content: [{ type: "text", text }], isError: true, details: { projectAgentsDir: null, results: [], totalDurationMs: 0 }, }; } /** * Reject legacy fields and shape/configuration combinations the current tool * cannot accept. `outputSchema` is a first-class per-spawn field; stale * `schema` remains an eval-only alias and is rejected. */ function validateShapeParams(batchEnabled: boolean, params: TaskParams): string | undefined { if (Object.hasOwn(params, "schema")) { return "The task tool uses `outputSchema`; rename the stale `schema` field."; } if (batchEnabled && params.tasks !== undefined && Object.hasOwn(params, "agent")) { return "Top-level `agent` is not part of the batch shape. Set `tasks[].agent` instead."; } if (!batchEnabled) { const disallowed = (["tasks", "context"] as const).filter(field => params[field] !== undefined); if (disallowed.length > 0) { return `task.batch is disabled, so the task tool does not accept ${disallowed.map(f => `\`${f}\``).join(" or ")}. Spawn one agent per call with \`task\`, or enable the task.batch setting.`; } } return undefined; } /** * Validate the spawn parameter contract against the wire shapes. With * `task.batch` the model-facing shape is `{ context, tasks[] }` — `tasks` * non-empty with one task source per item and unique names, `context` * non-empty, no top-level `task` or `taskFile` alongside. The flat `{ agent?, ...item }` * form stays accepted at runtime under either setting (internal callers, stale * transcripts). Missing `agent` values resolve against the session spawn * policy later, in `spawnParamsFor`. Returns a problem description, or * undefined when valid. */ /** Reject an out-of-range `effort` selector on internal/stale-transcript calls that bypass the wire schema. */ function validateEffort(effort: TaskEffort | undefined, label: string): string | undefined { if (effort === undefined || TASK_EFFORTS.includes(effort)) return undefined; return `${label} has an invalid \`effort\` value ${JSON.stringify(effort)}. Use "lo", "med", or "hi".`; } function validateTaskInput(item: TaskItem | undefined, label: string, fileEnabled: boolean): string | undefined { const hasTask = item?.task !== undefined; const hasTaskFile = item?.taskFile !== undefined; if (!fileEnabled) { if (typeof item?.task !== "string" || item.task.trim() === "") { return label === "The call" ? "Missing `task`. Provide complete, self-contained instructions for the agent." : `${label} is missing \`task\`. Every task needs complete, self-contained instructions.`; } return undefined; } if (hasTask === hasTaskFile) { return `${label} must provide exactly one of \`task\` or \`taskFile\`.`; } if (hasTask && (typeof item?.task !== "string" || item.task.trim() === "")) { return `${label} needs non-empty, self-contained \`task\` instructions.`; } if ( hasTaskFile && (typeof item?.taskFile !== "string" || (!path.isAbsolute(item.taskFile) && !item.taskFile.startsWith("local://"))) ) { return `${label} \`taskFile\` must be an absolute path or local:// URL to a UTF-8 file.`; } return undefined; } function validateSpawnParams( params: TaskParams, batchEnabled: boolean, fileEnabled: boolean, defaultAgent: string, settings: Settings, ): string | undefined { const tasks = params.tasks; if ( !fileEnabled && (params.taskFile !== undefined || (Array.isArray(tasks) && tasks.some(item => item?.taskFile !== undefined))) ) { return "task.file is disabled, so the task tool does not accept `taskFile`."; } if (batchEnabled && tasks !== undefined) { if (!Array.isArray(tasks) || tasks.length === 0) { return "Missing `tasks`. Provide at least one task item with `task` or `taskFile`."; } if (params.task !== undefined || params.taskFile !== undefined) { return "Top-level `task` or `taskFile` is not part of the batch shape. Put the work in `tasks[]` items."; } for (let i = 0; i < tasks.length; i++) { const item = tasks[i]; const label = `Task ${i + 1}${item?.name ? ` (\`${item.name}\`)` : ""}`; const inputError = validateTaskInput(item, label, fileEnabled); if (inputError) return inputError; const effortError = validateEffort(item.effort, label); if (effortError) return effortError; const modelError = validateModel(item, label, defaultAgent, settings); if (modelError) return modelError; } const seen = new Map(); for (const item of tasks) { const name = item.name?.trim(); if (!name) continue; const key = name.toLowerCase(); const existing = seen.get(key); if (existing !== undefined) { return `Duplicate task name ${existing === name ? `\`${name}\`` : `\`${existing}\` / \`${name}\``}. Provided names must be unique within a call (case-insensitive).`; } seen.set(key, name); } if (typeof params.context !== "string" || params.context.trim() === "") { return "Missing `context`. Provide the shared background for this batch — goal, constraints, and any contract the tasks share."; } return undefined; } if (batchEnabled && params.task === undefined && params.taskFile === undefined) { return "Missing `tasks`. Provide a `tasks` array (one subagent per item) with a shared `context`."; } return ( validateTaskInput(params, "The call", fileEnabled) ?? validateEffort(params.effort, "The call") ?? validateModel(params, "The call", defaultAgent, settings) ); } /** * A `model` on a call naming `seance` is checked at preflight, once the name resolves: * the bundled seance takes it as its source-session override, a shadowing agent as a per-call model. */ function validateModel( item: TaskItem | TaskParams, label: string, defaultAgent: string, settings: Settings, ): string | undefined { return (item.agent?.trim() || defaultAgent) === SEANCE_AGENT_NAME ? undefined : invalidModelSelectorReason(item.model, label, { settings, invocationKind: "task" }); } function spawnModelFields(params: TaskParams): Pick { if (params.model === undefined) return {}; if (params.agent === SEANCE_AGENT_NAME) return { seanceModel: params.model }; // `validateModel` rejected anything but one string before dispatch. return typeof params.model === "string" ? { perCallModel: params.model } : {}; } /** * Normalize a validated call into its spawn list: the `tasks[]` batch when * provided, otherwise the single top-level spawn. The flat form's `isolated` * flag is only materialized when the caller sent one — `#runSpawn` * distinguishes an absent key from an explicit value. */ function resolveSpawnItems(params: TaskParams): TaskItem[] { if (Array.isArray(params.tasks) && params.tasks.length > 0) { return params.tasks; } const item: TaskItem = { name: params.name, agent: params.agent, task: params.task, taskFile: params.taskFile }; if ("solutionSpace" in params) item.solutionSpace = params.solutionSpace; if ("outputSchema" in params) item.outputSchema = params.outputSchema; if ("schemaMode" in params) item.schemaMode = params.schemaMode; if ("tools" in params) item.tools = params.tools; if ("effort" in params) item.effort = params.effort; if ("isolated" in params) item.isolated = params.isolated; if ("sourceSession" in params) item.sourceSession = params.sourceSession; if ("model" in params) item.model = params.model; return [item]; } interface ResolvedTaskItem extends TaskItem { taskFileSha256?: string; } interface SpawnParams extends TaskParams { taskFileSha256?: string; } function submittedTaskFiles(args: unknown): string[] { if (!isRecord(args)) return []; const items = Array.isArray(args.tasks) ? args.tasks : [args]; return items.flatMap(item => (isRecord(item) && typeof item.taskFile === "string" ? [item.taskFile] : [])); } /** The filesystem path a `taskFile` names; approval and dispatch share it so both see the same file. */ function taskFilePath(session: ToolSession, taskFile: string): string | undefined { return taskFile.startsWith("local://") ? InternalUrlRouter.instance().locateSync(taskFile, sessionResolveContext(session)) : taskFile; } async function resolveTaskFile(owner: object, session: ToolSession, item: TaskItem): Promise { if (item.taskFile === undefined) return item; try { const approved = approvedTaskFile(owner, item.taskFile); const filePath = approved?.path ?? taskFilePath(session, item.taskFile); if (filePath === undefined) throw new Error("does not resolve to a local file"); // On POSIX, a non-blocking open lets fstat reject a FIFO without waiting for a writer. const file = await fs .open(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (approved ? fs.constants.O_NOFOLLOW : 0)) .catch(error => { if (approved && (hasFsCode(error, "ELOOP") || hasFsCode(error, "ENOENT") || hasFsCode(error, "ENOTDIR"))) { throw new Error("changed after approval; retry the call"); } throw error; }); try { const stat = await file.stat({ bigint: true }); if (approved && !taskFileMatchesApproval(stat, approved)) { throw new Error("changed after approval; retry the call"); } if (!stat.isFile()) throw new Error("must resolve to a regular file"); const bytes = await Bun.file(file.fd) .slice(0, MAX_TASK_FILE_BYTES + 1) .bytes(); if (bytes.byteLength > MAX_TASK_FILE_BYTES) throw new Error("exceeds the 4 MiB limit"); let task: string; try { task = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } catch { throw new Error("contains invalid UTF-8"); } if (!task.trim()) throw new Error("must contain non-empty task instructions"); return { ...item, task, taskFileSha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex") }; } finally { await file.close(); } } catch (error) { throw new Error( `taskFile ${JSON.stringify(item.taskFile)}: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Per-spawn params handed to the executor path: top-level call fields with the * item's identity substituted in. Each spawn's `agent` resolves here — * the item's own value, else `defaultAgent` from the session spawn policy. * `tasks` never leaks into a spawn; the shared `context` rides along * unchanged. Keys are only materialized when present — `#runSpawn` * distinguishes an absent `isolated` from an explicit one. The item's * `isolated` (batch form) wins over the top-level flag (flat form). */ function spawnParamsFor(params: TaskParams, item: ResolvedTaskItem, defaultAgent: string): SpawnParams { const spawn: SpawnParams = { agent: item.agent?.trim() || defaultAgent }; if (item.name !== undefined) spawn.name = item.name; if (item.task !== undefined) spawn.task = item.task; if (item.taskFile !== undefined) spawn.taskFile = item.taskFile; if (item.taskFileSha256 !== undefined) spawn.taskFileSha256 = item.taskFileSha256; if (item.solutionSpace !== undefined) spawn.solutionSpace = item.solutionSpace; if (params.context !== undefined) spawn.context = params.context; if ("outputSchema" in item) spawn.outputSchema = item.outputSchema; if ("schemaMode" in item) spawn.schemaMode = item.schemaMode; if ("tools" in item) spawn.tools = item.tools; if ("effort" in item) spawn.effort = item.effort; if ("sourceSession" in item) spawn.sourceSession = item.sourceSession; if ("model" in item) spawn.model = item.model; if (item.isolated !== undefined) { spawn.isolated = item.isolated; } else if ("isolated" in params) { spawn.isolated = params.isolated; } return spawn; } /** A validated call's spawn list: its items and the per-spawn params each run receives. */ interface SpawnPlan { params: TaskParams; items: TaskItem[]; spawns: TaskParams[]; } /** A present but blank `model` (see {@link normalizeModelSelector}): "use the agent's routing", not a selector. */ function isEmptyModelPlaceholder(model: unknown): boolean { return model !== undefined && normalizeModelSelector(model) === undefined; } /** * Drop empty `model` placeholders from the call and its batch items so they * route like an omitted model. Copy-on-write: the submitted arguments are * never mutated, and the same reference is returned when nothing changed. */ function normalizeTaskModelPlaceholders(params: TaskParams): TaskParams { const originalTasks = params.tasks; let normalizedTasks: TaskItem[] | undefined; if (Array.isArray(originalTasks)) { for (let i = 0; i < originalTasks.length; i++) { const item = originalTasks[i]; if (!isRecord(item) || !isEmptyModelPlaceholder(item.model)) continue; normalizedTasks ??= originalTasks.slice(); const copy = { ...item }; delete copy.model; normalizedTasks[i] = copy; } } const emptyRootModel = isEmptyModelPlaceholder(params.model); if (!emptyRootModel && !normalizedTasks) return params; const copy = { ...params }; if (emptyRootModel) delete copy.model; if (normalizedTasks) copy.tasks = normalizedTasks; return copy; } /** * Repair, validate, and normalize a call into its spawns. Returns the * validation error the call reports instead when the shape is rejected. Shared * by dispatch and the speculative launcher so both derive identical spawns. */ function planSpawns( rawParams: unknown, batchEnabled: boolean, fileEnabled: boolean, defaultAgent: string, seanceAdvertised: boolean, settings: Settings, ): SpawnPlan | string { const params = normalizeTaskModelPlaceholders(repairTaskParams(rawParams as TaskParams)); const error = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled, fileEnabled, defaultAgent, settings) ?? validateSeanceParams({ params, batchEnabled, defaultAgent, seanceAdvertised }); if (error) return error; const items = resolveSpawnItems(params); return { params, items, spawns: items.map(item => spawnParamsFor(params, item, defaultAgent)) }; } /** * One sync-executed spawn: its item, position in the original call, (for mixed * calls) a pre-claimed agent id, and a run already started speculatively. */ interface SyncSpawnRef { item: ResolvedTaskItem; index: number; run?: SpawnRun; preAllocatedId?: string; } /** Merged view of a sync spawn set's payloads: joined text plus flattened results/usage/paths. */ interface MergedSyncPayloads { contentParts: string[]; results: SingleResult[]; usage?: Usage; outputPaths?: string[]; projectAgentsDir: string | null; /** Some spawn returned an error result. */ isError: boolean; } /** * Merge per-spawn sync payloads into one result view. `index` is each spawn's * position in the original call so batch rows keep stable ordering; a missing * payload (cancelled before start) becomes an explanatory content line. An * error payload from any spawn makes the merged view an error too, the way a * one-spawn call returns that payload as is. */ function mergeSyncPayloads( spawns: SyncSpawnRef[], payloads: (AgentToolResult | undefined)[], ): MergedSyncPayloads { const results: SingleResult[] = []; const contentParts: string[] = []; const outputPaths: string[] = []; const usageTotals = createUsageTotals(); let hasUsage = false; let projectAgentsDir: string | null = null; let isError = false; for (let position = 0; position < spawns.length; position++) { const payload = payloads[position]; const { item, index } = spawns[position]; if (!payload) { contentParts.push(`Task ${item.name?.trim() || `#${index + 1}`}: cancelled before start.`); continue; } isError ||= payload.isError === true; projectAgentsDir ??= payload.details?.projectAgentsDir ?? null; const text = payload.content.find(part => part.type === "text")?.text; if (text) contentParts.push(text); for (const result of payload.details?.results ?? []) { results.push({ ...result, index }); if (result.usage) { addUsageTotals(usageTotals, result.usage); hasUsage = true; } if (result.outputPath) outputPaths.push(result.outputPath); } } return { contentParts, results, usage: hasUsage ? usageTotals : undefined, outputPaths: outputPaths.length > 0 ? outputPaths : undefined, projectAgentsDir, isError, }; } /** Generic worker agent types; several in one call usually means a more specific type exists. */ const GENERIC_SPAWN_AGENTS: ReadonlySet = new Set(["task", "sonic"]); /** * Advisory — never a rejection — nudging the spawner toward tailored * specific agent types when one call resolves ≥2 items to a generic * `task`/`sonic` worker and the spawner still holds spawn capacity * (DepthCapacity: it currently has the `task` tool). `agentNames` are the * per-item resolved agent types. Returns undefined when no nudge applies. */ export function buildSpecializationAdvisory( agentNames: string[], depthCapacity: boolean, scoutAvailable = true, ): string | undefined { if (!depthCapacity) return undefined; const generics = agentNames.filter(name => GENERIC_SPAWN_AGENTS.has(name)); if (generics.length < 2) return undefined; return prompt .render(taskSpecializationAdvisoryTemplate, { count: generics.length, agent: generics[0], scoutAvailable, }) .trim(); } /** * Suggestion — never a rejection — nudging the spawner to coordinate via the * peer messages when one call creates ≥2 live siblings and it still holds spawn * capacity. Returns undefined when there is nothing to coordinate or peer * messaging is unavailable. */ export function buildCoordinationAdvisory( items: TaskItem[], depthCapacity: boolean, ircEnabled: boolean, ): string | undefined { if (!depthCapacity || !ircEnabled || items.length < 2) return undefined; return prompt.render(taskCoordinationAdvisoryTemplate, { count: items.length }).trim(); } /** * Compose the non-blocking advisory appended to a `task` result: the * specialization nudge (from the per-item resolved agent types), plus — only * when some spawns keep running after this call (`willRunAsync`) — the * coordination suggestion over those still-live spawns (`items`). Coordination * is gated on async because a sync spawn has already finished by the time the * call returns, so a "coordinate while they run" hint would misfire. Returns * undefined when neither applies. */ export function composeSpawnAdvisory(args: { agents: string[]; items: TaskItem[]; depthCapacity: boolean; ircEnabled: boolean; willRunAsync: boolean; scoutAvailable?: boolean; }): string | undefined { return ( [ buildSpecializationAdvisory(args.agents, args.depthCapacity, args.scoutAvailable), args.willRunAsync ? buildCoordinationAdvisory(args.items, args.depthCapacity, args.ircEnabled) : undefined, ] .filter(Boolean) .join("\n\n") || undefined ); } /** Sentinel for async jobs whose subagent finished with a failing result; progress is already updated. */ class TaskJobError extends AsyncJobError {} /** * Process-level create-time discovery memo and published reload snapshots, * keyed by resolved cwd plus the exact effective `extensions` array. * * `TaskTool.create` runs for every (sub)agent session in this process. Sessions * may share a cwd while carrying different overlay/runtime extension settings, * so cwd alone is not an isolation boundary. Explicit plugin reloads replace * only the matching cwd+extensions snapshot. Execution-time discovery * (`#runSpawn`) intentionally stays fresh. The memo also tracks the live * `discoverAgents` binding: test spies swap that binding, which invalidates * both caches automatically. */ const discoveryMemo = new Map>(); const discoverySnapshots = new Map(); let discoveryMemoFn: typeof discoverAgents | undefined; /** Stable cache identity for the filesystem root and the full effective extension-root struct. */ function discoveryCacheKey(cwd: string, extensionRoots?: EffectiveExtensionRoots): string { return `${path.resolve(cwd)}\0${JSON.stringify(extensionRoots ?? null)}`; } function discoverAgentsForCreate(cwd: string, extensionRoots?: EffectiveExtensionRoots): Promise { const fn = discoverAgents; if (discoveryMemoFn !== fn) { discoveryMemoFn = fn; discoveryMemo.clear(); discoverySnapshots.clear(); } const key = discoveryCacheKey(cwd, extensionRoots); let pending = discoveryMemo.get(key); if (!pending) { pending = fn(cwd, undefined, extensionRoots); discoveryMemo.set(key, pending); pending.catch(() => { if (discoveryMemo.get(key) === pending) discoveryMemo.delete(key); }); } return pending; } /** Rescan one cwd and publish its definitions to existing and future task tools. */ export async function refreshAgentDiscovery(cwd: string, extensionRoots?: EffectiveExtensionRoots): Promise { const key = discoveryCacheKey(cwd, extensionRoots); discoveryMemo.delete(key); const pending = discoverAgentsForCreate(cwd, extensionRoots); const { agents } = await pending; if (discoveryMemo.get(key) === pending) { discoverySnapshots.set(key, agents); } } // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ /** * Task tool - Delegate tasks to specialized agents. * * Each call spawns one subagent — or, with `task.batch`, one per `tasks[]` * item. When `async.enabled` is on, spawns run as AsyncJobManager jobs; when * disabled, the tool blocks until every spawn finishes. */ export class TaskTool implements AgentTool { readonly name = "task"; readonly approval = "exec" as const; readonly bindApprovalTargets = (args: unknown): void => { bindTaskFileApproval(this, submittedTaskFiles(args), taskFile => taskFilePath(this.session, taskFile)); }; // Restricted folders cover task files, but approvals are never remembered per file: the grant would // cover whatever the path holds later, and inline items in the same batch. readonly approvalRestrictionPaths = (args: unknown): readonly string[] | undefined => { return taskFileRestrictionPaths(this, submittedTaskFiles(args), taskFile => taskFilePath(this.session, taskFile)); }; readonly formatApprovalDetails = (args: unknown): string[] => { const params = args as Partial; const lines: string[] = []; const formatTaskFile = (taskFile: string): string => { const submitted = truncateForPrompt(sanitizeText(JSON.stringify(taskFile))); const target = taskFileApprovalPath(this, taskFile); return target !== undefined && target !== taskFile ? `${submitted} -> ${truncateForPrompt(sanitizeText(JSON.stringify(target)))}` : submitted; }; if (typeof params.agent === "string") { lines.push(`Agent: ${truncateForPrompt(params.agent)}`); } if (typeof params.name === "string" && params.name.trim()) { lines.push(`Name: ${truncateForPrompt(params.name)}`); } if (typeof params.model === "string") { lines.push(`Model: ${truncateForPrompt(params.model)}`); } if (typeof params.task === "string") { lines.push(`Task:\n${truncateForPrompt(params.task)}`); } if (typeof params.taskFile === "string") { lines.push(`Task file: ${formatTaskFile(params.taskFile)}`); } if (typeof params.context === "string" && params.context.trim()) { lines.push(`Context:\n${truncateForPrompt(params.context)}`); } const tasks: unknown[] = Array.isArray(params.tasks) ? params.tasks : []; if (tasks.length > 0) { const defaultAgent = this.#defaultAgent(); const effectiveAgent = (item: unknown): string => { if (item && typeof item === "object" && "agent" in item) { const agent = item.agent; if (typeof agent === "string" && agent.trim()) return agent.trim(); } return defaultAgent; }; const agentCounts = new Map(); for (const item of tasks) { const agent = effectiveAgent(item); agentCounts.set(agent, (agentCounts.get(agent) ?? 0) + 1); } const agentSummary = [...agentCounts].map(([agent, count]) => `${agent} ×${count}`).join(", "); lines.push(`Batch agents: ${truncateForPrompt(agentSummary)}`); const firstTask = tasks[0]; if (firstTask && typeof firstTask === "object") { if ("name" in firstTask && typeof firstTask.name === "string" && firstTask.name.trim()) { lines.push(`Name: ${truncateForPrompt(firstTask.name)}`); } lines.push(`Agent: ${truncateForPrompt(effectiveAgent(firstTask))}`); if ("task" in firstTask && typeof firstTask.task === "string") { lines.push(`Task:\n${truncateForPrompt(firstTask.task)}`); } } for (const [index, item] of tasks.entries()) { if (isRecord(item) && typeof item.model === "string") { lines.push(`Task ${index + 1} model: ${truncateForPrompt(item.model)}`); } if (isRecord(item) && typeof item.taskFile === "string") { lines.push(`Task ${index + 1} file: ${formatTaskFile(item.taskFile)}`); } } if (tasks.length > 1) { lines.push(`+${tasks.length - 1} more task${tasks.length === 2 ? "" : "s"}`); } } return lines; }; readonly label = "Task"; readonly summary = "Spawn subagents to complete delegated tasks"; readonly strict = false; readonly loadMode = "essential"; // Arktype validates model calls against the active wire schema, but the flat // single-spawn schema carries `"+": "delete"`: a batch `{ context, tasks[] }` // payload has those keys stripped, then can fail on a missing flat-form field. // That preempts the tool's own actionable shape checks (`validateShapeParams` / // `validateSpawnParams`), which never run. Lenient validation forwards the // raw args to `execute()` on any arktype failure so those checks surface the // real reason ("enable task.batch, or use the flat `task` shape"). Valid // calls still normalize through arktype; `execute()` resolves `agent` // defaults independently, so the success path is unchanged. readonly lenientArgValidation = true; readonly renderResult = renderResult; // Suppress the streaming call preview once a (partial or final) result exists // so the task renders as ONE block that transitions in place — not a pending // call frame stacked above the result frame. Mirrors `taskToolRenderer`. readonly mergeCallAndResult = true; readonly #discoveredAgents: AgentDefinition[]; readonly #blockedAgent: string | undefined; /** * One semaphore per TaskTool instance (i.e. per session): bounds concurrent * subagents across parallel `task` calls within the session. Resized in * place from `task.maxConcurrency` before every acquire/release so a * mid-session settings change (UI toggle, `/settings`) applies to both new * spawns and work already parked in the semaphore queue. */ #spawnSemaphore: Semaphore | undefined; readonly #permit: SpawnPermit = { acquire: signal => this.#getSpawnSemaphore().acquire(signal), release: () => this.#releaseSpawnSemaphore(), }; /** Streamed calls' speculative launches, keyed by tool-call id until dispatch adopts or discards them. */ readonly #launchSessions = new Map(); readonly #launcher: TaskLauncher = { spawns: args => { const plan = planSpawns( args, this.#isBatchEnabled(), cfgTaskFile.get(this.session.settings), this.#defaultAgent(), this.#seanceSchemaFields() !== undefined, this.session.settings, ); return typeof plan === "string" ? undefined : plan.spawns; }, start: (toolCallId, spawn, index, signal) => this.#startSpeculative(toolCallId, spawn, index, signal), }; /** * Batch calls start each subagent as soon as its `tasks[]` item streams in; * see `./speculative-launch` for the abort/adoption contract. */ readonly speculation: ToolSpeculationPolicy = { stream: { open: async context => { if (!cfgTaskSpeculativeLaunch.get(this.session.settings) || !this.#isBatchEnabled()) return undefined; if (!context.coordinator.authorizeLaunch) return undefined; const id = context.parentToolCallId; const session = new TaskLaunchSession({ sink: context.coordinator, tool: this, launcher: this.#launcher, onClose: () => { if (this.#launchSessions.get(id) === session) this.#launchSessions.delete(id); }, }); this.#launchSessions.set(id, session); return session; }, }, }; get parameters(): TaskToolSchemaInstance { const planMode = this.session.getPlanModeState?.()?.enabled === true; const isolationEnabled = !planMode && cfgTaskIsolationEnabled.get(this.session.settings); return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled(), effortEnabled: cfgTaskEnableEffort.get(this.session.settings), evalToolsEnabled: evalToolsEnabled(this.session), structured: this.#isStructured(), file: cfgTaskFile.get(this.session.settings), perCallModel: perCallModelEnabled(this.session.settings, "task"), defaultAgent: this.#defaultAgent(), seanceFields: this.#seanceSchemaFields(), }); } renderCall(args: unknown, options: Parameters[1], theme: Theme) { return renderTaskCall(repairTaskParams(args as TaskParams), options, theme); } /** Background-job lifecycle contract; inherited by extension replacements that delegate to Task. */ get delegationGuidance(): string { if (!cfgAsyncEnabled.get(this.session.settings)) return ""; return prompt.render(taskAsyncContractTemplate, { ircEnabled: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0), waitTool: hasWaitTool(this.session), }); } /** Dynamic description that reflects current task settings. */ get description(): string { const disabledAgents = cfgTaskDisabledAgents.get(this.session.settings); const agents = discoverySnapshots.get(discoveryCacheKey(this.session.cwd, this.session.effectiveExtensionRoots?.())) ?? this.#discoveredAgents; const sessionAgents = this.session.advertisedSessionAgents?.() ?? this.session.getSessionAgents?.() ?? []; const planMode = this.session.getPlanModeState?.()?.enabled === true; const isolationEnabled = cfgTaskIsolationEnabled.get(this.session.settings); const perCallModel = perCallModelEnabled(this.session.settings, "task"); return renderDescription({ agents, sessionAgents, isolationEnabled: !planMode && isolationEnabled, applyIsolatedChanges: cfgTaskIsolationApply.get(this.session.settings), disabledAgents, batchEnabled: this.#isBatchEnabled(), effortEnabled: cfgTaskEnableEffort.get(this.session.settings), perCallModelEnabled: perCallModel, perCallModelAdvertisement: perCallModel ? getPerCallModelAdvertisement( this.session.settings, this.session.getActiveModel?.(), this.session.modelRegistry, ) : undefined, evalToolsEnabled: evalToolsEnabled(this.session), structured: this.#isStructured(), file: cfgTaskFile.get(this.session.settings), asyncEnabled: cfgAsyncEnabled.get(this.session.settings), ircEnabled: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0), planMode, asyncGuidance: this.delegationGuidance, parentSpawns: this.session.getSessionSpawns() ?? "*", }); } private constructor( private readonly session: ToolSession, discoveredAgents: AgentDefinition[], ) { this.#blockedAgent = $env.PI_BLOCKED_AGENT; this.#discoveredAgents = discoveredAgents; } #isBatchEnabled(): boolean { return cfgTaskBatch.get(this.session.settings); } #isStructured(): boolean { return cfgTaskStructured.get(this.session.settings); } /** * Result contract for a spawn: with `task.structured` the caller's * `outputSchema`/`schemaMode` pass through; without it the child gets no * schema or `yield` tool and returns its final message, unless its agent * declares `structured: true` (resolved in the shared subagent policy). */ #resultParams(params: TaskParams): Pick { if (!this.#isStructured()) return { structured: false }; return { ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}), ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}), }; } /** * Thinking override for a spawn. `task.enableEffort` gates the caller's * `effort` at runtime, not only in the advertised schema: stale transcripts * and lenient argument fallbacks still deliver the field, and honoring it * would override the agent's configured thinking selector. */ #effortParams(params: TaskParams): Pick { if (params.effort === undefined || !cfgTaskEnableEffort.get(this.session.settings)) return {}; return { effort: params.effort }; } #seanceSchemaFields() { const spawnPolicy = resolveSpawnPolicy(this.session.getSessionSpawns()); const planMode = this.session.getPlanModeState?.()?.enabled === true; const disabledAgents = cfgTaskDisabledAgents.get(this.session.settings); if ( planMode || !spawnPolicy.enabled || disabledAgents.includes(SEANCE_AGENT_NAME) || (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(SEANCE_AGENT_NAME)) ) { return undefined; } const agents = discoverySnapshots.get(discoveryCacheKey(this.session.cwd, this.session.effectiveExtensionRoots?.())) ?? this.#discoveredAgents; const sessionAgents = this.session.advertisedSessionAgents?.() ?? this.session.getSessionAgents?.() ?? []; const seanceAgent = [...agents, ...sessionAgents].find(agent => agent.name === SEANCE_AGENT_NAME); return seanceAgent && isBundledSeance(seanceAgent) ? seanceTaskSchemaFields : undefined; } #defaultAgent(): string { return defaultAgentFor( resolveSpawnPolicy(this.session.getSessionSpawns()), this.session.getPlanModeState?.()?.enabled === true, ); } /** Session-retained id allocator; async ids are claimed before job registration. */ #outputManager(): AgentOutputManager { let manager = this.session.agentOutputManager; if (!manager) { manager = new AgentOutputManager(this.session.getArtifactsDir ?? (() => null)); this.session.agentOutputManager = manager; } return manager; } /** Start one spawn's subagent run behind the session semaphore. */ #launch(spawn: { toolCallId: string; params: SpawnParams; index: number; agentId?: string; detached: boolean; }): SpawnRun { return new SpawnRun( this.#permit, run => this.#runSpawn( spawn.toolCallId, spawn.params, run.signal, run.onUpdate, spawn.agentId, spawn.index, spawn.detached, run.timing, spawn.detached ? run.onArtifactsRetained : undefined, run.tools, ), { agentId: spawn.agentId, detached: spawn.detached }, ); } /** * Start a streamed item's run ahead of dispatch. Preflight and execution * mode mirror dispatch exactly; refuses (undefined) whenever dispatch would * reject the item, so a refusal halts further speculation for the call. */ async #startSpeculative( toolCallId: string, spawn: TaskParams, index: number, signal: AbortSignal, ): Promise { // File bytes must be captured at dispatch, not while arguments stream. if (spawn.taskFile !== undefined) return undefined; if (spawn.tools?.length && this.session.getPlanModeState?.()?.enabled === true) return undefined; let blocking: boolean; try { blocking = (await this.#resolveSpawnPreflight(spawn)).effectiveAgent.blocking === true; } catch { return undefined; } const detached = cfgAsyncEnabled.get(this.session.settings) && this.session.asyncJobManager !== undefined && !blocking; const agentId = await this.#outputManager().allocate(spawn.name?.trim() || generateTaskName()); if (signal.aborted) return undefined; return this.#launch({ toolCallId, params: spawn, index, agentId, detached }); } #getSpawnSemaphore(): Semaphore { const max = cfgTaskMaxConcurrency.get(this.session.settings); if (this.#spawnSemaphore) { this.#spawnSemaphore.resize(max); } else { this.#spawnSemaphore = new Semaphore(max); } return this.#spawnSemaphore; } #releaseSpawnSemaphore(): void { this.#getSpawnSemaphore().release(); } /** * Resolve the shared policy before detached work exists. The resulting * policy intentionally stays local: executor dispatch resolves again from * normalized task params rather than smuggling internal policy over the * task wire contract. */ #resolveSpawnPreflight(params: TaskParams) { return resolveEffectiveSubagentPolicy({ session: this.session, invocationKind: "task", assignment: (params.task ?? "").trim(), context: this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined, agent: params.agent, sourceSession: params.sourceSession, ...spawnModelFields(params), ...this.#resultParams(params), ...this.#effortParams(params), ...("isolated" in params ? { isolation: { requested: params.isolated } } : {}), blockedAgent: this.#blockedAgent, enableLsp: (this.session.enableLsp ?? true) && cfgTaskEnableLsp.get(this.session.settings), enableIrc: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0), maxRuntimeMs: cfgTaskMaxRuntimeMs.get(this.session.settings), }); } /** * Create a TaskTool instance with async agent discovery. */ static async create(session: ToolSession): Promise { const { agents } = await discoverAgentsForCreate(session.cwd, session.effectiveExtensionRoots?.()); return new TaskTool(session, agents); } async execute( toolCallId: string, rawParams: unknown, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> { const launchSession = this.#launchSessions.get(toolCallId); this.#launchSessions.delete(toolCallId); try { return await this.#dispatch(toolCallId, rawParams, launchSession, signal, onUpdate); } finally { // No-op once dispatch adopted; otherwise nothing launched early may outlive the call. launchSession?.discard("task call settled without adopting speculative launches"); } } async #dispatch( toolCallId: string, rawParams: unknown, launchSession: TaskLaunchSession | undefined, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> { // Schema defaults fill `agent` for model calls, but internal callers // and stale transcripts can bypass arktype. `spawnParamsFor` resolves each // item's agent type against the session's actual default agent. const defaultAgent = this.#defaultAgent(); const batchEnabled = this.#isBatchEnabled(); const plan = planSpawns( rawParams, batchEnabled, cfgTaskFile.get(this.session.settings), defaultAgent, this.#seanceSchemaFields() !== undefined, this.session.settings, ); if (typeof plan === "string") { return createTaskModeError(plan); } const { params } = plan; const spawnItems: ResolvedTaskItem[] = []; try { for (const item of plan.items) spawnItems.push(await resolveTaskFile(this, this.session, item)); } catch (error) { return createTaskModeError(error instanceof Error ? error.message : String(error)); } const normalizedSpawnParams = spawnItems.map(item => spawnParamsFor(params, item, defaultAgent)); const evalToolNames = spawnItems.flatMap(item => item.tools ?? []); if (evalToolNames.length > 0) { if (this.session.getPlanModeState?.()?.enabled === true) { return createTaskModeError("Task execution failed: Eval-defined tools are unavailable in plan mode."); } try { await describeEvalTools(this.session, evalToolNames, signal); } catch (error) { return createTaskModeError( `Task execution failed: ${error instanceof Error ? error.message : String(error)}`, ); } } const resolvedAgents = normalizedSpawnParams.map(spawn => spawn.agent ?? defaultAgent); // Resolve every item before choosing an execution path. No executor or // job manager may observe a batch unless every effective policy is valid. const preflights = await Promise.all( normalizedSpawnParams.map(async spawn => { try { return { policy: await this.#resolveSpawnPreflight(spawn) }; } catch (error) { return { error: error instanceof StructuredSubagentError ? error.message : String(error) }; } }), ); const preflightFailures = preflights .map((preflight, index) => ("error" in preflight ? { index, error: preflight.error } : undefined)) .filter((failure): failure is { index: number; error: string } => failure !== undefined); if (preflightFailures.length > 0) { if (!batchEnabled) { return createTaskModeError(`Task execution failed: ${preflightFailures[0]!.error}`); } return createTaskModeError( preflightFailures .map(({ index, error }) => { const item = spawnItems[index]!; return `Task ${item.name?.trim() || `#${index + 1}`} failed preflight: ${error}`; }) .join("\n"), ); } const policies = preflights.map(preflight => preflight.policy!); const itemBlocking = policies.map(policy => policy.effectiveAgent.blocking === true); // Execution mode is per item: an item whose agent type declares // `blocking: true` runs inline on this turn (the parent waits on its // result); every other item becomes a background job when async // execution is available. const asyncEnabled = cfgAsyncEnabled.get(this.session.settings); const manager = asyncEnabled ? this.session.asyncJobManager : undefined; const asyncItems = manager ? spawnItems.filter((_, index) => !itemBlocking[index]) : []; // Runs started while the call streamed. An item runs detached iff it // becomes a background job here; a run launched under the other mode // (settings flipped mid-stream) cannot be re-homed and starts over. const adopted = launchSession ? await launchSession.adopt(normalizedSpawnParams) : new Map(); for (const [index, run] of adopted) { if (run.identity.detached === (manager !== undefined && !itemBlocking[index])) continue; run.discard("task execution mode changed after speculative launch"); adopted.delete(index); } const depthCapacity = canSpawnAtDepth( cfgTaskMaxRecursionDepth.get(this.session.settings), this.session.taskDepth ?? 0, ); const ircEnabled = isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0); if (!manager || asyncItems.length === 0) { // Sync fallback: async execution disabled, orphaned host that never // wired a job manager, or every item's agent type declares // `blocking: true`. if (asyncEnabled && !this.session.asyncJobManager) { logger.warn("task: no AsyncJobManager registered; falling back to sync execution"); } const advisory = this.session.suppressSpawnAdvisory ? undefined : composeSpawnAdvisory({ agents: resolvedAgents, items: asyncItems, depthCapacity, ircEnabled, willRunAsync: false, scoutAvailable: isScoutSpawnable( cfgTaskDisabledAgents.get(this.session.settings), this.session.getSessionSpawns?.() ?? "*", ), }); const result = await this.#executeSyncFanout( toolCallId, params, spawnItems.map((item, index) => ({ item, index, run: adopted.get(index) })), defaultAgent, signal, onUpdate, ); if (!advisory) return result; let appended = false; const content = result.content.map(part => { if (!appended && part.type === "text" && typeof part.text === "string") { appended = true; return { ...part, text: `${part.text}\n\n${advisory}` }; } return part; }); if (!appended) content.push({ type: "text", text: advisory }); return { ...result, content }; } // Coordination only makes sense for spawns that keep running after this // call returns (the async subset). Blocking items have already completed // by then, so a "coordinate while they run" hint would misfire. const advisory = this.session.suppressSpawnAdvisory ? undefined : composeSpawnAdvisory({ agents: resolvedAgents, items: asyncItems, depthCapacity, ircEnabled, willRunAsync: asyncItems.length > 0, scoutAvailable: isScoutSpawnable( cfgTaskDisabledAgents.get(this.session.settings), this.session.getSessionSpawns?.() ?? "*", ), }); // Returns a fresh result (copied content array, copied text part) rather // than mutating the caller's — task results are short-lived here, but an // in-place edit on a shared/cached AgentToolResult would be a hidden trap. const withAdvisory = (result: AgentToolResult): AgentToolResult => { if (!advisory) return result; let appended = false; const content = result.content.map(part => { if (!appended && part.type === "text" && typeof part.text === "string") { appended = true; return { ...part, text: `${part.text}\n\n${advisory}` }; } return part; }); if (!appended) content.push({ type: "text", text: advisory }); return { ...result, content }; }; const outputManager = this.#outputManager(); const callStartedAt = Date.now(); const spawns: Array<{ agentId: string; item: ResolvedTaskItem; index: number; blocking: boolean; run: SpawnRun | undefined; progress: AgentProgress; }> = []; for (const [index, item] of spawnItems.entries()) { const agentType = resolvedAgents[index]!; const policy = policies[index]!; const agentSource = policy.agent.source; const run = adopted.get(index); const agentId = run?.identity.agentId ?? (await outputManager.allocate(item.name?.trim() || generateTaskName())); const assignment = (item.task ?? "").trim(); spawns.push({ agentId, item, index, blocking: itemBlocking[index], run, progress: { index, id: agentId, agent: agentType, agentSource, modelRole: policy.modelRole, status: "pending", task: renderSubagentUserPrompt(assignment), assignment, ...(item.taskFile !== undefined ? { taskFile: item.taskFile, taskFileSha256: item.taskFileSha256 } : {}), recentTools: [], recentOutput: [], toolCount: 0, requests: 0, tokens: 0, cost: 0, durationMs: 0, }, }); } const asyncSpawns = spawns.filter(spawn => !spawn.blocking); const syncSpawns = spawns.filter(spawn => spawn.blocking); const agentLabel = [...new Set(asyncSpawns.map(spawn => spawn.progress.agent))].join(", "); // Aggregate state for the one tool call. Async spawns report into the // shared progress snapshot through their jobs: the async half stays // "running" until every job settles, then turns "failed" if any spawn // failed. Blocking spawns run inline below and land in `results` before // the call returns, so post-return job updates never drop them. let settledCount = 0; let failedCount = 0; let primaryJobId = asyncSpawns[0].agentId; const syncResults: SingleResult[] = []; // oxlint-disable-next-line prefer-const -- read by buildAsyncDetails before assignment let syncUsage: Usage | undefined; // oxlint-disable-next-line prefer-const -- read by buildAsyncDetails before assignment let syncOutputPaths: string[] | undefined; let syncProjectAgentsDir: string | null = null; const buildAsyncDetails = (): TaskToolDetails => ({ projectAgentsDir: syncProjectAgentsDir, results: [...syncResults], totalDurationMs: Date.now() - callStartedAt, usage: syncUsage, outputPaths: syncOutputPaths, progress: spawns.map(spawn => ({ ...spawn.progress })), async: { state: settledCount < asyncSpawns.length ? "running" : failedCount > 0 ? "failed" : "completed", jobId: primaryJobId, type: "task", }, }); const started: Array<{ agentId: string; jobId: string }> = []; const failedSchedules: string[] = []; for (const spawn of asyncSpawns) { try { const jobId = this.#registerSpawnJob({ manager, toolCallId, spawnParams: spawnParamsFor(params, spawn.item, defaultAgent), agentId: spawn.agentId, run: spawn.run, progress: spawn.progress, ircEnabled, buildDetails: buildAsyncDetails, onUpdate, onSettled: failed => { settledCount += 1; if (failed) failedCount += 1; }, }); if (started.length === 0) primaryJobId = jobId; started.push({ agentId: spawn.agentId, jobId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); // An adopted run already executes; without its job nothing would own it. spawn.run?.discard(`background job registration failed: ${message}`); failedSchedules.push(`${spawn.agentId}: ${message}`); spawn.progress.status = "failed"; settledCount += 1; failedCount += 1; } } if (started.length === 0 && syncSpawns.length === 0) { return { content: [ { type: "text", text: `Failed to start background task job${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}`, }, ], details: { projectAgentsDir: null, results: [], totalDurationMs: 0 }, }; } const scheduleFailureSummary = failedSchedules.length > 0 ? ` Failed to schedule ${failedSchedules.length} spawn${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}.` : ""; const renderSpawnFeedback = (mixed: boolean): string => prompt .render(taskSpawnFeedbackTemplate, { mixed, singular: started.length === 1, singleCall: spawns.length === 1, showListing: mixed || spawns.length > 1, count: started.length, agentId: started[0]?.agentId, jobId: started[0]?.jobId, agentLabel, started, scheduleFailureSummary, }) .trim(); if (syncSpawns.length === 0) { if (spawns.length === 1) { const { agentId } = started[0]; onUpdate?.({ content: [{ type: "text", text: `Spawned agent \`${agentId}\`...` }], details: buildAsyncDetails(), }); return withAdvisory({ content: [ { type: "text", text: renderSpawnFeedback(false), }, ], details: buildAsyncDetails(), }); } onUpdate?.({ content: [{ type: "text", text: `Spawned ${started.length} agents...` }], details: buildAsyncDetails(), }); return withAdvisory({ content: [ { type: "text", text: renderSpawnFeedback(false), }, ], details: buildAsyncDetails(), }); } // Mixed call: the async jobs above already run detached; the blocking // subset runs inline and gates the call's return — exactly what each // agent type declares (`blocking: true` = the parent waits on it). const syncLabel = syncSpawns.map(spawn => `\`${spawn.agentId}\``).join(", "); onUpdate?.({ content: [ { type: "text", text: `Running ${syncLabel} inline; ${started.length} background agent${started.length === 1 ? "" : "s"} spawned...`, }, ], details: buildAsyncDetails(), }); const payloads = await this.#runSyncSpawns({ toolCallId, params, defaultAgent, signal, spawns: syncSpawns.map(spawn => ({ item: spawn.item, index: spawn.index, preAllocatedId: spawn.agentId, run: spawn.run, })), onItemProgress: onUpdate ? (index, progress) => { const spawn = spawns.find(candidate => candidate.index === index); if (spawn) spawn.progress = { ...progress, index }; onUpdate({ content: [{ type: "text", text: `Running ${syncLabel} inline...` }], details: buildAsyncDetails(), }); } : undefined, }); const merged = mergeSyncPayloads( syncSpawns.map(spawn => ({ item: spawn.item, index: spawn.index })), payloads, ); syncResults.push(...merged.results); syncUsage = merged.usage; syncOutputPaths = merged.outputPaths; syncProjectAgentsDir = merged.projectAgentsDir; // Settle the inline spawns' progress rows from their merged results so // post-return job updates carry final statuses, not the last snapshot. for (let position = 0; position < syncSpawns.length; position++) { const spawn = syncSpawns[position]; const result = merged.results.find(r => r.id === spawn.agentId); if (result) { spawn.progress.status = result.aborted ? "aborted" : result.exitCode === 0 && !result.error ? "completed" : "failed"; spawn.progress.durationMs = result.durationMs; } else { spawn.progress.status = payloads[position] ? "failed" : "aborted"; } } const spawnedSummary = started.length > 0 ? renderSpawnFeedback(true) : scheduleFailureSummary.trim(); const text = [merged.contentParts.join("\n\n"), spawnedSummary] .filter(section => section.trim().length > 0) .join("\n\n"); return withAdvisory({ content: [{ type: "text", text: text.length > 0 ? text : "No results." }], ...(merged.isError ? { isError: true } : {}), details: buildAsyncDetails(), }); } /** * Register one background job that runs a single spawn to completion and * delivers its yield text. The job body mirrors the sync path; `buildDetails` * supplies the (possibly batch-shared) progress snapshot and `onSettled` * feeds the caller's aggregate counters. */ #registerSpawnJob(options: { manager: AsyncJobManagerApi; toolCallId: string; spawnParams: SpawnParams; agentId: string; /** Run already started while the call streamed; the job adopts it instead of launching. */ run?: SpawnRun; progress: AgentProgress; ircEnabled: boolean; buildDetails: () => TaskToolDetails; onUpdate?: AgentToolUpdateCallback; onSettled?: (failed: boolean) => void; }): string { const { manager, toolCallId, spawnParams, agentId, run, progress, ircEnabled, buildDetails, onUpdate, onSettled, } = options; // A queued job launches (or adopts) its run when it starts; until then // its widget has no tool tap to read. let spawnRun: SpawnRun | undefined = run; const buildFollowUpHint = async (aborted: boolean): Promise => { // Isolated runs are parked without a reviver once the run ends // (`finalizeSubagentLifecycle`), so "message it" would point the // caller at a follow-up path that no longer exists. The template says // nothing about the worktree itself: the runner keeps it when captured // changes could not be written, and names that path in the result. const isolated = spawnParams.isolated === true; const ref = aborted ? AgentRegistry.global().get(agentId) : undefined; return `\n\n${prompt.render(taskFollowUpTemplate, { agentId, aborted, isolated, ircEnabled, resumable: !isolated && isResumable(ref), transcriptAvailable: aborted ? await hasResolvableTranscript(agentId) : true, })}`; }; return manager.register( "task", agentId, async ({ jobId, signal: runSignal, reportProgress, markRunning }) => { const startedAt = Date.now(); const forwardSyncProgress: AgentToolUpdateCallback = async update => { const nextProgress = update.details?.progress?.[0]; if (nextProgress) { // The job body owns status and identity (id/index/agent); // copy only the live metrics the subagent streams so the // polling row reflects the resolved model, reasoning level, // and running counters without reverting the "running" // status back to the subagent's initial "pending" snapshot. progress.modelRole = nextProgress.modelRole ?? progress.modelRole; progress.resolvedModel = nextProgress.resolvedModel; progress.resolvedModelIdentity = nextProgress.resolvedModelIdentity; progress.resolvedThinkingLevel = nextProgress.resolvedThinkingLevel; progress.resolvedModelIsFallback = nextProgress.resolvedModel ? nextProgress.resolvedModelIsFallback : undefined; progress.advisor = nextProgress.advisor ?? progress.advisor; progress.completionPercent = nextProgress.completionPercent ?? progress.completionPercent; progress.stage = nextProgress.stage ?? progress.stage; progress.watch = nextProgress.watch ?? progress.watch; progress.resolvedModelRoute = nextProgress.resolvedModelRoute ?? progress.resolvedModelRoute; progress.tokens = nextProgress.tokens; progress.requests = nextProgress.requests; progress.contextTokens = nextProgress.contextTokens; progress.contextWindow = nextProgress.contextWindow; progress.cost = nextProgress.cost; progress.toolCount = nextProgress.toolCount; progress.currentTool = nextProgress.currentTool; progress.currentToolArgs = nextProgress.currentToolArgs; progress.currentToolArgsKey = nextProgress.currentToolArgsKey; progress.currentToolIntent = nextProgress.currentToolIntent; progress.currentToolStartMs = nextProgress.currentToolStartMs; progress.lastIntent = nextProgress.lastIntent; progress.recentTools = nextProgress.recentTools.slice(); progress.recentOutput = nextProgress.recentOutput.slice(); progress.retryState = nextProgress.retryState; progress.retryFailure = nextProgress.retryFailure; } const updateText = update.content.find(part => part.type === "text")?.text ?? `Running background task ${agentId}...`; await reportProgress(updateText, buildDetails() as unknown as Record); }; const activeRun = (spawnRun ??= this.#launch({ toolCallId, params: spawnParams, index: progress.index, agentId, detached: true, })); activeRun.attach({ signal: runSignal, onUpdate: forwardSyncProgress, onArtifactsRetained: cleanup => { // Tie the retained temp directory's lifetime to this job // row: the manager runs `cleanup` exactly once, on // eviction or manager disposal, instead of it leaking // for the process lifetime. Look up by the resolved // `jobId`, not the requested `agentId` — `register()` // suffixes `jobId` on collision, and looking up the // requested id would hit an unrelated pre-existing row. const job = manager.getJob(jobId); if (job) job.retainedArtifactsCleanup = cleanup; else void cleanup(); }, }); // An acquire-time abort and a post-acquire abort take the same // path: progress + onSettled have to fire even when the spawn // never reached the executor, otherwise the batch aggregate state // stays "running" forever. const permitHeld = await activeRun.started.then( () => true, () => false, ); if (!permitHeld || runSignal.aborted) { progress.status = "aborted"; onSettled?.(true); throw new Error("Aborted before execution"); } try { markRunning(); progress.status = "running"; await reportProgress( `Running background task ${agentId}...`, buildDetails() as unknown as Record, ); const result = await activeRun.result; const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)"; const singleResult = result.details?.results[0]; // A missing result means the sync path failed at the tool level // (results: []) — treat it as a failure, not success. A runner // error on a zero exit (changes captured but not landed, or a // retained workspace) is a failure too: the work needs manual // recovery, which a "completed" job would hide. Mirrors the sync // path's status derivation. `isError` marks a child that finished // before a later step (isolation merge, nested patch apply) threw. const resultFailed = result.isError === true || !singleResult || (singleResult.aborted ?? false) || singleResult.exitCode !== 0 || singleResult.error !== undefined; progress.status = singleResult?.aborted ? "aborted" : resultFailed ? "failed" : "completed"; progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt); progress.tokens = singleResult?.tokens ?? 0; progress.requests = singleResult?.requests ?? 0; progress.contextTokens = singleResult?.contextTokens; progress.contextWindow = singleResult?.contextWindow; progress.cost = singleResult?.usage?.cost.total ?? 0; progress.extractedToolData = singleResult?.extractedToolData; progress.retryFailure = singleResult?.retryFailure; progress.retryState = undefined; progress.modelRole = singleResult?.modelRole ?? progress.modelRole; progress.advisor = singleResult?.advisor ?? progress.advisor; progress.resolvedModelRoute = singleResult?.resolvedModelRoute ?? progress.resolvedModelRoute; if (singleResult?.resolvedModel) { progress.resolvedModel = singleResult.resolvedModel; progress.resolvedModelIdentity = singleResult.resolvedModelIdentity; progress.resolvedThinkingLevel = singleResult.resolvedThinkingLevel; progress.resolvedModelIsFallback = singleResult.resolvedModelIsFallback; } else { delete progress.resolvedModel; delete progress.resolvedModelIdentity; delete progress.resolvedThinkingLevel; delete progress.resolvedModelIsFallback; } onSettled?.(resultFailed); const statusText = resultFailed ? `Background task ${agentId} failed.` : `Background task ${agentId} complete.`; await reportProgress(statusText, buildDetails() as unknown as Record); const deliveryText = `${finalText}${await buildFollowUpHint(singleResult?.aborted === true)}`; const structured = singleResult?.structuredOutput; if (resultFailed) { // Mark the job itself failed; the failed agent stays interrogable. throw new TaskJobError(deliveryText, structured); } return structured ? { text: deliveryText, structured } : deliveryText; } catch (error) { if (error instanceof TaskJobError) { throw error; } progress.status = "failed"; progress.durationMs = Math.max(0, Date.now() - startedAt); onSettled?.(true); const statusText = `Background task ${agentId} failed.`; await reportProgress(statusText, buildDetails() as unknown as Record); const message = error instanceof Error ? error.message : String(error); const hint = AgentRegistry.global().get(agentId) ? await buildFollowUpHint(false) : ""; throw new TaskJobError(`${message}${hint}`); } }, { id: agentId, agentId, queued: true, ownerId: this.session.getAgentId?.() ?? undefined, widgetInputs: createTaskWidgetInputs({ manager, agentId, tools: () => spawnRun?.tools, progress, intent: this.session.getToolCallIntent?.(toolCallId), }), onProgress: text => { onUpdate?.({ content: [{ type: "text", text }], details: buildDetails() }); }, }, ); } /** * Sync fan-out (async unavailable, or every item's agent type is * `blocking: true`): run every spawn to completion inline and merge the * per-spawn payloads into a single tool result, an error when any spawn's * payload is. The session-scoped semaphore still bounds concurrency across * parallel task calls. */ async #executeSyncFanout( toolCallId: string, params: TaskParams, spawns: SyncSpawnRef[], defaultAgent: string, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, ): Promise> { if (spawns.length === 1) { const spawn = spawns[0]!; const run = spawn.run ?? this.#launch({ toolCallId, params: spawnParamsFor(params, spawn.item, defaultAgent), index: spawn.index, agentId: spawn.preAllocatedId, detached: false, }); run.attach({ signal, onUpdate }); return await run.result; } const startTime = Date.now(); const latestProgress = new Map(); const emitCombined = () => { onUpdate?.({ content: [{ type: "text", text: `Running ${spawns.length} agents...` }], details: { projectAgentsDir: null, results: [], totalDurationMs: Date.now() - startTime, progress: Array.from(latestProgress.entries()) .sort((a, b) => a[0] - b[0]) .map(([, progress]) => progress), }, }); }; const payloads = await this.#runSyncSpawns({ toolCallId, params, defaultAgent, signal, spawns, onItemProgress: onUpdate ? (index, progress) => { latestProgress.set(index, { ...progress, index }); emitCombined(); } : undefined, }); const merged = mergeSyncPayloads(spawns, payloads); return { content: [{ type: "text", text: merged.contentParts.join("\n\n") }], ...(merged.isError ? { isError: true } : {}), details: { projectAgentsDir: merged.projectAgentsDir, results: merged.results, totalDurationMs: Date.now() - startTime, usage: merged.usage, outputPaths: merged.outputPaths, }, }; } /** * Run a set of spawns to completion inline, bounded by the session spawn * semaphore. `preAllocatedId` reuses an id claimed up front (mixed calls); * `index` is each item's position in the original call so progress rows and * merged results keep stable ordering. Per-item progress snapshots flow * through `onItemProgress`. Returns per-spawn payloads in input order; * `undefined` marks a spawn cancelled before it started. */ async #runSyncSpawns(args: { toolCallId: string; params: TaskParams; defaultAgent: string; spawns: SyncSpawnRef[]; signal?: AbortSignal; onItemProgress?: (index: number, progress: AgentProgress) => void; }): Promise<(AgentToolResult | undefined)[]> { const { toolCallId, params, defaultAgent, spawns, signal, onItemProgress } = args; const { results } = await mapWithConcurrencyLimitAllSettled( spawns, spawns.length, async (spawn, _position, workerSignal) => { const run = spawn.run ?? this.#launch({ toolCallId, params: spawnParamsFor(params, spawn.item, defaultAgent), index: spawn.index, agentId: spawn.preAllocatedId, detached: false, }); run.attach({ signal: workerSignal, onUpdate: onItemProgress ? update => { const progress = update.details?.progress?.[0]; if (progress) onItemProgress(spawn.index, progress); } : undefined, }); try { await run.started; } catch (error) { if (workerSignal.aborted) return undefined; throw error; } return await run.result; }, signal, ); // A worker never invoked (call aborted first) never attached its adopted run. spawns.forEach((spawn, position) => { if (!results[position]) spawn.run?.discard("task call aborted before the spawn started"); }); return results.map((settled, position) => { if (!settled) return undefined; if (settled.status === "fulfilled") return settled.value; const message = settled.reason instanceof Error ? settled.reason.message : String(settled.reason); const item = spawns[position].item; return { content: [ { type: "text", text: `Task ${item.name?.trim() || `#${spawns[position].index + 1}`} failed: ${message}`, }, ], details: { projectAgentsDir: null, results: [], totalDurationMs: 0 }, }; }); } /** Spawn a fresh subagent and run it to completion; the body of every {@link SpawnRun}. */ async #runSpawn( toolCallId: string, params: SpawnParams, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, preAllocatedId?: string, spawnIndex = 0, detached = false, launchTiming?: { invokedAt: number; acquiredAt: number }, onArtifactsRetained?: (cleanup: () => Promise) => void, tools?: TextTap, ): Promise> { const startTime = Date.now(); const assignment = (params.task ?? "").trim(); const taskFileMetadata = params.taskFile !== undefined ? { taskFile: params.taskFile, taskFileSha256: params.taskFileSha256 } : {}; const context = this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined; let latestProgress: AgentProgress | undefined; try { const execution = await runStructuredSubagent({ session: this.session, invocationKind: "task", assignment, context, agent: params.agent, sourceSession: params.sourceSession, ...spawnModelFields(params), ...this.#resultParams(params), ...this.#effortParams(params), solutionSpace: params.solutionSpace, ...(params.tools?.length ? { customTools: createEvalCustomTools( this.session, await describeEvalTools(this.session, params.tools, signal), ), } : {}), // `name` is the spawn handle: keep it for id allocation when this // path did not pre-reserve one. Do not treat it as a HUD description. identity: { id: preAllocatedId, label: params.name }, index: spawnIndex, parentToolCallId: toolCallId, detached, // Detached (async) spawns advertise `agent://` handles in the // eventual async-result delivery, which can land well after this // call returns. Without this, a temporary (in-memory session) // artifacts directory is deleted immediately on completion and the // advertised URL 404s by the time delivery happens. retainArtifacts: detached, ...(onArtifactsRetained ? { onArtifactsRetained } : {}), invokedAt: launchTiming?.invokedAt, acquiredAt: launchTiming?.acquiredAt, ...("isolated" in params ? { isolation: { requested: params.isolated } } : {}), blockedAgent: this.#blockedAgent, enableLsp: (this.session.enableLsp ?? true) && cfgTaskEnableLsp.get(this.session.settings), enableIrc: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0), maxRuntimeMs: cfgTaskMaxRuntimeMs.get(this.session.settings), signal, ...(tools ? { onToolResult: (result: SubagentToolResult) => appendToolResult(tools, result) } : {}), onProgress: progress => { latestProgress = { ...progress, ...taskFileMetadata, recentTools: progress.recentTools.slice() }; onUpdate?.({ content: [{ type: "text", text: `Running agent ${progress.id}...` }], details: { projectAgentsDir: null, results: [], totalDurationMs: Date.now() - startTime, progress: [latestProgress], }, }); }, }); return this.#buildResultPayload( { ...execution.result, ...taskFileMetadata }, execution.policy.discovery.projectAgentsDir, Date.now() - startTime, execution.mergeSummary, ); } catch (error) { const message = error instanceof StructuredSubagentError ? error.message : String(error); // A child that finished before the failure keeps its exit status, // usage, and artifact path. `error` is set so nothing reads a zero // exit code as a completed run. const settled = error instanceof StructuredSubagentError ? error.result : undefined; const cause = error instanceof StructuredSubagentError ? error.cause : undefined; const salvaged = settled ? { ...settled, ...taskFileMetadata, error: settled.error ?? (cause instanceof Error ? cause.message : message), } : undefined; return { content: [{ type: "text", text: `Task execution failed: ${message}` }], isError: true, details: { projectAgentsDir: null, results: salvaged ? [salvaged] : [], totalDurationMs: Date.now() - startTime, ...(salvaged?.usage ? { usage: salvaged.usage } : {}), ...(salvaged?.outputPath ? { outputPaths: [salvaged.outputPath] } : {}), ...(latestProgress ? { progress: [latestProgress] } : {}), }, }; } } /** Build the tool result (summary text + details) for a settled run. */ #buildResultPayload( result: SingleResult, projectAgentsDir: string | null, totalDurationMs: number, mergeSummary: string, ): AgentToolResult { const summary = formatTaskResultSummary(result, { totalDurationMs, mergeSummary }); return { content: [{ type: "text", text: summary }], details: { projectAgentsDir, results: [result], totalDurationMs, usage: result.usage, outputPaths: result.outputPath ? [result.outputPath] : undefined, }, }; } } subprocessToolRegistry.register("task", { ...taskSubprocessRenderer, extractData: event => (isTaskToolDetails(event.result?.details) ? event.result.details : undefined), });