diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 7cfdb7fa2..fb89ea095 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -481,6 +481,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { // just because retained:true was requested at spawn. deps.sessions.complete(session.id, result.report, { agentRetained: result.agentRetained === true, + ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), }); }) .catch((err) => { diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 9a6aeaad4..c41ecda04 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -152,13 +152,13 @@ describe("forced-stop reasons", () => { lane({ id: "api", status: "done", - stopReason: 'repetition — window "Groaning. " × 1363', + stopReason: "stalled", }), lane({ id: "docs" }), ], T0 + 1000, ); - expect(updates).toEqual(['api stopped — repetition — window "Groaning. " × 1363']); + expect(updates).toEqual(["api stopped — stalled"]); }); test("a cancelled lane carries its recorded reason", () => { diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 0ae42b6e2..3ac362f18 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -15,7 +15,6 @@ import { forcedStopReport, formatSubAgentReport, parseSubAgentReport, - stopReasonFromReport, appendSubAgentParentHints, createBriefDispatchLedger, fingerprintTaskBrief, @@ -396,24 +395,21 @@ describe("sub-agent stop helpers", () => { ).not.toContain("wall-clock deadline"); }); - test("forcedStopReport carries a machine-readable Stopped line the parent sees verbatim", () => { - const cancelled = forcedStopReport("cancelled", "partial", "Session closed"); - expect(stopReasonFromReport(cancelled)).toBe("cancelled — Session closed"); - // Without a detail the line is the bare reason token. - expect(stopReasonFromReport(forcedStopReport("cancelled", "partial"))).toBe("cancelled"); - expect(stopReasonFromReport(forcedStopReport("deadline", "x", "30s elapsed"))).toBe( - "deadline — 30s elapsed", + test("forcedStopReport renders a Stopped line for display; classification uses the typed reason", () => { + expect(forcedStopReport("cancelled", "partial", "Session closed")).toMatch( + /^Stopped: cancelled — Session closed\n/, ); - - // A nested forced-stop quoted in Findings must not leak its Stopped line - // as the outer report's reason. + expect(forcedStopReport("cancelled", "partial")).toMatch(/^Stopped: cancelled\n/); + expect(forcedStopReport("deadline", "x", "30s elapsed")).toMatch( + /^Stopped: deadline — 30s elapsed\n/, + ); + // Nested Stopped: under Findings is display-only; classify via typed reason. const nested = forcedStopReport( "deadline", forcedStopReport("cancelled", "inner", "inner reason"), ); - expect(stopReasonFromReport(nested)).toBe("deadline"); - // A clean report has no Stopped line. - expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null); + expect(nested).toMatch(/^Stopped: deadline\n/); + expect(nested).toContain("Stopped: cancelled — inner reason"); }); test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => { diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 5666f6dd8..73a6f32a6 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -29,7 +29,6 @@ export { formatSubAgentReport, hasReportEnvelope, parseSubAgentReport, - stopReasonFromReport, subAgentToolName, type DispatchBrief, type SubAgentReport, diff --git a/src/subagent/report.ts b/src/subagent/report.ts index c66356c04..7270b2092 100644 --- a/src/subagent/report.ts +++ b/src/subagent/report.ts @@ -122,31 +122,15 @@ export interface SubAgentReport { findings: string; blockers: string; paths: string; - /** - * Machine-readable termination reason for a forced stop (e.g. - * `stalled — no output for 120s`). Rendered as a dedicated - * `Stopped:` line above the envelope; absent on successful completes. - */ + /** Display-only `Stopped:` preamble for forcedStopReport; never parsed back. */ stopped?: string; } -const STOPPED_LINE_RE = /^Stopped:\s*(.+)$/m; - -/** Machine-readable stop reason from a report's `Stopped:` line, or null. */ -export function stopReasonFromReport(report: string): string | null { - return parseSubAgentReport(report).stopped ?? null; -} - export function parseSubAgentReport(reply: string): SubAgentReport { const text = reply.trim(); const sections: Record = {}; const headingRe = /^##\s+(Summary|Findings|Blockers|Paths)\s*$/gim; const matches = [...text.matchAll(headingRe)]; - // Only the preamble (before the first heading) can carry the report's own - // Stopped: line — a nested forced-stop report quoted under Findings must - // not be read as this report's reason. - const preamble = matches.length > 0 ? text.slice(0, matches[0]?.index ?? 0) : ""; - const stopped = STOPPED_LINE_RE.exec(preamble)?.[1]?.trim(); if (matches.length === 0) { return { summary: text.length > 0 ? text : "Sub-agent finished without a textual result.", @@ -167,7 +151,6 @@ export function parseSubAgentReport(reply: string): SubAgentReport { findings: sections.findings ?? "", blockers: sections.blockers ?? "", paths: sections.paths ?? "", - ...(stopped !== undefined && stopped.length > 0 ? { stopped } : {}), }; } diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 5f93b7935..a05e1bdd1 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createSubAgentSessionStore } from "./session-store.js"; +import { forcedStopReport } from "./stop-policy.js"; import type { ReactorEmittedEvent } from "@intx/inference"; @@ -261,16 +262,21 @@ describe("parallel tool calls", () => { }); describe("terminal stop reasons", () => { - test("complete() records the report's Stopped line as stopReason", () => { + test("complete() records the typed stopReason, not report prose", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + store.complete(session.id, forcedStopReport("stalled", "partial"), { stopReason: "stalled" }); + expect(store.get(session.id)?.stopReason).toBe("stalled"); + }); + + test("literal Stopped: in report prose does not fabricate stopReason", () => { const store = createSubAgentSessionStore(); const session = store.start({ description: "d", agentId: "a", brief: "b" }); store.complete( session.id, - 'Stopped: repetition — window "Groaning. " × 1363\n\n## Summary\nStopped: degenerate repetition in streamed output (same window looping mid-turn).', + 'Stopped: repetition — window "Groaning. " × 1363\n\n## Summary\nStopped: looping.\n\n## Findings\nx', ); - const stored = store.get(session.id); - expect(stored?.status).toBe("done"); - expect(stored?.stopReason).toBe('repetition — window "Groaning. " × 1363'); + expect(store.get(session.id)?.stopReason).toBeUndefined(); }); test("a clean complete has no stopReason", () => { diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index f19bcd111..bbf8ab8f4 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -6,7 +6,7 @@ import type { ReactorEmittedEvent } from "@intx/inference"; import { DEFAULT_CLOSE_DEADLINE_MS } from "./dispose.js"; -import { stopReasonFromReport } from "./report.js"; +import type { ForcedStopReason } from "./stop-policy.js"; import { toolCallPreview } from "./tool-preview.js"; export type SubAgentSessionStatus = "running" | "done" | "failed" | "cancelled"; @@ -79,9 +79,8 @@ export interface SubAgentSession { report?: string; error?: string; /** - * Machine-readable termination reason for a forced stop (stall abort, - * operator cancel) — the report's `Stopped:` line, or `cancelled — ` - * on cancel. Absent on clean completes. + * Typed ForcedStopReason from runSubAgent, or `cancelled — ` on + * cancel(). Absent on clean completes. Never parsed from report prose. */ stopReason?: string; // Session id of the orchestrator that dispatched this worker, when this is @@ -147,7 +146,11 @@ export interface SubAgentSessionStore { // deadline/cancel salvage resolves the same promise agent-fleet routes // here but always disposes its agent first, so omitting/false-ing this // keeps a disposed session from ever reporting as resumable. - complete(id: string, report: string, opts?: { agentRetained?: boolean }): void; + complete( + id: string, + report: string, + opts?: { agentRetained?: boolean; stopReason?: ForcedStopReason }, + ): void; fail(id: string, error: string): void; // Register the live abort handle for a running session so cancel() can stop // the child reactor (agent.close), not only flip status. @@ -767,7 +770,11 @@ export function createSubAgentSessionStore( }); }, - complete(id: string, report: string, opts?: { agentRetained?: boolean }): void { + complete( + id: string, + report: string, + opts?: { agentRetained?: boolean; stopReason?: ForcedStopReason }, + ): void { // CL-7001: run.ts always disposes on a salvage return (deadline/cancel) // even though it resolves through this same success path — only trust // "still open, resumable" when the caller says the agent genuinely @@ -795,10 +802,7 @@ export function createSubAgentSessionStore( session.finishedAt = now(); clearToolCalls(session); session.report = report; - // A forced-stop salvage arrives via complete(); its Stopped: line is - // the terminal reason (stall abort, etc). - const stopped = stopReasonFromReport(report); - if (stopped !== null) session.stopReason = stopped; + if (opts?.stopReason !== undefined) session.stopReason = opts.stopReason; pushEntry(session, { kind: "report", content: capText(report, maxEntryChars) }); // A disposed salvage has nothing left for its close handle to do — // release it now rather than leaving a stale reference around. diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index e16c95944..844707ad3 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -840,7 +840,11 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { ), ); } - if (session !== undefined) deps.sessions?.complete(session.id, result.report); + if (session !== undefined) + deps.sessions?.complete(session.id, result.report, { + ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), + }); + const reported = appendSubAgentParentHints(result.report, result.stopReason, hintOptions); return await finishWithWorktree( taskToolResult(