Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 10 additions & 14 deletions src/subagent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
forcedStopReport,
formatSubAgentReport,
parseSubAgentReport,
stopReasonFromReport,
appendSubAgentParentHints,
createBriefDispatchLedger,
fingerprintTaskBrief,
Expand Down Expand Up @@ -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 () => {
Expand Down
1 change: 0 additions & 1 deletion src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export {
formatSubAgentReport,
hasReportEnvelope,
parseSubAgentReport,
stopReasonFromReport,
subAgentToolName,
type DispatchBrief,
type SubAgentReport,
Expand Down
19 changes: 1 addition & 18 deletions src/subagent/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
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.",
Expand All @@ -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 } : {}),
};
}

Expand Down
16 changes: 11 additions & 5 deletions src/subagent/session-store.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down
24 changes: 14 additions & 10 deletions src/subagent/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 — <reason>`
* on cancel. Absent on clean completes.
* Typed ForcedStopReason from runSubAgent, or `cancelled — <reason>` 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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/subagent/task-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading