diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 4de789106..9a6aeaad4 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -138,3 +138,43 @@ describe("fleetDigest", () => { expect(fleetDigest([], T0)).toBe("nothing running"); }); }); + +describe("forced-stop reasons", () => { + test("a lane finished by a forced stop announces the reason, not a bare done", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs" })], + T0, + ).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ + id: "api", + status: "done", + stopReason: 'repetition — window "Groaning. " × 1363', + }), + lane({ id: "docs" }), + ], + T0 + 1000, + ); + expect(updates).toEqual(['api stopped — repetition — window "Groaning. " × 1363']); + }); + + test("a cancelled lane carries its recorded reason", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs" })], + T0, + ).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ id: "api", status: "cancelled", stopReason: "cancelled — Session closed" }), + lane({ id: "docs" }), + ], + T0 + 1000, + ); + expect(updates).toEqual(["api stopped — cancelled — Session closed"]); + }); +}); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 70a487feb..9e0715ff1 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -27,6 +27,8 @@ export interface FleetLane { readonly currentToolStartedAt: number | null; readonly report?: string; readonly error?: string; + /** Machine-readable forced-stop reason (see SubAgentSession.stopReason). */ + readonly stopReason?: string; } interface LaneMark { @@ -147,14 +149,26 @@ export function observeFleet( if (before.status !== lane.status) { if (lane.status === "done") { - changes.push({ kind: "done", line: `${lane.description} done` }); + // A forced stop (repetition / stall / salvage caps) lands as "done" + // with a stopReason — that is attention, not a success line. + if (lane.stopReason !== undefined) { + changes.push({ + kind: "failed", + line: `${lane.description} stopped — ${clip(lane.stopReason, OUTCOME_CHARS)}`, + }); + } else { + changes.push({ kind: "done", line: `${lane.description} done` }); + } } else if (lane.status === "failed") { changes.push({ kind: "failed", line: `${lane.description} failed — ${clip(firstLine(lane.error) || "no error reported", OUTCOME_CHARS)}`, }); } else if (lane.status === "cancelled") { - changes.push({ kind: "failed", line: `${lane.description} cancelled` }); + changes.push({ + kind: "failed", + line: `${lane.description} stopped — ${clip(lane.stopReason ?? "cancelled", OUTCOME_CHARS)}`, + }); } continue; } diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 7f747eab5..4630b273d 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -19,6 +19,8 @@ import { formatSubAgentReport, nextToolCallStreak, parseSubAgentReport, + repetitionStopDetail, + stopReasonFromReport, appendDeadlineParentHint, appendNeverActedParentHint, appendSubAgentParentHints, @@ -768,6 +770,48 @@ describe("sub-agent stop helpers", () => { ); }); + test("forcedStopReport carries a machine-readable Stopped line the parent sees verbatim", () => { + const repetition = forcedStopReport( + "repetition", + "Looped window (repeated 1363x): Groaning. ", + 'window "Groaning. " × 1363', + ); + expect(repetition.startsWith('Stopped: repetition — window "Groaning. " × 1363\n')).toBe(true); + expect(parseSubAgentReport(repetition).stopped).toBe('repetition — window "Groaning. " × 1363'); + expect(stopReasonFromReport(repetition)).toBe('repetition — window "Groaning. " × 1363'); + // Survives runSubAgent's parse/format normalization round-trip. + const roundTripped = formatSubAgentReport(parseSubAgentReport(repetition)); + expect(stopReasonFromReport(roundTripped)).toBe('repetition — window "Groaning. " × 1363'); + // Classifiers and hints still fire on the unchanged Summary text. + expect(appendSubAgentParentHints(repetition)).toContain("degenerated into a loop"); + + 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("turn-budget", "x", "30/30 turns"))).toBe( + "turn-budget — 30/30 turns", + ); + + // A nested forced-stop quoted in Findings must not leak its Stopped line + // as the outer report's reason. + const nested = forcedStopReport( + "never-acted", + forcedStopReport("cancelled", "inner", "inner reason"), + ); + expect(stopReasonFromReport(nested)).toBe("never-acted"); + // A clean report has no Stopped line. + expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null); + }); + + test("repetitionStopDetail formats the looped window snippet and repeat count", () => { + expect(repetitionStopDetail({ window: "Groaning. ", repeats: 1363 })).toBe( + 'window "Groaning. " × 1363', + ); + const long = repetitionStopDetail({ window: "x".repeat(500), repeats: 7 }); + expect(long).toBe(`window "${"x".repeat(80)}" × 7`); + }); + test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => { const ctl = createSubAgentRunController(undefined, 20); expect(ctl.signal.aborted).toBe(false); @@ -1884,6 +1928,29 @@ describe("createTaskTool", () => { expect(row?.status).toBe("cancelled"); }); + test("pre-progress cancel surfaces the recorded cancel reason to the parent", async () => { + const sessions = createSubAgentSessionStore(); + const tool = createTaskTool({ + permissionGate: testPermissionGate, + cwd: "/repo", + getWorkdirBase: () => "/repo/.corbits", + provider, + sessions, + run: async () => { + const row = sessions.list().find((s) => s.description === "reasoned"); + if (row !== undefined) sessions.cancel(row.id, "Session closed"); + const err = new Error("aborted"); + err.name = "AbortError"; + throw err; + }, + }); + const out = await callTask(tool, { description: "reasoned", prompt: "x", intent: "explore" }); + expect(out).toContain("Stopped: cancelled — Session closed"); + const row = sessions.list().find((s) => s.description === "reasoned"); + expect(row?.status).toBe("cancelled"); + expect(row?.stopReason).toBe("cancelled — Session closed"); + }); + test("pre-progress AbortError still surfaces as bare cancel", async () => { const tool = createTaskTool({ permissionGate: testPermissionGate, diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 21ee91f21..b7af0d5d8 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -40,6 +40,7 @@ export { formatSubAgentReport, hasReportEnvelope, parseSubAgentReport, + stopReasonFromReport, subAgentToolName, type DispatchBrief, type SubAgentReport, @@ -119,6 +120,7 @@ export { buildSubAgentPrimarySource, coreSubAgentWebTools, createSubAgentRunController, + repetitionStopDetail, runSubAgent, shouldRequireEvidence, type SubAgentRunController, diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index f8a445aee..c78fc22ea 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -283,9 +283,15 @@ export class SubAgentDirector extends DefaultDirector { : stop === "no-ship" ? "subagent-no-ship" : "subagent-turn-budget"; + const detail = + stop === "no-progress" + ? `identical tool call × ${this.streak.consecutiveIdentical}` + : stop === "turn-budget" + ? `${this.turnsCompleted}/${this.maxTurns} turns` + : undefined; const terminal: ReactorAction[] = [ capabilities.checkpoint(checkpoint), - capabilities.reply(forcedStopReport(stop, lastText(content))), + capabilities.reply(forcedStopReport(stop, lastText(content), detail)), ]; this.compaction.noteIdleTurn(event, terminal); const compacted = this.compaction.interceptActions(event, terminal, capabilities); @@ -344,7 +350,13 @@ export class SubAgentDirector extends DefaultDirector { } const terminal: ReactorAction[] = [ capabilities.checkpoint("subagent-stalled"), - capabilities.reply(forcedStopReport("stalled", this.lastAssistantText)), + capabilities.reply( + forcedStopReport( + "stalled", + this.lastAssistantText, + `no activity for ${Math.round(elapsed / 1000)}s after stall nudge`, + ), + ), ]; return terminal; } diff --git a/src/subagent/report.ts b/src/subagent/report.ts index 5222034e0..e300afa58 100644 --- a/src/subagent/report.ts +++ b/src/subagent/report.ts @@ -112,6 +112,19 @@ export interface SubAgentReport { findings: string; blockers: string; paths: string; + /** + * Machine-readable termination reason for a forced stop (e.g. + * `repetition — window "Groaning. " × 1363`). Rendered as a dedicated + * `Stopped:` line above the envelope; absent on successful completes. + */ + 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 { @@ -119,6 +132,11 @@ export function parseSubAgentReport(reply: string): SubAgentReport { 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.", @@ -139,14 +157,16 @@ export function parseSubAgentReport(reply: string): SubAgentReport { findings: sections.findings ?? "", blockers: sections.blockers ?? "", paths: sections.paths ?? "", + ...(stopped !== undefined && stopped.length > 0 ? { stopped } : {}), }; } export function formatSubAgentReport(report: SubAgentReport): string { - const lines: string[] = [ - "## Summary", - report.summary.length > 0 ? report.summary : "(no summary)", - ]; + const lines: string[] = []; + if (report.stopped !== undefined && report.stopped.length > 0) { + lines.push(`Stopped: ${report.stopped}`, ""); + } + lines.push("## Summary", report.summary.length > 0 ? report.summary : "(no summary)"); if (report.findings.length > 0) { lines.push("", "## Findings", report.findings); } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index e8e5a3133..ed5751405 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -225,6 +225,22 @@ export function createSubAgentRunController( }; } +/** Stopped-line detail for a repetition abort: the looped window plus repeat count. */ +export function repetitionStopDetail(hit: RepetitionHit): string { + return `window ${JSON.stringify(hit.window.slice(0, 80))} × ${hit.repeats}`; +} + +/** String form of an abort signal's reason (cancel detail), or undefined. */ +function abortReasonText(signal: AbortSignal): string | undefined { + const reason: unknown = signal.reason; + if (typeof reason === "string" && reason.length > 0) return reason; + // A bare abort() carries a default AbortError — no operator-written cause. + if (reason instanceof Error && reason.name !== "AbortError" && reason.message.length > 0) { + return reason.message; + } + return undefined; +} + /** * Arm requireEvidence only for CritiqueDirector. Greybeard is also * intent=review and may spawn-only then envelope; that is not a fake @@ -714,7 +730,13 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { repetition.hit !== null ? `Looped window (repeated ${repetition.hit.repeats}x): ${repetition.hit.window.slice(0, 300)}\n\n${tail}` : tail; - return appendActivitySummary(forcedStopReport(reason, partial), toolNamesUsed); + const detail = + repetition.hit !== null + ? repetitionStopDetail(repetition.hit) + : reason === "deadline" && resolvedDeadlineMs !== undefined + ? `${resolvedDeadlineMs}ms elapsed` + : abortReasonText(runController.signal); + return appendActivitySummary(forcedStopReport(reason, partial, detail), toolNamesUsed); } } throw err; diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 9eee9a2b0..fbefffcd4 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -259,3 +259,35 @@ describe("parallel tool calls", () => { expect(store.get(session.id)?.currentToolStartedAt).toBeNull(); }); }); + +describe("terminal stop reasons", () => { + test("complete() records the report's Stopped line as 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).', + ); + const stored = store.get(session.id); + expect(stored?.status).toBe("done"); + expect(stored?.stopReason).toBe('repetition — window "Groaning. " × 1363'); + }); + + test("a clean complete has no stopReason", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + store.complete(session.id, "## Summary\nDone.\n\n## Findings\nx"); + expect(store.get(session.id)?.stopReason).toBeUndefined(); + }); + + test("cancel() records the cancel reason as stopReason", () => { + const store = createSubAgentSessionStore(); + const withReason = store.start({ description: "d", agentId: "a", brief: "b" }); + store.cancel(withReason.id, "Session closed"); + expect(store.get(withReason.id)?.stopReason).toBe("cancelled — Session closed"); + + const bare = store.start({ description: "d2", agentId: "a", brief: "b" }); + store.cancel(bare.id); + expect(store.get(bare.id)?.stopReason).toBe("cancelled"); + }); +}); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 5e5214548..bad5e7469 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -5,6 +5,7 @@ // this store is the dedicated child record the enter-session UI reads. import type { ReactorEmittedEvent } from "@intx/inference"; +import { stopReasonFromReport } from "./report.js"; import { toolCallPreview } from "./tool-preview.js"; export type SubAgentSessionStatus = "running" | "done" | "failed" | "cancelled"; @@ -65,6 +66,12 @@ export interface SubAgentSession { finishedAt?: number; report?: string; error?: string; + /** + * Machine-readable termination reason for a forced stop (repetition guard, + * stall abort, salvage caps, operator cancel) — the report's `Stopped:` + * line, or `cancelled — ` on cancel. Absent on clean completes. + */ + stopReason?: string; // Session id of the orchestrator that dispatched this worker, when this is // a nested (one-hop) dispatch. Undefined for top-level sessions started // directly from the primary session's task tool. @@ -116,6 +123,8 @@ export interface SubAgentSessionStore { clear(): void; } +export const DEFAULT_CANCEL_REASON = "Cancelled by operator"; + const DEFAULT_MAX_COMPLETED = 20; const DEFAULT_MAX_ENTRIES = 400; const DEFAULT_MAX_ENTRY_CHARS = 24_000; @@ -273,6 +282,7 @@ export function createSubAgentSessionStore( session.lastActivityAt = now(); clearToolCalls(session); session.error = reason; + session.stopReason = reason === DEFAULT_CANCEL_REASON ? "cancelled" : `cancelled — ${reason}`; pushEntry(session, { kind: "report", content: capText(`Cancelled: ${reason}`, maxEntryChars), @@ -539,6 +549,10 @@ 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 (repetition / stall / salvage caps). + const stopped = stopReasonFromReport(report); + if (stopped !== null) session.stopReason = stopped; pushEntry(session, { kind: "report", content: capText(report, maxEntryChars) }); cancelHandles.delete(id); pruneCompleted(); @@ -567,11 +581,11 @@ export function createSubAgentSessionStore( cancelHandles.set(id, abort); }, - cancel(id: string, reason = "Cancelled by operator"): boolean { + cancel(id: string, reason = DEFAULT_CANCEL_REASON): boolean { return cancelSession(id, reason); }, - cancelAll(reason = "Cancelled by operator"): string[] { + cancelAll(reason = DEFAULT_CANCEL_REASON): string[] { const running = [...sessions.values()].filter((s) => s.status === "running"); const cancelled: string[] = []; for (const session of running) { @@ -617,6 +631,7 @@ function cloneSession(session: SubAgentSession): SubAgentSession { ...(session.finishedAt !== undefined ? { finishedAt: session.finishedAt } : {}), ...(session.report !== undefined ? { report: session.report } : {}), ...(session.error !== undefined ? { error: session.error } : {}), + ...(session.stopReason !== undefined ? { stopReason: session.stopReason } : {}), ...(session.parentSessionId !== undefined ? { parentSessionId: session.parentSessionId } : {}), }; } diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index f84da894f..9085cb9a4 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -426,25 +426,31 @@ export function partialTextFromEvent(event: ReactorEmittedEvent): string | null return text.length > 0 ? text : null; } +export type ForcedStopReason = + | "no-progress" + | "turn-budget" + | "never-acted" + | "never-edited" + | "cancelled" + | "deadline" + | "thrash" + | "no-ship" + | "stalled" + | "repetition" + | "incomplete-report"; + /** * Build the parent-facing report when a leaf is force-stopped. There is no * further inference, so this must already be a full envelope — not an - * instruction asking the finished worker to summarize. + * instruction asking the finished worker to summarize. `detail` is the + * path-specific specifics (looped window × count, turn counts, cancel reason) + * rendered verbatim on the report's `Stopped:` line so the parent and the TUI + * see the cause, not just that the worker stopped. */ export function forcedStopReport( - reason: - | "no-progress" - | "turn-budget" - | "never-acted" - | "never-edited" - | "cancelled" - | "deadline" - | "thrash" - | "no-ship" - | "stalled" - | "repetition" - | "incomplete-report", + reason: ForcedStopReason, partialText: string, + detail?: string, ): string { const summary = reason === "no-progress" @@ -503,6 +509,7 @@ export function forcedStopReport( findings, blockers, paths: "", + stopped: detail !== undefined && detail.length > 0 ? `${reason} — ${detail}` : reason, }); } diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 3426428ed..431949922 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -33,7 +33,7 @@ import { type ReasoningEffort, } from "../provider/reasoning-effort.js"; import { isCodexProviderName } from "../config/codex-providers.js"; -import type { SubAgentSessionStore } from "./session-store.js"; +import { DEFAULT_CANCEL_REASON, type SubAgentSessionStore } from "./session-store.js"; import { buildDispatchBrief, type TaskIntent } from "./report.js"; import { appendSubAgentParentHints } from "./stop-policy.js"; import { @@ -756,8 +756,13 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (session !== undefined && deps.sessions?.get(session.id)?.status === "running") { deps.sessions.cancel(session.id, cancelReason(childCtl.signal)); } + // Prefer the store's recorded reason (strip cancel writes it there + // before aborting); fall back to the abort signal's reason. + const reason = + (session !== undefined ? deps.sessions?.get(session.id)?.error : undefined) ?? + cancelReason(childCtl.signal); return await finishWithWorktree( - taskToolResult(call.id, cancelledSubAgentMessage(description)), + taskToolResult(call.id, cancelledSubAgentMessage(description, reason)), ); } subagentStatus = "failed"; @@ -793,9 +798,13 @@ function cancelReason(signal: AbortSignal): string { const reason = signal.reason; if (typeof reason === "string" && reason.length > 0) return reason; if (reason instanceof Error && reason.message.length > 0) return reason.message; - return "Cancelled by operator"; + return DEFAULT_CANCEL_REASON; } -function cancelledSubAgentMessage(description: string): string { - return `Sub-agent "${description}" cancelled by operator.`; +function cancelledSubAgentMessage(description: string, reason?: string): string { + const base = `Sub-agent "${description}" cancelled by operator.`; + // Only a non-default reason adds signal ("Session cleared", a stop cause…). + return reason !== undefined && reason !== DEFAULT_CANCEL_REASON + ? `${base} Stopped: cancelled — ${reason}` + : base; }