import { createAgentSession, createBashTool, createEditTool, createEditToolDefinition, createExtensionRuntime, createFindTool, createGrepTool, createLsTool, createReadTool, createReadToolDefinition, createWriteTool, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { loadPromptRules } from "../../vera-session-tools/extensions/prompt-rules/rules-loader"; import { artifactsDirFor, ensureArtifactsDir, readRunState, writeRunState } from "./artifacts"; import type { SubagentDefinition } from "./definitions"; import { wrapReadWithLineCap } from "./read-cap-policy"; import { wrapEditWithHashline, wrapReadWithHashline } from "../../vera-theme/src/public"; import type { ActiveSubagentConfig, SubagentRunDetails, SubagentTimelineItem, SubagentUsage } from "./types"; const DEFAULT_SUBAGENT_SYSTEM_PROMPT = `You are Vera's delegated subagent. Your job is to complete the delegated task inside an isolated, temporary session. Rules: - Stay tightly scoped to the delegated task. - Use the available tools when direct inspection is needed. - Prefer evidence over speculation. - Do not ask the user questions. - Do not refer to yourself as the primary assistant. - End with a concise answer that the parent agent can reuse directly.`; const MAX_TIMELINE_ITEMS = 120; const MAX_TIMELINE_TEXT = 1600; const MAX_LIVE_TEXT = 2000; const MAX_RAW_SESSION_OUTPUT = 12000; const MAX_TOOL_ERRORS = 20; const MAX_CONTENT_TOOL_ERRORS = 3; const ALLOWED_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const; const ALLOWED_EFFORTS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; type AllowedToolName = typeof ALLOWED_TOOL_NAMES[number]; type AllowedEffort = typeof ALLOWED_EFFORTS[number]; export type ResumeFailureReason = | "run_not_found" | "done_run_cannot_resume" | "runid_mismatch" | "session_file_missing" | "session_file_unreadable" | "unknown_format"; export interface ResumeFailureContext { runId?: string; artifactsDir?: string; sessionFile?: string; persistedRunId?: string; persistedPhase?: string; } export class ResumeError extends Error { constructor( public readonly reason: ResumeFailureReason, public readonly detail: string, public readonly suggestedAction: string, public readonly context: ResumeFailureContext = {}, ) { super(`${reason}: ${detail}`); this.name = "ResumeError"; } } let runIdSequence = 0; function newRunId(): string { runIdSequence += 1; return `subagent_${Date.now()}_${String(runIdSequence).padStart(3, "0")}`; } function clip(text: string, max = MAX_TIMELINE_TEXT): string { const normalized = String(text ?? "").trim(); if (!normalized) return ""; return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized; } function normalizeMaybePath(value: string): string { return value.startsWith("@") ? value.slice(1) : value; } function normalizeToolName(value: string): AllowedToolName | null { const normalized = value.trim() as AllowedToolName; return (ALLOWED_TOOL_NAMES as readonly string[]).includes(normalized) ? normalized : null; } function normalizeEffort(value?: string): AllowedEffort | null { if (!value) return null; const normalized = value.trim() as AllowedEffort; return (ALLOWED_EFFORTS as readonly string[]).includes(normalized) ? normalized : null; } function pushTimeline(details: SubagentRunDetails, item: SubagentTimelineItem): void { const next = { ...item, text: clip(item.text), }; if (!next.text) return; details.timeline.push(next); if (details.timeline.length > MAX_TIMELINE_ITEMS) { details.timeline.splice(0, details.timeline.length - MAX_TIMELINE_ITEMS); } details.lastEvent = next.text; } function appendRunError(details: SubagentRunDetails, message: string): void { const text = clip(message, 800); if (!text) return; details.error = details.error ? `${details.error}\n${text}` : text; pushTimeline(details, { kind: "error", text, isError: true }); } function recordToolError(details: SubagentRunDetails, tool: string, message: string): void { const item = { tool, message: clip(message, 500) || "tool call failed" }; details.toolErrors.push(item); if (details.toolErrors.length > MAX_TOOL_ERRORS) { details.toolErrors.splice(0, details.toolErrors.length - MAX_TOOL_ERRORS); } } function formatToolErrorList(toolErrors: SubagentRunDetails["toolErrors"]): string { return toolErrors .slice(-MAX_CONTENT_TOOL_ERRORS) .map((item) => ` - ${item.tool}: ${item.message}`) .join("\n"); } // Tool errors are recoverable events; only true session-level exceptions or // missing/incomplete final output trigger phase="error". Empty finalText is // no longer considered a successful run — see deriveTerminalPhase for the // four-way classification. function deriveTerminalPhase(details: SubagentRunDetails, messages: any): void { // Already marked by a session-level exception; keep the original error text. if (details.error) { details.phase = "error"; return; } // Scenario 0: clean stream completion with final text captured directly. if (details.streamingCompleted && details.finalText && details.finalText.trim()) { details.phase = "done"; return; } // Scenario 1: no direct final text, but assistant text can be salvaged from history. if (!details.finalText || !details.finalText.trim()) { const salvaged = collectFinalAssistantText(messages); if (salvaged && salvaged.trim()) { details.salvagedFinalText = salvaged; details.finalText = salvaged; details.error = "stream did not complete cleanly; recovered final text from message history (salvaged)"; details.phase = "error"; return; } } // Scenario 2: no salvageable assistant text, but session messages exist. if (Array.isArray(messages) && messages.length > 0) { details.rawSessionOutput = collectLastMessages(messages, 3); details.error = "subagent produced no final assistant text; showing last 3 messages in details"; details.phase = "error"; return; } // Scenario 3: no messages at all. details.error = "subagent produced no messages at all"; details.phase = "error"; } function buildReturnContent(details: SubagentRunDetails): string { let body: string; if (details.phase === "error") { const lines = [`Subagent failed: ${details.error ?? "unknown error"}`]; if (details.salvagedFinalText) { lines.push("", "Salvaged final text from message history:", details.salvagedFinalText); } else if (details.rawSessionOutput) { lines.push("", "Last messages from session (truncated):", details.rawSessionOutput); } if (details.toolErrors.length > 0) { lines.push("", "Last tool errors:", formatToolErrorList(details.toolErrors)); } body = lines.join("\n"); } else { const output = details.finalText; body = details.toolErrors.length > 0 ? `${output}\n\n[Note: ${details.toolErrors.length} tool call(s) inside the subagent failed during the run; see details for full timeline.]` : output; } return details.runId ? `Run: ${details.runId}\n\n${body}` : body; } function extractTextFromContent(content: any): string { if (!Array.isArray(content)) return ""; return content .filter((part) => part?.type === "text") .map((part) => String(part.text ?? "")) .join("\n") .trim(); } function collectFinalAssistantText(messages: any): string { if (!Array.isArray(messages)) return ""; for (let i = messages.length - 1; i >= 0; i -= 1) { const message = messages[i]; if (message?.role !== "assistant") continue; const text = extractTextFromContent(message.content); if (text) return text; } return ""; } function collectRawSessionOutput(messages: any): string { if (!Array.isArray(messages)) return ""; try { const text = JSON.stringify(messages, null, 2); return clip(text, MAX_RAW_SESSION_OUTPUT); } catch { return ""; } } function collectLastMessages(messages: any, count: number): string { if (!Array.isArray(messages) || messages.length === 0) return ""; try { const lastN = messages.slice(-Math.max(1, count)); const text = JSON.stringify(lastN, null, 2); return clip(text, MAX_RAW_SESSION_OUTPUT); } catch { return ""; } } function formatModel(model: any): string | undefined { if (!model) return undefined; if (typeof model === "string") return model; if (typeof model === "object") { const provider = typeof model.provider === "string" ? model.provider : undefined; const id = typeof model.id === "string" ? model.id : undefined; if (provider && id) return `${provider}/${id}`; if (id) return id; if (typeof model.name === "string") return model.name; } return undefined; } function accumulateUsage(target: SubagentUsage, usage: any): void { if (!usage || typeof usage !== "object") return; target.input += Number(usage.input ?? 0) || 0; target.output += Number(usage.output ?? 0) || 0; target.cacheRead += Number(usage.cacheRead ?? 0) || 0; target.cacheWrite += Number(usage.cacheWrite ?? 0) || 0; target.turns += 1; const costTotal = typeof usage.cost === "object" ? Number(usage.cost?.total ?? 0) || 0 : Number(usage.cost ?? 0) || 0; target.cost += costTotal; } function shortenPath(rawPath: string): string { return rawPath.length > 48 ? `${rawPath.slice(0, 45)}...` : rawPath; } function formatToolCall(toolName: string, args: any): string { switch (toolName) { case "read": { const path = shortenPath(String(args?.path ?? args?.file_path ?? "...")); const offset = args?.offset; const limit = args?.limit; if (typeof offset === "number" || typeof limit === "number") { const start = typeof offset === "number" ? offset : 1; const end = typeof limit === "number" ? start + limit - 1 : undefined; return `read ${path}:${start}${end ? `-${end}` : ""}`; } return `read ${path}`; } case "grep": { const pattern = String(args?.pattern ?? ""); const path = shortenPath(String(args?.path ?? ".")); return `grep /${clip(pattern, 40) || "..."}/ in ${path}`; } case "find": { const pattern = String(args?.pattern ?? ""); const path = shortenPath(String(args?.path ?? ".")); return `find ${clip(pattern, 40) || "..."} in ${path}`; } case "ls": { const path = shortenPath(String(args?.path ?? ".")); return `ls ${path}`; } case "bash": { return `bash ${clip(String(args?.command ?? ""), 80)}`; } case "edit": { return `edit ${shortenPath(String(args?.path ?? "..."))}`; } case "write": { return `write ${shortenPath(String(args?.path ?? "..."))}`; } default: return `${toolName} ${clip(JSON.stringify(args ?? {}), 80)}`; } } function formatToolResult(toolName: string, result: any): string { const text = clip(extractTextFromContent(result?.content), 240); if (!text) return `${toolName} finished`; return `${toolName}: ${text}`; } function formatToolErrorMessage(result: any): string { const contentText = clip(extractTextFromContent(result?.content), 500); if (contentText) return contentText; const directText = clip(result?.error ?? result?.message ?? result?.stderr ?? "", 500); if (directText) return directText; try { const serialized = clip(JSON.stringify(result ?? {}), 500); if (serialized && serialized !== "{}") return serialized; } catch { // ignore unstringifiable tool payloads } return "tool call failed"; } function nowMs(): number { return Date.now(); } export function resolveSubagentCwd(baseCwd: string, maybeCwd?: string): string { const input = String(maybeCwd ?? "").trim(); if (!input) return baseCwd; const normalized = normalizeMaybePath(input); return isAbsolute(normalized) ? normalized : resolve(baseCwd, normalized); } function isHashlineEnabled(cwd: string): boolean { const readFlag = (path: string): boolean | undefined => { if (!existsSync(path)) return undefined; try { const json = JSON.parse(readFileSync(path, "utf-8")); return json?.hashline?.enabled === true; } catch { return false; } }; const project = readFlag(resolve(cwd, ".pi", "config", "vera-theme.json")); if (project !== undefined) return project; return readFlag(join(getAgentDir(), "config", "vera-theme.json")) ?? false; } function buildHashlineCustomTools(cwd: string): any[] { if (!isHashlineEnabled(cwd)) return []; const read = wrapReadWithHashline(wrapReadWithLineCap(createReadToolDefinition(cwd))); const edit = wrapEditWithHashline(createEditToolDefinition(cwd), cwd); return [read, edit]; } function createBuiltInTools(cwd: string, selected?: string[]) { const toolMap = { read: wrapReadWithLineCap(createReadTool(cwd)), bash: createBashTool(cwd), edit: createEditTool(cwd), write: createWriteTool(cwd), grep: createGrepTool(cwd), find: createFindTool(cwd), ls: createLsTool(cwd), } satisfies Record>; if (!selected || selected.length === 0) { return { tools: Object.values(toolMap), errors: [] as string[], normalized: [...ALLOWED_TOOL_NAMES] as string[] }; } const normalized: AllowedToolName[] = []; const errors: string[] = []; for (const item of selected) { const name = normalizeToolName(item); if (!name) { errors.push(`Unsupported tool '${item}'. Supported built-in tools: ${ALLOWED_TOOL_NAMES.join(", ")}.`); continue; } if (!normalized.includes(name)) normalized.push(name); } return { tools: normalized.map((name) => toolMap[name]), errors, normalized: normalized.map((name) => String(name)), }; } function createSkillAwareResourceLoader(input: { cwd: string; systemPrompt: string; selectedSkills?: string[]; extensionTools?: string[]; }) { const skillFilter = (input.selectedSkills ?? []).map((item) => item.trim()).filter(Boolean); const disableAllSkills = skillFilter.length === 1 && skillFilter[0].toLowerCase() === "none"; const extToolFilter = (input.extensionTools ?? []).map((item) => item.trim()).filter(Boolean); const hasExtTools = extToolFilter.length > 0; const baseLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir: getAgentDir(), noExtensions: !hasExtTools, noPromptTemplates: true, noThemes: true, noSkills: disableAllSkills, skillsOverride: !disableAllSkills && skillFilter.length > 0 ? (current) => ({ skills: current.skills.filter((skill) => skillFilter.includes(skill.name)), diagnostics: current.diagnostics, }) : undefined, extensionsOverride: hasExtTools ? (base) => ({ ...base, extensions: base.extensions .map((ext) => ({ ...ext, tools: new Map([...ext.tools].filter(([name]) => extToolFilter.includes(name))), })) .filter((ext) => ext.tools.size > 0), }) : undefined, }); return { async reload() { await baseLoader.reload(); }, getExtensions: hasExtTools ? () => baseLoader.getExtensions() : () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }), getSkills: () => baseLoader.getSkills(), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => input.systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, }; } function resolveDefinitionPrompt(definition?: SubagentDefinition): string | undefined { return definition?.prompt?.trim() ? definition.prompt.trim() : undefined; } // Loads Vera's assembled SOUL + core rules (the same `loadPromptRules` the // prompt-rules extension uses for the main agent), so a subagent without an // explicit `system:` field is governed by an identical rule set rather than a // thin generic default. Returns null when no rules can be loaded. function loadInheritedVeraPrompt(cwd?: string): string | null { if (!cwd) return null; try { const result = loadPromptRules(cwd); return result.prompt && result.prompt.trim() ? result.prompt.trim() : null; } catch { return null; } } function resolveDefinitionSystem(definition?: SubagentDefinition, extra?: string, inheritCwd?: string): string { const base = definition?.system?.trim() ? definition.system.trim() : loadInheritedVeraPrompt(inheritCwd) ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT.trim(); const suffix = String(extra ?? "").trim(); return suffix ? `${base}\n\n${suffix}` : base; } function buildActiveConfig( definition: SubagentDefinition | undefined, normalizedTools: string[], extensionTools: string[], normalizedSkills: string[], modelOverride?: string, effortOverride?: string, ): ActiveSubagentConfig | undefined { const model = modelOverride ?? definition?.model; const effort = effortOverride ?? definition?.effort; if (!definition && normalizedTools.length === 0 && extensionTools.length === 0 && normalizedSkills.length === 0 && !model && !effort) return undefined; return { name: definition?.name, model, effort, description: definition?.description, source: definition?.source, filePath: definition?.filePath, tools: normalizedTools.length > 0 ? normalizedTools : undefined, extensionTools: extensionTools.length > 0 ? extensionTools : undefined, skills: normalizedSkills.length > 0 ? normalizedSkills : undefined, }; } function needsDeveloperRoleCompat(model: any): boolean { return ( model?.api === "openai-completions" && model.provider === "cpa" && typeof model.id === "string" && model.id.startsWith("deepseek-") ); } function applySubagentModelCompat(model: any): any { if (!needsDeveloperRoleCompat(model)) return model; if (model.compat?.supportsDeveloperRole === false) return model; return { ...model, compat: { ...model.compat, supportsDeveloperRole: false, }, }; } function resolveModelFromSpec(spec: string | undefined, ctx: ExtensionContext): { model?: any; error?: string } { if (!spec) return { model: applySubagentModelCompat(ctx.model ?? undefined) }; const trimmed = spec.trim(); const slash = trimmed.indexOf("/"); if (slash <= 0 || slash === trimmed.length - 1) { return { error: `Invalid model '${trimmed}'. Expected provider/model.` }; } const provider = trimmed.slice(0, slash).trim(); const modelId = trimmed.slice(slash + 1).trim(); const model = ctx.modelRegistry.find(provider, modelId); if (!model) { return { error: `Unknown model '${trimmed}'.` }; } return { model: applySubagentModelCompat(model) }; } function getSystemPromptSnapshot(created: any, fallback?: string): string | undefined { const snapshot = created?.session?.agent?.state?.systemPrompt ?? created?.session?.agent?.state?._systemPrompt; return typeof snapshot === "string" && snapshot.trim() ? snapshot : fallback; } function buildStartupFailure(details: SubagentRunDetails, message: string): { content: string; details: SubagentRunDetails } { details.phase = "error"; details.error = message; details.endedAt = nowMs(); details.durationMs = details.endedAt - details.startedAt; if (details.artifactsDir) { try { writeRunState(details.artifactsDir, details); } catch { // best-effort persistence } } return { content: `Subagent failed: ${message}`, details, }; } /** * Inspect the session's current leaf branch and synthesize tool_result blocks * for any tool_use blocks in the last assistant message that have no matching * tool_result. Returns the count of synthesized blocks. */ function healDanglingToolCalls(sessionManager: any): number { let healed = 0; try { const context = sessionManager.buildSessionContext(); const messages = context?.messages ?? []; if (messages.length === 0) return 0; const last = messages[messages.length - 1]; if (last?.role !== "assistant" || !Array.isArray(last.content)) return 0; const danglingToolUses = last.content.filter( (block: any) => block?.type === "tool_use" && block.id, ); if (danglingToolUses.length === 0) return 0; const syntheticResults = danglingToolUses.map((block: any) => ({ type: "tool_result" as const, tool_use_id: block.id, is_error: true, content: "Interrupted by process restart. The previous call's effect on the world is unknown. Decide whether to retry or proceed differently.", })); sessionManager.appendMessage({ role: "user", content: syntheticResults, }); healed = syntheticResults.length; } catch { // Detection failures are non-fatal; the continuation prompt still explains the interruption. } return healed; } async function promptSession(input: { session: any; task: string; details: SubagentRunDetails; definitionName?: string; signal?: AbortSignal; keepAlive?: boolean; onUpdate?: (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentRunDetails }) => void; }): Promise<{ content: string; details: SubagentRunDetails; session?: any }> { const { session, task, details } = input; const emitUpdate = () => { input.onUpdate?.({ content: [{ type: "text", text: details.finalText || details.liveText || "(subagent running...)" }], details: { ...details, liveText: clip(details.liveText ?? "", MAX_LIVE_TEXT), }, }); if (details.artifactsDir) { try { writeRunState(details.artifactsDir, details); } catch { // Persisting crash-recovery state is best-effort and must not break the run. } } }; const onAbort = () => { try { (session as any)?.agent?.abort?.(); } catch { // ignore nested abort failures } }; if (input.signal) { if (input.signal.aborted) onAbort(); else input.signal.addEventListener("abort", onAbort, { once: true }); } const unsubscribe = session.subscribe((event: any) => { switch (event?.type) { case "agent_start": { pushTimeline(details, { kind: "status", text: input.definitionName ? `subagent '${input.definitionName}' started` : "subagent started" }); emitUpdate(); break; } case "message_update": { if (event.assistantMessageEvent?.type === "text_delta") { details.liveText = clip(`${details.liveText ?? ""}${event.assistantMessageEvent.delta ?? ""}`, MAX_LIVE_TEXT); } break; } case "tool_execution_start": { pushTimeline(details, { kind: "tool", phase: "start", text: formatToolCall(String(event.toolName ?? "tool"), event.args), }); emitUpdate(); break; } case "tool_execution_end": { const toolName = String(event.toolName ?? "tool"); if (event.isError) { recordToolError(details, toolName, formatToolErrorMessage(event.result)); } pushTimeline(details, { kind: "tool", phase: "end", text: formatToolResult(toolName, event.result), isError: Boolean(event.isError), }); emitUpdate(); break; } case "message_end": { const message = event.message; if (message?.role !== "assistant") break; const text = extractTextFromContent(message.content); if (text) { details.finalText = text; details.liveText = ""; pushTimeline(details, { kind: "assistant", text }); } accumulateUsage(details.usage, message.usage); const model = formatModel(message.model); if (model) details.model = model; emitUpdate(); break; } case "agent_end": { details.streamingCompleted = true; details.endedAt = nowMs(); details.durationMs = details.endedAt - details.startedAt; emitUpdate(); break; } case "compaction_end": { if (event.errorMessage) { appendRunError(details, `compaction failed: ${event.errorMessage}`); emitUpdate(); } break; } case "auto_retry_end": { if (!event.success && event.finalError) { appendRunError(details, `auto retry failed: ${event.finalError}`); emitUpdate(); } break; } default: break; } }); try { emitUpdate(); await session.prompt(task); const messages = (session as any)?.state?.messages ?? []; deriveTerminalPhase(details, messages); details.endedAt = nowMs(); details.durationMs = details.endedAt - details.startedAt; emitUpdate(); return { content: buildReturnContent(details), details, ...(input.keepAlive ? { session } : {}), }; } catch (error) { const message = error instanceof Error ? error.message : String(error); details.phase = "error"; details.error = details.error ? `${details.error}\n${message}` : message; details.endedAt = nowMs(); details.durationMs = details.endedAt - details.startedAt; details.liveText = ""; pushTimeline(details, { kind: "error", text: message, isError: true }); emitUpdate(); return { content: buildReturnContent(details), details, ...(input.keepAlive ? { session } : {}), }; } finally { try { unsubscribe?.(); } catch { // ignore unsubscribe failures } if (input.signal) { try { input.signal.removeEventListener("abort", onAbort); } catch { // ignore cleanup failures } } if (!input.keepAlive) { try { (session as any)?.dispose?.(); } catch { // ignore dispose failures } } } } /** Runs one delegated task in a session-scoped child AgentSession. */ export async function runSubagentTask(input: { ctx: ExtensionContext; task: string; cwd?: string; systemPrompt?: string; thinkingLevel?: string; /** Per-call model override from the tool/API surface, if supplied. */ modelOverride?: string; /** Per-call thinking-effort override from the tool/API surface, if supplied. */ effortOverride?: string; /** YAML definition that specializes the child session, if named. */ definition?: SubagentDefinition; /** Abort signal owned by the foreground tool call or background job. */ signal?: AbortSignal; /** Existing child session to resume instead of creating a new one. */ existingSession?: any; /** Retains the child session for the caller instead of disposing it. */ keepAlive?: boolean; /** Streams run details for live cards and background status updates. */ onUpdate?: (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentRunDetails }) => void; }): Promise<{ content: string; details: SubagentRunDetails; session?: any }> { const cwd = resolveSubagentCwd(input.ctx.cwd, input.cwd); const finalTask = [resolveDefinitionPrompt(input.definition), input.task].filter(Boolean).join("\n\n"); if (input.existingSession) { const modelSpec = input.modelOverride ?? input.definition?.model; const effortSpec = input.effortOverride ?? input.definition?.effort; const details: SubagentRunDetails = { version: 1, phase: "running", agent: (input.definition || modelSpec || effortSpec) ? { name: input.definition?.name, model: modelSpec, effort: effortSpec, description: input.definition?.description, source: input.definition?.source, filePath: input.definition?.filePath, } : undefined, task: finalTask, cwd, model: modelSpec, startedAt: nowMs(), finalText: "", toolErrors: [], usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }, timeline: [], }; pushTimeline(details, { kind: "status", text: input.definition?.name ? `resuming session for '${input.definition.name}'` : "resuming session" }); return promptSession({ session: input.existingSession, task: finalTask, details, definitionName: input.definition?.name, signal: input.signal, keepAlive: true, onUpdate: input.onUpdate, }); } const builtInTools = createBuiltInTools(cwd, input.definition?.tools); const extensionToolNames = (input.definition?.extensionTools ?? []).map((item) => item.trim()).filter(Boolean); const rawSkillNames = (input.definition?.skills ?? []).map((item) => item.trim()).filter(Boolean); const skillsDisabled = rawSkillNames.length === 1 && rawSkillNames[0].toLowerCase() === "none"; const selectedSkillNames = skillsDisabled ? rawSkillNames : rawSkillNames; const modelSpec = input.modelOverride ?? input.definition?.model; const effortSpec = input.effortOverride ?? input.definition?.effort; const resolvedModel = resolveModelFromSpec(modelSpec, input.ctx); // Precedence: explicit caller override > YAML effort > inherited main-agent level. const resolvedEffort = input.effortOverride ? normalizeEffort(input.effortOverride) : input.definition?.effort ? normalizeEffort(input.definition.effort) : normalizeEffort(input.thinkingLevel); const runId = newRunId(); const artifactsDir = ensureArtifactsDir(runId); const sessionManager = SessionManager.create(cwd, artifactsDir); const sessionFile = sessionManager.getSessionFile(); const details: SubagentRunDetails = { version: 1, runId, artifactsDir, sessionFile, phase: "running", agent: buildActiveConfig(input.definition, builtInTools.normalized, extensionToolNames, selectedSkillNames, modelSpec, effortSpec), task: finalTask, cwd, model: modelSpec?.trim() || formatModel(resolvedModel.model ?? input.ctx.model), startedAt: nowMs(), finalText: "", toolErrors: [], usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, }, timeline: [], }; const startupErrors = [...builtInTools.errors]; if (resolvedModel.error) startupErrors.push(resolvedModel.error); if (input.effortOverride && !resolvedEffort) { startupErrors.push(`Unsupported thinkingLevel '${input.effortOverride}'. Supported values: ${ALLOWED_EFFORTS.join(", ")}.`); } else if (input.definition?.effort && !resolvedEffort) { startupErrors.push(`Unsupported effort '${input.definition.effort}'. Supported values: ${ALLOWED_EFFORTS.join(", ")}.`); } for (const error of startupErrors) { pushTimeline(details, { kind: "error", text: error, isError: true }); } if (startupErrors.length > 0) { return buildStartupFailure(details, startupErrors.join(" ")); } const systemPrompt = resolveDefinitionSystem(input.definition, input.systemPrompt, input.ctx.cwd); details.systemPromptSnapshot = systemPrompt; const resourceLoader = createSkillAwareResourceLoader({ cwd, systemPrompt, selectedSkills: selectedSkillNames, extensionTools: extensionToolNames, }); await resourceLoader.reload(); const availableSkills = resourceLoader.getSkills().skills.map((skill) => skill.name); const missingSkills = skillsDisabled ? [] : selectedSkillNames.filter((name) => !availableSkills.includes(name)); if (missingSkills.length > 0) { const message = `Unknown skill(s): ${missingSkills.join(", ")}. Available skills: ${availableSkills.join(", ") || "(none)"}.`; pushTimeline(details, { kind: "error", text: message, isError: true }); return buildStartupFailure(details, message); } if (details.agent) { details.agent.skills = selectedSkillNames.length > 0 ? selectedSkillNames : availableSkills; } const created = await createAgentSession({ cwd, model: resolvedModel.model ?? input.ctx.model ?? undefined, resourceLoader, thinkingLevel: resolvedEffort ?? undefined, tools: [...builtInTools.normalized, ...extensionToolNames], customTools: buildHashlineCustomTools(cwd), sessionManager, settingsManager: SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false, maxRetries: 0 }, }), }); details.systemPromptSnapshot = getSystemPromptSnapshot(created, systemPrompt); return promptSession({ session: created.session, task: finalTask, details, definitionName: input.definition?.name, signal: input.signal, keepAlive: input.keepAlive, onUpdate: input.onUpdate, }); } export async function resumeSubagentTask(input: { ctx: ExtensionContext; runId: string; artifactsDirOverride?: string; signal?: AbortSignal; onUpdate?: (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentRunDetails }) => void; }): Promise<{ content: string; details: SubagentRunDetails; session?: any }> { const cwd = input.ctx.cwd; const artifactsDir = input.artifactsDirOverride ?? artifactsDirFor(input.runId); let persisted: SubagentRunDetails; try { persisted = readRunState(artifactsDir); } catch (error: any) { const context = { runId: input.runId, artifactsDir }; if (error?.code === "ENOENT") { throw new ResumeError( "run_not_found", `No run.json found at ${artifactsDir}`, `Verify the runId is correct and the state/subagents/${input.runId}/ directory exists.`, context, ); } throw new ResumeError( "unknown_format", `run.json at ${artifactsDir} is corrupt or missing required fields`, "Inspect the file manually; consider deleting the run directory to start fresh.", context, ); } if (!persisted || typeof persisted !== "object" || typeof persisted.phase !== "string") { throw new ResumeError( "unknown_format", `run.json at ${artifactsDir} is corrupt or missing required fields`, "Inspect the file manually; consider deleting the run directory to start fresh.", { runId: input.runId, artifactsDir }, ); } if (persisted.runId && persisted.runId !== input.runId) { throw new ResumeError( "runid_mismatch", `Requested runId ${input.runId}, persisted runId ${persisted.runId}`, "Use the runId stored in the run directory, not a different one.", { runId: input.runId, artifactsDir, persistedRunId: persisted.runId, persistedPhase: persisted.phase }, ); } if (persisted.phase === "done") { throw new ResumeError( "done_run_cannot_resume", `Run ${input.runId} completed at ${persisted.endedAt ?? "unknown time"} and cannot be resumed.`, "Done runs are terminal — start a new subagent with a fresh task to continue this line of work.", { runId: input.runId, artifactsDir, persistedRunId: persisted.runId, persistedPhase: persisted.phase, sessionFile: persisted.sessionFile }, ); } if (!persisted.sessionFile) { throw new ResumeError( "session_file_missing", "Run state does not record a sessionFile path.", "This run predates the persisted-session change; resume is not supported. Start a new subagent.", { runId: input.runId, artifactsDir, persistedRunId: persisted.runId, persistedPhase: persisted.phase }, ); } if (!existsSync(persisted.sessionFile)) { throw new ResumeError( "session_file_unreadable", `Session file ${persisted.sessionFile} no longer exists on disk.`, "The SDK session file has been moved or deleted externally; the run cannot be resumed.", { runId: input.runId, artifactsDir, persistedRunId: persisted.runId, persistedPhase: persisted.phase, sessionFile: persisted.sessionFile }, ); } const runCwd = persisted.cwd ?? cwd; const sessionManager = SessionManager.open(persisted.sessionFile); const healCount = healDanglingToolCalls(sessionManager); const builtInTools = createBuiltInTools(runCwd, persisted.agent?.tools); const extensionToolNames = persisted.agent?.extensionTools ?? []; const selectedSkillNames = persisted.agent?.skills ?? []; const modelSpec = persisted.agent?.model ?? persisted.model; const effortSpec = persisted.agent?.effort; const resolvedModel = resolveModelFromSpec(modelSpec, input.ctx); const resolvedEffort = normalizeEffort(effortSpec); const systemPrompt = persisted.systemPromptSnapshot ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT.trim(); const resourceLoader = createSkillAwareResourceLoader({ cwd: runCwd, systemPrompt, selectedSkills: selectedSkillNames, extensionTools: extensionToolNames, }); await resourceLoader.reload(); const details: SubagentRunDetails = { ...persisted, runId: input.runId, artifactsDir, sessionFile: persisted.sessionFile, systemPromptSnapshot: systemPrompt, phase: "running", finalText: "", error: undefined, salvagedFinalText: undefined, streamingCompleted: false, rawSessionOutput: undefined, timeline: [...(persisted.timeline ?? [])], startedAt: nowMs(), endedAt: undefined, durationMs: undefined, }; if (builtInTools.errors.length > 0 || resolvedModel.error || (effortSpec && !resolvedEffort)) { const errors = [...builtInTools.errors]; if (resolvedModel.error) errors.push(resolvedModel.error); if (effortSpec && !resolvedEffort) errors.push(`Unsupported effort '${effortSpec}'. Supported values: ${ALLOWED_EFFORTS.join(", ")}.`); return buildStartupFailure(details, errors.join(" ")); } const created = await createAgentSession({ cwd: runCwd, model: resolvedModel.model ?? input.ctx.model ?? undefined, resourceLoader, thinkingLevel: resolvedEffort ?? undefined, tools: [...builtInTools.normalized, ...extensionToolNames], customTools: buildHashlineCustomTools(runCwd), sessionManager, settingsManager: SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false, maxRetries: 0 }, }), }); details.systemPromptSnapshot = getSystemPromptSnapshot(created, systemPrompt); pushTimeline(details, { kind: "status", text: `resuming run ${input.runId} (healed ${healCount} dangling tool call(s))` }); const continuationPrompt = [ "You were interrupted mid-task by a process restart.", "Briefly recap in one sentence what you had completed before the interruption, based on your prior messages.", "Then resume the original task from where it left off.", "If any tool call shows as interrupted (is_error: true with an 'Interrupted by process restart' message), treat its world effect as unknown — decide whether to retry the call or proceed differently based on the task's idempotency.", ].join(" "); return promptSession({ session: created.session, task: continuationPrompt, details, signal: input.signal, keepAlive: false, onUpdate: input.onUpdate, }); } // Exposed for unit tests; not part of the public extension API. export const __testables = { deriveTerminalPhase, buildReturnContent, collectLastMessages, healDanglingToolCalls, };