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
40 changes: 40 additions & 0 deletions src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
18 changes: 16 additions & 2 deletions src/subagent/fleet-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
67 changes: 67 additions & 0 deletions src/subagent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
formatSubAgentReport,
nextToolCallStreak,
parseSubAgentReport,
repetitionStopDetail,
stopReasonFromReport,
appendDeadlineParentHint,
appendNeverActedParentHint,
appendSubAgentParentHints,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
formatSubAgentReport,
hasReportEnvelope,
parseSubAgentReport,
stopReasonFromReport,
subAgentToolName,
type DispatchBrief,
type SubAgentReport,
Expand Down Expand Up @@ -119,6 +120,7 @@ export {
buildSubAgentPrimarySource,
coreSubAgentWebTools,
createSubAgentRunController,
repetitionStopDetail,
runSubAgent,
shouldRequireEvidence,
type SubAgentRunController,
Expand Down
16 changes: 14 additions & 2 deletions src/subagent/nudge-director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
28 changes: 24 additions & 4 deletions src/subagent/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,31 @@ 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 {
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 @@ -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);
}
Expand Down
24 changes: 23 additions & 1 deletion src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -714,7 +730,13 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
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;
Expand Down
32 changes: 32 additions & 0 deletions src/subagent/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Loading
Loading