import { retainDeliverable } from "../execution-evidence.ts"; import { preflightDeliverables, readBackDeliverables, type DeliverableContract } from "../acceptance.ts"; import { withNoProgress, type NoProgressGuard } from "../no-progress.ts"; import { randomUUID } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { blockingFindingCap, checkReviewRoundCap, checkTaskBudget, checkTierPersonaGate, checkTurnBudget, isReviewPersona, remainingTaskResearch, reviewBudgetClause, reviewRoundCap } from "../run-budget.js"; import { countReviewFindings, findingBudgetNotice } from "../review-findings.js"; import { checkDocsLane, docsLaneNotice } from "../docs-lane.js"; import { checkExternalBlockerGate, extractExternalBlockers } from "../external-blocker.js"; import type { BudgetRecovery } from "../budget-recovery.ts"; import { checkScope, diffAgainst, snapshotWorktree } from "../scope-gate.js"; import { crossCheck, deliveryDisposition, extractAssertionIds, parseDeliveredReturn } from "../return-contract.js"; import { shouldExtractReturn } from "../return-extract.js"; import { normalizeAgentInput, safeAgentKey, safePathWithin, taskFingerprint } from "../helpers.ts"; import { MAX_AUTO_RESEARCH_QUESTIONS, MAX_AUTO_RESEARCH_ROUNDS, type ResearchAgentDef, type ResearchRuntime } from "../research/runtime.ts"; import type { BudgetContext, SessionTotals, TurnReport } from "../context/budgets.ts"; import type { AssertionsArtifactsContext, InputArtifactPreview } from "../context/assertions-artifacts.ts"; import type { DispatchAgentParams, SpawnResearchParams, ToolExecutionResult, ToolExecutor, ToolUpdate } from "./context.ts"; type Gate = { reason: string; message: string } | null; type DispatchResult = import("../dispatch-native-types.ts").NativeDispatchResult; type AgentState = { def: { name: string; tools: string }; runCount: number; contextPct: number; lastBackend?: string | null }; export interface DispatchExecutionState { getTurnDispatchCount(): number; setTurnDispatchCount(value: number): void; getTurnResearchCount(): number; setTurnResearchCount(value: number): void; getTaskDispatchCount(): number; setTaskDispatchCount(value: number): void; getTaskResearchCount(): number; setTaskResearchCount(value: number): void; getTaskReviewRounds(): number; setTaskReviewRounds(value: number): void; getTaskTier(): string | null; getTurnReport(): TurnReport; getSessionTotals(): SessionTotals; getTurnDispatchFingerprints(): Set; getExternalBlockers(): Array<{ agent: string; what: string }>; getExternalBlockerAcknowledged(): boolean; setExternalBlockerAcknowledged(value: boolean): void; getExternalBlockerRefusedOnce(): boolean; setExternalBlockerRefusedOnce(value: boolean): void; isAskUserAvailable(): boolean; getUserLanguage(): string; getSessionDir(): string; getAgentStates(): Map; getResearchPersonas(): ResearchAgentDef[]; getActiveWritableDispatches(): number; setActiveWritableDispatches(value: number): void; getWritableOverlapCounter(): number; setWritableOverlapCounter(value: number): void; } export interface DispatchExecutorDeps { noProgress: NoProgressGuard; state: DispatchExecutionState; budget: BudgetContext; budgetRecovery: BudgetRecovery; artifacts: AssertionsArtifactsContext; research: ResearchRuntime; provisionalCapabilityRefusal(pack: "fleet"): ToolExecutionResult | null; dispatchAgent(agent: string, task: string, ctx: ExtensionContext, artifacts: InputArtifactPreview[], scope: string[], watchdog?: boolean, backend?: "auto" | "native" | "coms", resume?: boolean): Promise; runReturnExtraction(path: string, ids: string[], ctx: ExtensionContext): Promise; extractNeedsResearch(output: string): string[]; extractAskUserQuestions(output: string): string[]; contextPressure(percent: number): boolean; displayName(name: string): string; } interface PreparedDispatch { taskToken: object; contract: DeliverableContract; sessionDir: string; agent: string; task: string; inputArtifacts: InputArtifactPreview[]; scopeGlobs: string[]; fingerprint: string; } interface RunData { result: DispatchResult; billed: number; out: number; researchRounds: { questions: string[]; files: string[] }[]; autoResearchTaskCapped: boolean; } interface Tracking { writable: boolean; snapshot: any; overlapBaseline: number; concurrentAtStart: boolean; } export function preflightGate(d: DispatchExecutorDeps, persona: string): Gate { const s = d.state; const blocked = checkExternalBlockerGate({ blockers: s.getExternalBlockers(), acknowledged: s.getExternalBlockerAcknowledged(), askUserAvailable: s.isAskUserAvailable(), refusedOnce: s.getExternalBlockerRefusedOnce() }); if (blocked) { s.setExternalBlockerRefusedOnce(true); return blocked; } return checkTierPersonaGate(s.getTaskTier(), persona); } function refusal(d: DispatchExecutorDeps, agent: string, task: string, status: string, message: string, reason?: string): ToolExecutionResult { d.state.getTurnReport().refusals++; d.state.getSessionTotals().refusals++; return { content: [{ type: "text", text: message }], details: { agent, task, status, reason: reason ?? status, elapsed: 0, exitCode: 1, fullOutput: "" } }; } const RESEARCH_DISPATCH_NAMES = new Set(["researcher", "deep-researcher"]); function rosterNames(d: DispatchExecutorDeps): string[] { return Array.from(d.state.getAgentStates().values()).map(s => s.def.name); } function isResearchDispatchName(d: DispatchExecutorDeps, agent: string): boolean { if (RESEARCH_DISPATCH_NAMES.has(agent)) return true; return d.state.getResearchPersonas().some(p => normalizeAgentInput(p.name) === agent); } function unknownAgentMessage(agent: string, available: string[], researchHint: boolean): string { const roster = available.length ? available.join(", ") : "(none)"; const research = researchHint ? `\n\n"${agent}" is a research persona. Call spawn_research with persona "${agent}" instead of dispatch_agent. Do not invent a substitute dispatch.` : ""; return `Unknown agent "${agent}". dispatch_agent only accepts the active roster. Available agents: ${roster}.${research}\n\nDo not invent a substitute dispatch. Use an available agent, or spawn_research for a research persona.`; } export function validateDispatchAgent(d: DispatchExecutorDeps, agent: string, task: string): ToolExecutionResult | null { const available = rosterNames(d); const onRoster = d.state.getAgentStates().has(agent) || available.some(name => normalizeAgentInput(name) === agent); if (isResearchDispatchName(d, agent)) { return refusal(d, agent, task, "research_persona_via_dispatch", unknownAgentMessage(agent, available, true), "research_persona_via_dispatch"); } if (!onRoster) { return refusal(d, agent, task, "unknown_agent", unknownAgentMessage(agent, available, false), "unknown_agent"); } return null; } export function prepareDispatch(d: DispatchExecutorDeps, params: DispatchAgentParams, ctx: ExtensionContext): PreparedDispatch | ToolExecutionResult { const s = d.state; const { task, artifacts, scope, review_reason } = params; const agent = normalizeAgentInput(params.agent); d.budget.ensureTaskTier(); const rosterRefusal = validateDispatchAgent(d, agent, task); if (rosterRefusal) return rosterRefusal; const preflight = preflightGate(d, agent) ?? checkReviewRoundCap(s.getTaskTier(), agent, s.getTaskReviewRounds()) ?? checkDocsLane(agent, scope || [], review_reason); if (preflight) return refusal(d, agent, task, preflight.reason, preflight.message, preflight.reason); const taskRefusal = checkTaskBudget("dispatch", d.budget.taskCounters(), d.budget.currentTaskBudget(), d.budget.taskActiveElapsedMs(), s.getTaskTier()); if (taskRefusal) return refusal(d, agent, task, "task_budget_refused", taskRefusal.message, taskRefusal.reason); const turnRefusal = checkTurnBudget("dispatch", { dispatches: s.getTurnDispatchCount(), research: s.getTurnResearchCount() }, d.budget.currentBudget(), d.budget.turnBudgetActiveElapsedMs(), s.getTaskTier()); if (turnRefusal) return refusal(d, agent, task, "budget_refused", turnRefusal.message, turnRefusal.reason); let contract: DeliverableContract; try { contract = preflightDeliverables(params, { cwd: ctx.cwd || process.cwd(), sessionDir: s.getSessionDir() }); } catch (error) { return refusal(d, agent, task, "scope_preflight_failed", String(error)); } const fingerprint = taskFingerprint(agent, task); if (s.getTurnDispatchFingerprints().has(fingerprint)) return refusal(d, agent, task, "duplicate_refused", `⚠ Duplicate dispatch refused: you already dispatched ${agent} with this task (or a trivial rewording of it) THIS turn. Use the earlier result — re-read its digest/returnPath — or change the task materially (new instructions, corrected inputs) before re-dispatching.`); let inputArtifacts: InputArtifactPreview[]; try { inputArtifacts = d.artifacts.loadInputArtifacts(artifacts, ctx); } catch (err: any) { return { content: [{ type: "text", text: `⚠ Dispatch NOT sent and NOT counted against the turn budget — input artifact could not be resolved:\n${err?.message || err}\n\nFix the path and dispatch again.` }], details: { agent, task, status: "artifact_preflight_failed", elapsed: 0, exitCode: 1, fullOutput: "" } }; } s.setTurnDispatchCount(s.getTurnDispatchCount() + 1); s.setTaskDispatchCount(s.getTaskDispatchCount() + 1); if (isReviewPersona(agent)) s.setTaskReviewRounds(s.getTaskReviewRounds() + 1); s.getSessionTotals().dispatches++; d.budget.updateModeStatus(); const declaredTask = contract.files.length ? `${task}\n\n## Expected deliverables (explicit contract)\nProduce these exact files and report their paths. The hub will read them back; a prose claim is not delivery.\n${contract.files.map(file => `- ${file.path}`).join("\n")}` : task; return { taskToken: d.noProgress.taskToken(), contract, sessionDir: s.getSessionDir(), agent, task: declaredTask, inputArtifacts, scopeGlobs: (scope || []).map(String).map(x => x.trim()).filter(Boolean), fingerprint }; } function startTracking(d: DispatchExecutorDeps, prepared: PreparedDispatch, ctx: ExtensionContext): Tracking { const s = d.state; const state = s.getAgentStates().get(prepared.agent.toLowerCase()); const canWrite = !!state && hasWriteCapability(state.def.tools); const tracking = { writable: canWrite, snapshot: null as any, overlapBaseline: s.getWritableOverlapCounter(), concurrentAtStart: false }; if (canWrite) { tracking.concurrentAtStart = s.getActiveWritableDispatches() > 0; if (tracking.concurrentAtStart) s.setWritableOverlapCounter(s.getWritableOverlapCounter() + 1); s.setActiveWritableDispatches(s.getActiveWritableDispatches() + 1); if (prepared.scopeGlobs.length > 0) tracking.snapshot = snapshotWorktree(ctx.cwd || process.cwd()); } return tracking; } async function runWithAutoResearch(d: DispatchExecutorDeps, p: PreparedDispatch, params: DispatchAgentParams, ctx: ExtensionContext, onUpdate: ToolUpdate): Promise { const findingClause = reviewBudgetClause(d.state.getTaskTier(), p.agent); const dispatchedTask = findingClause ? `${p.task}\n\n${findingClause}` : p.task; let result = await d.dispatchAgent(p.agent, dispatchedTask, ctx, p.inputArtifacts, p.scopeGlobs, params.watchdog, params.backend ?? "auto"); let billed = result.billed ?? 0; let out = result.out ?? 0; const researchRounds: RunData["researchRounds"] = []; let autoResearchTaskCapped = false; while (result.exitCode === 0 && researchRounds.length < MAX_AUTO_RESEARCH_ROUNDS && p.taskToken === d.noProgress.taskToken() && p.sessionDir === d.state.getSessionDir()) { const left = remainingTaskResearch(d.budget.currentTaskBudget(), d.budget.taskCounters()); if (left === 0) { autoResearchTaskCapped = true; break; } const questions = d.extractNeedsResearch(result.output).slice(0, left == null ? MAX_AUTO_RESEARCH_QUESTIONS : Math.min(MAX_AUTO_RESEARCH_QUESTIONS, left)); if (!questions.length) break; d.state.setTaskResearchCount(d.state.getTaskResearchCount() + questions.length); d.budget.updateModeStatus(); onUpdate?.({ content: [{ type: "text", text: `${p.agent} paused for research (${questions.length} question(s)) — spawning read-only helpers...` }], details: { agent: p.agent, task: p.task, status: "researching" } }); const findingsDir = safePathWithin(p.sessionDir, "findings"); mkdirSync(findingsDir, { recursive: true }); const key = safeAgentKey(d.state.getAgentStates().get(p.agent.toLowerCase())?.def.name ?? p.agent); const answered = await Promise.all(questions.map(async question => { const def = d.research.anonymousDef(); const state = d.research.createState(def, false, d.research.resolveModel(def, undefined, ctx)); const response = await d.research.spawn(state, question, ctx); const file = safePathWithin(findingsDir, `${key}-${response.dispatchId ?? randomUUID()}.md`); writeFileSync(file, `# Research findings r${state.id}\n\n**Question:** ${question}\n\n${response.exitCode === 0 ? response.output : `(research helper failed, exit ${response.exitCode})\n\n${response.output}`}\n`, { encoding: "utf-8", flag: "wx" }); return { question, file }; })); researchRounds.push({ questions, files: answered.map(a => a.file) }); const resume = "Research findings for your NEEDS_RESEARCH questions are ready. Read each file with your read tool, then continue from where you paused:\n" + answered.map((a, i) => `${i + 1}. ${a.question}\n → ${a.file}`).join("\n"); if (p.taskToken !== d.noProgress.taskToken() || p.sessionDir !== d.state.getSessionDir()) break; result = await d.dispatchAgent(p.agent, resume, ctx, p.inputArtifacts, p.scopeGlobs, params.watchdog, params.backend ?? "auto", true); billed += result.billed ?? 0; out += result.out ?? 0; } return { result, billed, out, researchRounds, autoResearchTaskCapped }; } function scopeResult(d: DispatchExecutorDeps, p: PreparedDispatch, tracking: Tracking, ctx: ExtensionContext): any { if (!tracking.snapshot) return null; const diff = diffAgainst(tracking.snapshot, ctx.cwd || process.cwd()); const concurrentWritableOverlap = tracking.concurrentAtStart || d.state.getWritableOverlapCounter() !== tracking.overlapBaseline; if (diff.skipped) return { skipped: true, reason: diff.reason, declaredScope: p.scopeGlobs, concurrentWritableOverlap }; return { ...checkScope(diff.paths, p.scopeGlobs), changedPaths: diff.paths, declaredScope: p.scopeGlobs, concurrentWritableOverlap }; } async function finishDispatch(d: DispatchExecutorDeps, p: PreparedDispatch, params: DispatchAgentParams, run: RunData, tracking: Tracking, ctx: ExtensionContext, onUpdate: ToolUpdate): Promise { const s = d.state; const { result, researchRounds } = run; const sameTask = p.taskToken === d.noProgress.taskToken() && p.sessionDir === s.getSessionDir(); const ids = extractAssertionIds(p.task); const disposition = deliveryDisposition(result.exitCode, result.pending === true); let { parsed: parsedReturn, notices: contractNotices } = parseDeliveredReturn(result.output, ids, disposition.delivered); const shouldUseDigest = ids.length > 0 || !!parsedReturn; const state = sameTask ? s.getAgentStates().get(p.agent.toLowerCase()) : undefined; const key = safeAgentKey(state?.def.name ?? p.agent); const failureMetadata = { dispatchId: result.dispatchId ?? null, transcriptPath: result.transcriptPath ?? null, agent: p.agent, task: p.task, scope: p.scopeGlobs, exitCode: result.exitCode, diagnostics: result.diagnostics ?? null }; const artifactOutput = disposition.delivered ? result.output : `# Dispatch failure\n\n\`\`\`json\n${JSON.stringify(failureMetadata, null, 2)}\n\`\`\`\n\n## Output\n\n${result.output}`; const runPath = disposition.artifactKind ? d.artifacts.writeRunArtifact(key, state?.runCount ?? 0, artifactOutput, disposition.artifactKind, result.dispatchId, p.sessionDir) : null; const returnPath = disposition.delivered ? runPath : null; const failurePath = disposition.delivered ? null : runPath; let returnExtracted = false; if (returnPath && shouldExtractReturn(parsedReturn, ids)) { onUpdate?.({ content: [{ type: "text", text: `${p.agent} returned no structured block — extracting it from the report...` }], details: { agent: p.agent, task: p.task, status: "extracting_return" } }); const recovered = await d.runReturnExtraction(returnPath, ids, ctx); if (recovered) { parsedReturn = recovered; contractNotices = crossCheck(recovered, ids); returnExtracted = true; } } const assessmentId = result.dispatchId ?? randomUUID(); const readback = disposition.pending ? [] : readBackDeliverables(p.contract, { cwd: ctx.cwd || process.cwd(), sessionDir: p.sessionDir }, (index, bytes) => retainDeliverable(p.sessionDir, assessmentId, index, bytes)); const deliverableFailed = readback.some(file => file.status !== "read"); const executionStatus = disposition.pending ? "pending" : disposition.delivered ? "completed" : "failed"; const status = disposition.delivered ? deliverableFailed ? "verification_failed" : "completed_unverified" : disposition.status; const acceptanceStatus = disposition.pending || !disposition.delivered ? "not_available" : deliverableFailed ? "deliverable_failed" : "needs_verification"; const assessment = { executionStatus, acceptanceStatus, accepted: false, dispatchId: result.dispatchId ?? null, readback, scopeRoots: p.contract.scopeRoots, assertions: ids }; const assessmentPath = disposition.pending ? null : d.artifacts.writeRunArtifact(`${key}-acceptance`, state?.runCount ?? 0, JSON.stringify(assessment, null, 2), "evidence", assessmentId, p.sessionDir); if (sameTask && [0, 124, 125].includes(result.exitCode)) s.getTurnDispatchFingerprints().add(p.fingerprint); if (sameTask) { s.getTurnReport().dispatches.push({ agent: p.agent, status, elapsed: result.elapsed, billed: run.billed, out: run.out }); s.getSessionTotals().billed += run.billed; s.getSessionTotals().out += run.out; } const questions = d.extractAskUserQuestions(result.output); const unresolved = d.extractNeedsResearch(result.output); const answered = researchRounds.reduce((n, r) => n + r.questions.length, 0); const notices: string[] = []; if (!sameTask) notices.push("Late result from a prior task/session: evidence retained in its original namespace; current-task counters and blockers were not changed."); if (disposition.delivered) notices.push(`Execution completed, NOT accepted. ${deliverableFailed ? "Expected deliverable readback failed." : "Verify correctness against the assertion ledger; file presence and exit 0 are not proof."} Acceptance check: ${assessmentPath}`); if (readback.some(file => file.changed === false)) notices.push("Some deliverables already existed unchanged. Do not attribute their creation to this execution."); const blockers = extractExternalBlockers(result.output); if (sameTask && blockers.length) { for (const what of blockers) if (!s.getExternalBlockers().some(b => b.what === what)) s.getExternalBlockers().push({ agent: p.agent, what }); s.setExternalBlockerAcknowledged(false); s.setExternalBlockerRefusedOnce(false); notices.push(`⛔ ${p.agent} reported an EXTERNAL BLOCKER — something outside the fleet's reach is missing:\n${blockers.map((w, i) => ` ${i + 1}. ${w}`).join("\n")}\nThe next dispatch/research call is refused until you escalate this to the human. Do not build a substitute for the missing fact.`); } if (questions.length) notices.push(`⚠ ${questions.length} ASK_USER question(s) raised by ${p.agent}. You MUST call ask_user for each (in ${s.getUserLanguage()}) before re-dispatching:\n${questions.map((q, i) => ` ${i + 1}. ${q}`).join("\n")}`); if (researchRounds.length) notices.push(`ℹ ${p.agent} auto-paused for research ${researchRounds.length} round(s); ${answered} question(s) answered by read-only helpers. Findings were saved under ${safePathWithin(s.getSessionDir(), "findings")} and read by the agent directly — they are NOT inlined here.`); if (unresolved.length && researchRounds.length >= MAX_AUTO_RESEARCH_ROUNDS) notices.push(`⚠ ${p.agent} still requests research (${unresolved.length} question(s)) but the auto-research budget is exhausted. Run spawn_research yourself and re-dispatch with the findings, or simplify the task.`); if (run.autoResearchTaskCapped) notices.push(`⚠ ${p.agent} paused for research, but the TASK research envelope is spent (${s.getTaskResearchCount()}/${d.budget.currentTaskBudget().maxResearch}) — no helper was spawned and the specialist was not resumed. Its questions are unanswered. Narrow the task so it can proceed on what it has, or call set_task_tier with new_task: true if this is genuinely different work.`); if (returnPath) notices.push(`Full specialist output: ${returnPath}`); if (disposition.pending) notices.push("⏳ DELIVERY PENDING — no result or assertion evidence is available yet, and no return/failure artifact was written. Use the msg_id above with coms_get/coms_await; do not re-dispatch."); if (failurePath) notices.push(`⚠ DELIVERY FAILURE (exit ${result.exitCode}) — no specialist result was returned. The error output is at ${failurePath}; it is NOT a return and carries no assertion evidence. The work may or may not have happened — check the artifacts the task was supposed to produce before re-dispatching.`); const corrected = p.inputArtifacts.filter(a => a.resolvedFromKind); if (corrected.length) notices.push(`ℹ Artifact path corrected: ${corrected.map(a => `"${a.input}" → ${a.displayPath}`).join("; ")}. Use the corrected path from now on.`); if (state && d.contextPressure(state.contextPct)) notices.push(`⚠ ${d.displayName(state.def.name)} context at ${Math.ceil(state.contextPct)}% — consider /af-agents-restart ${state.def.name} (state lives in the artifacts/ledger, a restart is cheap).`); const scopeViolations = scopeResult(d, p, tracking, ctx); const scopeNotice = scopeNoticeText(scopeViolations); if (scopeNotice) notices.push(scopeNotice.trim()); const finding = isReviewPersona(p.agent) ? findingBudgetNotice(p.agent, blockingFindingCap(s.getTaskTier()), countReviewFindings(result.output), s.getTaskReviewRounds(), reviewRoundCap(s.getTaskTier())) || "" : ""; if (finding) notices.push(finding.trim()); const docs = docsLaneNotice(p.agent, p.scopeGlobs); if (docs) notices.push(docs); const contract = contractNoticeText(contractNotices); const extraction = returnExtracted ? "ℹ The specialist declared no structured return. The block below was EXTRACTED from its report by a cheap read-only pass — weaker than a declared return. Verify the named evidence before you gate on it." : ""; const digest = shouldUseDigest ? [extraction, structuredReturnDigest(parsedReturn) || "Structured return: (none parsed)", contract].filter(Boolean).join("\n\n") : (result.output.length > 8000 ? `${result.output.slice(0, 8000)}\n\n... [truncated]` : result.output); return { content: [{ type: "text", text: `[${p.agent}] ${status} in ${Math.round(result.elapsed / 1000)}s${notices.length ? `\n\n${notices.join("\n\n")}` : ""}\n\n${digest}` }], details: { agent: p.agent, task: p.task, status, executionStatus, acceptanceStatus, accepted: false, staleTask: !sameTask, deliverableReadback: readback, scopeRoots: p.contract.scopeRoots, assessmentPath, backendRequested: params.backend ?? "auto", backendUsed: state?.lastBackend ?? null, elapsed: result.elapsed, exitCode: result.exitCode, fullOutput: result.output, dispatchId: result.dispatchId ?? null, transcriptPath: result.transcriptPath ?? null, diagnostics: result.diagnostics ?? null, evidencePath: result.evidencePath ?? null, structuredReturn: parsedReturn, returnExtracted, pending: disposition.pending, returnPath, failurePath, contractNotices, questions, researchRounds, scopeViolations, sessionReset: result.sessionReset ?? null, artifacts: p.inputArtifacts.map(a => ({ path: a.path, displayPath: a.displayPath, preview: a.preview, resolvedFromKind: a.resolvedFromKind ?? null })) } }; } export function createDispatchExecutor(d: DispatchExecutorDeps): ToolExecutor { return withNoProgress(d, "dispatch", async (_id, params, signal, onUpdate, ctx) => { const capability = d.provisionalCapabilityRefusal("fleet"); if (capability) return capability; d.budget.ensureTaskTier(); const agent = normalizeAgentInput(params.agent); const invalid = validateDispatchAgent(d, agent, params.task); if (invalid) return invalid; const preflight = preflightGate(d, agent) ?? checkReviewRoundCap(d.state.getTaskTier(), agent, d.state.getTaskReviewRounds()) ?? checkDocsLane(agent, params.scope || [], params.review_reason); if (preflight) return refusal(d, agent, params.task, preflight.reason, preflight.message); try { preflightDeliverables(params, { cwd: ctx.cwd || process.cwd(), sessionDir: d.state.getSessionDir() }); } catch (error) { return refusal(d, agent, params.task, "scope_preflight_failed", String(error)); } const budgetBlock = await d.budgetRecovery.ensure("dispatch", `${agent}: ${params.task}`, ctx, signal); if (budgetBlock) return refusal(d, agent, params.task, budgetBlock.reason, budgetBlock.message); if (signal?.aborted) return refusal(d, agent, params.task, "cancelled", "Operation cancelled before dispatch."); const prepared = prepareDispatch(d, params, ctx); if (!("agent" in prepared)) return prepared; const tracking = startTracking(d, prepared, ctx); try { onUpdate?.({ content: [{ type: "text", text: `Dispatching to ${prepared.agent}...` }], details: { agent: prepared.agent, task: prepared.task, status: "dispatching" } }); return await finishDispatch(d, prepared, params, await runWithAutoResearch(d, prepared, params, ctx, onUpdate), tracking, ctx, onUpdate); } catch (err: any) { return { content: [{ type: "text", text: `Error dispatching to ${prepared.agent}: ${err?.message || err}` }], details: { agent: prepared.agent, task: prepared.task, status: "error", elapsed: 0, exitCode: 1, fullOutput: "" } }; } finally { if (tracking.writable) d.state.setActiveWritableDispatches(Math.max(0, d.state.getActiveWritableDispatches() - 1)); } }); } export function createResearchExecutor(d: DispatchExecutorDeps): ToolExecutor { return withNoProgress(d, "research", async (_id, params, signal, onUpdate, ctx) => { const capability = d.provisionalCapabilityRefusal("fleet"); if (capability) return capability; const s = d.state; d.budget.ensureTaskTier(); const preflight = preflightGate(d, params.persona || ""); if (preflight) return refusal(d, "", params.task, preflight.reason, preflight.message, preflight.reason); const budgetBlock = await d.budgetRecovery.ensure("research", params.task, ctx, signal); if (budgetBlock) return refusal(d, "", params.task, budgetBlock.reason, budgetBlock.message); if (signal?.aborted) return refusal(d, "", params.task, "cancelled", "Operation cancelled before research."); const taskRefusal = checkTaskBudget("research", d.budget.taskCounters(), d.budget.currentTaskBudget(), d.budget.taskActiveElapsedMs(), s.getTaskTier()); if (taskRefusal) return refusal(d, "", params.task, "task_budget_refused", taskRefusal.message, taskRefusal.reason); const turnRefusal = checkTurnBudget("research", { dispatches: s.getTurnDispatchCount(), research: s.getTurnResearchCount() }, d.budget.currentBudget(), d.budget.turnBudgetActiveElapsedMs(), s.getTaskTier()); if (turnRefusal) return refusal(d, "", params.task, "budget_refused", turnRefusal.message, turnRefusal.reason); let def: any; let persona = false; if (params.persona) { def = s.getResearchPersonas().find(x => x.name.toLowerCase() === params.persona!.toLowerCase()); if (!def) return { content: [{ type: "text", text: `No research persona "${params.persona}". Available: ${s.getResearchPersonas().map(x => x.name).join(", ") || "(none defined)"}. Omit \`persona\` for an ad-hoc helper. (Not counted against the turn budget.)` }], details: { status: "error" } }; persona = true; } else def = d.research.anonymousDef(); const model = d.research.resolveModel(def, persona ? undefined : params.model, ctx); let artifacts: InputArtifactPreview[]; try { artifacts = d.artifacts.loadInputArtifacts(params.artifacts, ctx); } catch (err: any) { return { content: [{ type: "text", text: `⚠ Research NOT spawned and NOT counted against the turn budget — input artifact could not be resolved:\n${err?.message || err}\n\nFix the path and try again.` }], details: { status: "artifact_preflight_failed" } }; } s.setTurnResearchCount(s.getTurnResearchCount() + 1); s.setTaskResearchCount(s.getTaskResearchCount() + 1); s.getTurnReport().research++; s.getSessionTotals().research++; d.budget.updateModeStatus(); const state = d.research.createState(def, persona, model); onUpdate?.({ content: [{ type: "text", text: `Spawning research helper r${state.id}...` }], details: { handle: `r${state.id}`, persona: persona ? def.name : null, status: "spawning" } }); try { const result = await d.research.spawn(state, params.task, ctx, artifacts, signal); const status = result.termination ? result.termination.reason : result.exitCode === 0 ? "done" : "error"; const output = result.output.length > 8000 ? `${result.output.slice(0, 8000)}\n\n... [truncated]` : result.output; return { content: [{ type: "text", text: `[research r${state.id} · ${persona ? d.displayName(def.name) : "ad-hoc"} · read-only] ${status} in ${Math.round(result.elapsed / 1000)}s\n\n${output}${result.evidencePath ? `\n\nFull execution evidence: ${result.evidencePath}` : ""}` }], details: { handle: `r${state.id}`, persona: persona ? def.name : null, model, status, elapsed: result.elapsed, exitCode: result.exitCode, fullOutput: result.output, dispatchId: result.dispatchId, evidencePath: result.evidencePath, transcriptPath: result.transcriptPath, termination: result.termination, artifacts: artifacts.map(a => ({ path: a.path, displayPath: a.displayPath, preview: a.preview, resolvedFromKind: a.resolvedFromKind ?? null })) } }; } catch (err: any) { return { content: [{ type: "text", text: `Error spawning research helper: ${err?.message || err}` }], details: { handle: `r${state.id}`, status: "error", elapsed: 0, exitCode: 1, fullOutput: "" } }; } }); } function hasWriteCapability(tools: string): boolean { const set = new Set(String(tools || "").split(",").map(x => x.trim()).filter(Boolean)); return ["write", "edit", "bash"].some(x => set.has(x)); } function scopeNoticeText(v: any): string { if (!v) return ""; if (v.skipped) return `\n\n⚠ Scope gate skipped: ${v.reason || "not a git worktree"}.`; if (!v.outOfScope?.length) return ""; const overlap = v.concurrentWritableOverlap ? " Concurrent writable dispatches overlapped this run, so attribution is approximate." : ""; return `\n\n⚠ Scope advisory: changed outside declared scope: ${v.outOfScope.join(", ")}. Review these paths and decide whether to accept them or explicitly order cleanup; the hub did not revert anything.${overlap}`; } function structuredReturnDigest(parsed: any): string { if (!parsed) return ""; const lines = ["Structured return (parsed):"]; for (const key of ["assertions_proven", "assertions_unproven", "assertions_failed"]) { const entries = parsed[key] || []; if (!entries.length) continue; lines.push(`${key}:`); for (const entry of entries) { const evidence = entry.evidence ? ` — evidence: ${entry.evidence}` : ""; const note = entry.note || (entry.evidence ? "" : "(no note)"); lines.push(`- ${entry.id}${note ? `: ${note}` : ""}${evidence}`); } } for (const key of ["changed_files", "tests_run", "open_risks", "requires_user_decision"]) { const entries = parsed[key] || []; if (entries.length) lines.push(`${key}: ${entries.slice(0, 5).join("; ")}${entries.length > 5 ? " …" : ""}`); } return lines.join("\n"); } function contractNoticeText(notices: any[]): string { if (!notices?.length) return ""; const lines = ["⚠ Structured return contract notices:"]; const missing = notices.filter(n => n.type === "missing").map(n => n.id); const noStructured = notices.find(n => n.type === "no_structured_return"); if (noStructured) lines.push(`- no_structured_return: no parseable structured return for dispatched assertions ${(noStructured.ids || []).join(", ")} — treat all as unproven; full output is on disk.`); if (missing.length) lines.push(`- missing: return does not cover ${missing.join(", ")} — treat as unproven.`); for (const n of notices.filter(n => n.type === "proven_without_evidence")) lines.push(`- proven_without_evidence: ${n.id} claimed proven without named evidence — demoted to unproven.`); return lines.join("\n"); }