import {appendDiscoveryContext,type ResearchAdmission} from '../file-discovery/native-context.ts'; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { beginExecutionEvidence, finishExecutionEvidence } from "../execution-evidence.ts"; import { profileFallback, readActiveProfile } from '../policy/profile-runtime.ts'; import { resolveAssist } from '../assist-profile.ts'; import { BOUNDED_OUTPUT_DIR_ENV } from '../bounded-output.ts'; import { FILESYSTEM_SESSION_DIR_ENV } from '../filesystem-tool.ts'; import { boundOutput } from '../deterministic-fs.ts'; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { contextPct, resolveContextWindow } from "../context-window.js"; import { isReadOnlyToolList, safePathWithin } from "../helpers.ts"; import { researchTerminationOutcome, researchWatchdogSpawnOptions } from "../research-watchdog.ts"; import { explicitProcessLifecycle, lifecycleAfterSpawnException } from "../recovery-contract.ts"; import type { InputArtifactPreview } from "../context/assertions-artifacts.ts"; import type { ResearchAgentDef, ResearchFinalizeOutcome, ResearchResult, ResearchRuntimeDeps, ResearchState } from "./runtime.ts"; import { bumpMessageCount } from "../ui/activity-dots.ts"; export interface ResearchSpawnPorts extends ResearchRuntimeDeps { researchTools: string; sessionPath(id: number): string; finalize(state: ResearchState, outcome: ResearchFinalizeOutcome): void; } function completeOutput(state: ResearchState, res: any): string { let output = String(res.output ?? ""); if (res.modelFallback) { output = `(ℹ model fallback: ${res.modelFallback.from} failed before work began; retried once with original persona model ${res.modelFallback.to}.)\n\n${output}`; } if (res.exitCode !== 0) { const errText = [res.assistantError, res.stderr].filter(Boolean).join("\n\n").trim(); const tail = errText.length > 1500 ? `...\n${errText.slice(-1500)}` : errText; const errBlock = tail ? `\n\n[stderr]\n${tail}` : ""; output = output ? `${output}${errBlock}` : `Research helper r${state.id} exited with code ${res.exitCode} and produced no output.${errBlock}`; } return output; } export async function runResearchSpawn( deps: ResearchSpawnPorts, state: ResearchState, prompt: string, ctx: ExtensionContext, inputArtifacts: InputArtifactPreview[] = [], signal?: AbortSignal, admission?:ResearchAdmission, ): Promise { const startTime = Date.now(); const launchIdentity=deps.launchIdentity?.(), sessionDir=deps.hubState.getSessionDir(), admittedTools=state.def.tools; const admitted=()=>state.def.tools===admittedTools&&!signal?.aborted&&!state.killedByOperator&&deps.getResearchStates().get(state.id)===state&&sessionDir===deps.hubState.getSessionDir()&&launchIdentity===deps.launchIdentity?.()&&deps.researchAdmission?.()!==false&&admission?.admit?.()!==false; let result: ResearchResult | undefined; let diagnostics: object | null = null; let spawnAttempted = false; let spawnLifecycle: { launched: boolean; closeSeen: boolean } | undefined; const transcriptPath = safePathWithin(state.evidenceDir, "transcript.jsonl"); const settle = (status: ResearchFinalizeOutcome["status"], historyStatus: ResearchFinalizeOutcome["historyStatus"], lastWork: string, settled: ResearchResult): ResearchResult => { state.elapsed = settled.elapsed; deps.finalize(state, { status, historyStatus, lastWork }); deps.flushTimelineStore(state); result = finishExecutionEvidence(state.evidenceDir, { ...settled, dispatchId: state.dispatchId, transcriptPath, sessionPath: state.sessionPath, diagnostics }); return result; }; try { beginExecutionEvidence(dirname(dirname(state.evidenceDir)), state.dispatchId, { task: prompt, agent: state.def.name, modelRequested: state.model }, inputArtifacts); if (!state.histEntry) state.histEntry = deps.executionHistory.start("research", `Research r${state.id}`); const safety = deps.requireSafetyHarness(deps.getSafetyHarnessPath()); // T3: safety refusal never launched a process — distinct from spawn failure and timeout. if (!safety.ok) return settle("error", "error", safety.error, { output: safety.error, exitCode: 1, elapsed: 0, lifecycle: { launched: false, closeSeen: false } }); state.status = "running"; state.task = prompt; state.toolCount = 0; state.messageCount = 0; state.elapsed = 0; state.lastWork = ""; state.killedByOperator = false; deps.flushTimelineStore(state); state.timeline = []; state.transcriptStore = deps.createTranscriptStore(transcriptPath); state.timer = setInterval(() => { state.elapsed = Date.now() - startTime; deps.onElapsed?.(); }, 1000); const thinkingLevel = deps.resolveThinkingLevel(deps.resolvedThinking(state.def)); const wantThinking = thinkingLevel !== "off"; const sessionPath = state.sessionPath; const researchWindow = resolveContextWindow(state.model, { lookup: deps.modelWindowLookup(ctx), fallbackWindow: deps.getContextWindow() }); const fallbackCandidate = profileFallback(deps.substitutedModel(deps.fallbackModelFor(state.def, state.model))); const fallback = fallbackCandidate === state.model ? undefined : fallbackCandidate; let fullText = ""; deps.notifyProviderQueue(state.model, `Research r${state.id}`, ctx); const assist = resolveAssist(readActiveProfile()?.profile.assist); const boundedOutput = assist['bounded-output']; const declaredTools = state.def.tools.split(",").map(tool => tool.trim()).filter(Boolean); const deterministicTools = assist['deterministic-tools'] && (state.def.toolsExplicit !== true || declaredTools.includes("filesystem")); const boundedOutputDir = safePathWithin(state.evidenceDir, "bounded-output"); const cappedTools=state.def.toolsExplicit===true?deps.researchTools.split(',').filter(t=>declaredTools.includes(t)).join(','):deps.researchTools; const researchTools = deterministicTools && !cappedTools.split(",").includes("filesystem") ? `${cappedTools},filesystem` : cappedTools; const cwd = ctx.cwd || process.cwd(); if(!admitted())return settle('error','error','Research admission cancelled',{output:'Research cancelled or admission changed before manifest; no child started.',exitCode:143,elapsed:Date.now()-startTime,lifecycle:{launched:false,closeSeen:false}}); const discovery=await deps.prepareDiscovery?.({ownerId:state.dispatchId,task:prompt,query:prompt,scope:admission?.readScope??[],tools:researchTools.split(','),cwd,signal}); if(!admitted()||discovery?.current()===false)return settle('error','error','Research admission changed',{output:'Research cancelled or admission changed after manifest; no child started.',exitCode:143,elapsed:Date.now()-startTime,lifecycle:{launched:false,closeSeen:false}}); const res = await deps.providerSemaphore.run(state.model, () => { if(!admitted()||discovery?.current()===false)throw Error('Research admission changed while queued; no child started.'); spawnAttempted = true; return deps.spawnPiAgentWithModelFallback({ discoveryRegistration:discovery?.registration?{...discovery.registration,admit:()=>admitted()&&discovery.current()}:undefined, model: state.model, tools: researchTools, thinking: thinkingLevel, systemPrompt: deps.nativeResearchSystemPrompt({ ...(state.persona ? { personaName: state.def.name, personaPath: state.def.file } : {}), cwd, rulesPaths: deps.getProjectPolicyPaths(cwd), docsPaths: deps.getProjectDocsPaths(), }), noSkills: true, noContextFiles: true, sessionFile: sessionPath, resume: false, prompt: appendDiscoveryContext(deps.artifacts.appendInputArtifacts(prompt, inputArtifacts),discovery), cwd: ctx.cwd || process.cwd(), extensions: [...safety.extensions, ...(boundedOutput ? [fileURLToPath(new URL("../bounded-output.ts", import.meta.url))] : []), ...(deterministicTools ? [fileURLToPath(new URL("../filesystem-tool.ts", import.meta.url))] : [])], env: { ...deps.guardrailEnv(`research-r${state.id}`), ...(boundedOutput ? { [BOUNDED_OUTPUT_DIR_ENV]: boundedOutputDir } : {}), ...(deterministicTools ? { [FILESYSTEM_SESSION_DIR_ENV]: dirname(dirname(state.evidenceDir)) } : {}) }, ...(boundedOutput ? { boundedOutputDir } : {}), ...researchWatchdogSpawnOptions(deps.getReconSearchTimeoutMs(), signal), turnDeadlineMs: deps.budget.currentBudget().agentTurnMs, }, fallback, { onProcess: process => { state.proc = process; }, onModelFallback: ({ from, to, reason }) => { state.lastWork = `model fallback: ${deps.shortModel(from)} → ${deps.shortModel(to)}`; ctx.ui.notify(`Research r${state.id}: overridden model ${from} failed before work began; retrying with persona model ${to} (${reason})`, "warning"); }, onTextDelta: delta => { fullText += delta; state.lastWork = fullText.split("\n").filter(line => line.trim()).pop() || ""; bumpMessageCount(state, "text"); deps.appendTimelineText(state, "text", delta); state.zoomRender?.(); }, onThinkingDelta: delta => { if (!wantThinking) return; deps.appendTimelineText(state, "thinking", delta); state.zoomRender?.(); }, onToolStart: (toolName, argStr, callId) => { state.toolCount++; deps.appendTimelineEvent(state, { kind: "tool-start", title: `Tool: ${toolName}`, content: argStr, timestamp: Date.now(), ...(callId ? { callId } : {}) }); state.zoomRender?.(); }, onToolEnd: (toolName, callId, isError, resultText, durationMs) => { deps.appendTimelineEvent(state, { kind: "tool-result", title: `Result: ${toolName}`, content: resultText ?? "", timestamp: Date.now(), ...(callId ? { callId } : {}), status: isError ? "error" : "success", ...(durationMs == null ? {} : { durationMs }) }); state.zoomRender?.(); }, onUsage: usage => { state.contextTokens = (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0); if (researchWindow.window > 0) { state.contextPct = contextPct(usage, researchWindow.window); } }, }, { midRun: isReadOnlyToolList(researchTools) }); }); diagnostics = { assistantError: res.assistantError ?? null, stderr: res.stderr, spawnError: res.spawnError ?? null, modelUsed: res.modelUsed ?? null, toolCallsStarted: res.toolCallsStarted ?? null, termination: res.termination ?? null, processExitCode: res.exitCode }; state.elapsed = Date.now() - startTime; state.proc = undefined; // T3: only explicit spawn lifecycle is trusted. Missing facts stay omitted, not inferred. spawnLifecycle = explicitProcessLifecycle((res as { lifecycle?: unknown }).lifecycle); const lifecycle = spawnLifecycle; if (res.spawnError) { return settle("error", "error", `Error: ${res.spawnError}`, { output: `Error spawning research helper: ${res.spawnError}`, exitCode: 1, elapsed: state.elapsed, lifecycle }); } if (res.termination) { const outcome = researchTerminationOutcome(state.id, res.termination); return settle("error", "error", outcome.lastWork, { output: outcome.output, exitCode: outcome.exitCode, elapsed: state.elapsed, termination: res.termination, lifecycle }); } if (state.killedByOperator) { state.killedByOperator = false; return settle("idle", "idle", "(killed by operator)", { output: `Research helper r${state.id} was killed by the operator before it finished.`, exitCode: res.exitCode == null || res.exitCode === 0 ? 143 : res.exitCode, elapsed: state.elapsed, lifecycle }); } const status = res.exitCode === 0 ? "done" : "error"; const lastWork = String(res.output ?? "").split("\n").filter((line: string) => line.trim()).pop() || ""; const completed = completeOutput(state, res); const parent = res.boundedOutput ? boundOutput({ content: completed, retentionDir: safePathWithin(state.evidenceDir, "bounded-output", "parent"), label: "parent-summary" }) : null; const settled = settle(status, status, lastWork, { output: parent?.reply ?? completed, exitCode: res.exitCode ?? 1, elapsed: state.elapsed, lifecycle }); ctx.ui.notify(`Research r${state.id} ${status} in ${Math.round(state.elapsed / 1000)}s`, status === "done" ? "info" : "error"); return settled; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); state.elapsed = Date.now() - startTime; const lifecycle = lifecycleAfterSpawnException({ spawnAttempted, spawnLifecycle, processSeen: !!state.proc }); return settle("error", "error", `Error: ${message}`, { output: `Error spawning research helper: ${message}`, exitCode: 1, elapsed: state.elapsed, ...(lifecycle ? { lifecycle } : {}) }); } finally { if (!result) { state.elapsed = Date.now() - startTime; deps.finalize(state, { status: "error", historyStatus: "error", lastWork: "terminated unexpectedly" }); } } }