Skip to content

Commit dab8460

Browse files
Merge branch 'main' into cl-6902-repetition-detector-misses-short-phrase-loops-and-zero
Amp-Thread-ID: https://ampcode.com/threads/T-01a02c68-0d8d-777b-b717-81fb9a282023 Co-authored-by: Amp <amp@ampcode.com>
2 parents b06492d + 799b205 commit dab8460

11 files changed

Lines changed: 271 additions & 29 deletions

src/subagent/fleet-report.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,43 @@ describe("fleetDigest", () => {
138138
expect(fleetDigest([], T0)).toBe("nothing running");
139139
});
140140
});
141+
142+
describe("forced-stop reasons", () => {
143+
test("a lane finished by a forced stop announces the reason, not a bare done", () => {
144+
const seeded = observeFleet(
145+
createFleetWatch(),
146+
[lane({ id: "api" }), lane({ id: "docs" })],
147+
T0,
148+
).watch;
149+
const { updates } = observeFleet(
150+
seeded,
151+
[
152+
lane({
153+
id: "api",
154+
status: "done",
155+
stopReason: 'repetition — window "Groaning. " × 1363',
156+
}),
157+
lane({ id: "docs" }),
158+
],
159+
T0 + 1000,
160+
);
161+
expect(updates).toEqual(['api stopped — repetition — window "Groaning. " × 1363']);
162+
});
163+
164+
test("a cancelled lane carries its recorded reason", () => {
165+
const seeded = observeFleet(
166+
createFleetWatch(),
167+
[lane({ id: "api" }), lane({ id: "docs" })],
168+
T0,
169+
).watch;
170+
const { updates } = observeFleet(
171+
seeded,
172+
[
173+
lane({ id: "api", status: "cancelled", stopReason: "cancelled — Session closed" }),
174+
lane({ id: "docs" }),
175+
],
176+
T0 + 1000,
177+
);
178+
expect(updates).toEqual(["api stopped — cancelled — Session closed"]);
179+
});
180+
});

src/subagent/fleet-report.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface FleetLane {
2727
readonly currentToolStartedAt: number | null;
2828
readonly report?: string;
2929
readonly error?: string;
30+
/** Machine-readable forced-stop reason (see SubAgentSession.stopReason). */
31+
readonly stopReason?: string;
3032
}
3133

3234
interface LaneMark {
@@ -147,14 +149,26 @@ export function observeFleet(
147149

148150
if (before.status !== lane.status) {
149151
if (lane.status === "done") {
150-
changes.push({ kind: "done", line: `${lane.description} done` });
152+
// A forced stop (repetition / stall / salvage caps) lands as "done"
153+
// with a stopReason — that is attention, not a success line.
154+
if (lane.stopReason !== undefined) {
155+
changes.push({
156+
kind: "failed",
157+
line: `${lane.description} stopped — ${clip(lane.stopReason, OUTCOME_CHARS)}`,
158+
});
159+
} else {
160+
changes.push({ kind: "done", line: `${lane.description} done` });
161+
}
151162
} else if (lane.status === "failed") {
152163
changes.push({
153164
kind: "failed",
154165
line: `${lane.description} failed — ${clip(firstLine(lane.error) || "no error reported", OUTCOME_CHARS)}`,
155166
});
156167
} else if (lane.status === "cancelled") {
157-
changes.push({ kind: "failed", line: `${lane.description} cancelled` });
168+
changes.push({
169+
kind: "failed",
170+
line: `${lane.description} stopped — ${clip(lane.stopReason ?? "cancelled", OUTCOME_CHARS)}`,
171+
});
158172
}
159173
continue;
160174
}

src/subagent/index.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
formatSubAgentReport,
2020
nextToolCallStreak,
2121
parseSubAgentReport,
22+
repetitionStopDetail,
23+
stopReasonFromReport,
2224
appendDeadlineParentHint,
2325
appendNeverActedParentHint,
2426
appendSubAgentParentHints,
@@ -768,6 +770,48 @@ describe("sub-agent stop helpers", () => {
768770
);
769771
});
770772

773+
test("forcedStopReport carries a machine-readable Stopped line the parent sees verbatim", () => {
774+
const repetition = forcedStopReport(
775+
"repetition",
776+
"Looped window (repeated 1363x): Groaning. ",
777+
'window "Groaning. " × 1363',
778+
);
779+
expect(repetition.startsWith('Stopped: repetition — window "Groaning. " × 1363\n')).toBe(true);
780+
expect(parseSubAgentReport(repetition).stopped).toBe('repetition — window "Groaning. " × 1363');
781+
expect(stopReasonFromReport(repetition)).toBe('repetition — window "Groaning. " × 1363');
782+
// Survives runSubAgent's parse/format normalization round-trip.
783+
const roundTripped = formatSubAgentReport(parseSubAgentReport(repetition));
784+
expect(stopReasonFromReport(roundTripped)).toBe('repetition — window "Groaning. " × 1363');
785+
// Classifiers and hints still fire on the unchanged Summary text.
786+
expect(appendSubAgentParentHints(repetition)).toContain("degenerated into a loop");
787+
788+
const cancelled = forcedStopReport("cancelled", "partial", "Session closed");
789+
expect(stopReasonFromReport(cancelled)).toBe("cancelled — Session closed");
790+
// Without a detail the line is the bare reason token.
791+
expect(stopReasonFromReport(forcedStopReport("cancelled", "partial"))).toBe("cancelled");
792+
expect(stopReasonFromReport(forcedStopReport("turn-budget", "x", "30/30 turns"))).toBe(
793+
"turn-budget — 30/30 turns",
794+
);
795+
796+
// A nested forced-stop quoted in Findings must not leak its Stopped line
797+
// as the outer report's reason.
798+
const nested = forcedStopReport(
799+
"never-acted",
800+
forcedStopReport("cancelled", "inner", "inner reason"),
801+
);
802+
expect(stopReasonFromReport(nested)).toBe("never-acted");
803+
// A clean report has no Stopped line.
804+
expect(stopReasonFromReport("## Summary\nDone.\n\n## Findings\nx")).toBe(null);
805+
});
806+
807+
test("repetitionStopDetail formats the looped window snippet and repeat count", () => {
808+
expect(repetitionStopDetail({ window: "Groaning. ", repeats: 1363 })).toBe(
809+
'window "Groaning. " × 1363',
810+
);
811+
const long = repetitionStopDetail({ window: "x".repeat(500), repeats: 7 });
812+
expect(long).toBe(`window "${"x".repeat(80)}" × 7`);
813+
});
814+
771815
test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => {
772816
const ctl = createSubAgentRunController(undefined, 20);
773817
expect(ctl.signal.aborted).toBe(false);
@@ -1884,6 +1928,29 @@ describe("createTaskTool", () => {
18841928
expect(row?.status).toBe("cancelled");
18851929
});
18861930

1931+
test("pre-progress cancel surfaces the recorded cancel reason to the parent", async () => {
1932+
const sessions = createSubAgentSessionStore();
1933+
const tool = createTaskTool({
1934+
permissionGate: testPermissionGate,
1935+
cwd: "/repo",
1936+
getWorkdirBase: () => "/repo/.corbits",
1937+
provider,
1938+
sessions,
1939+
run: async () => {
1940+
const row = sessions.list().find((s) => s.description === "reasoned");
1941+
if (row !== undefined) sessions.cancel(row.id, "Session closed");
1942+
const err = new Error("aborted");
1943+
err.name = "AbortError";
1944+
throw err;
1945+
},
1946+
});
1947+
const out = await callTask(tool, { description: "reasoned", prompt: "x", intent: "explore" });
1948+
expect(out).toContain("Stopped: cancelled — Session closed");
1949+
const row = sessions.list().find((s) => s.description === "reasoned");
1950+
expect(row?.status).toBe("cancelled");
1951+
expect(row?.stopReason).toBe("cancelled — Session closed");
1952+
});
1953+
18871954
test("pre-progress AbortError still surfaces as bare cancel", async () => {
18881955
const tool = createTaskTool({
18891956
permissionGate: testPermissionGate,

src/subagent/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export {
4040
formatSubAgentReport,
4141
hasReportEnvelope,
4242
parseSubAgentReport,
43+
stopReasonFromReport,
4344
subAgentToolName,
4445
type DispatchBrief,
4546
type SubAgentReport,
@@ -119,6 +120,7 @@ export {
119120
buildSubAgentPrimarySource,
120121
coreSubAgentWebTools,
121122
createSubAgentRunController,
123+
repetitionStopDetail,
122124
runSubAgent,
123125
shouldRequireEvidence,
124126
type SubAgentRunController,

src/subagent/nudge-director.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,15 @@ export class SubAgentDirector extends DefaultDirector {
283283
: stop === "no-ship"
284284
? "subagent-no-ship"
285285
: "subagent-turn-budget";
286+
const detail =
287+
stop === "no-progress"
288+
? `identical tool call × ${this.streak.consecutiveIdentical}`
289+
: stop === "turn-budget"
290+
? `${this.turnsCompleted}/${this.maxTurns} turns`
291+
: undefined;
286292
const terminal: ReactorAction[] = [
287293
capabilities.checkpoint(checkpoint),
288-
capabilities.reply(forcedStopReport(stop, lastText(content))),
294+
capabilities.reply(forcedStopReport(stop, lastText(content), detail)),
289295
];
290296
this.compaction.noteIdleTurn(event, terminal);
291297
const compacted = this.compaction.interceptActions(event, terminal, capabilities);
@@ -344,7 +350,13 @@ export class SubAgentDirector extends DefaultDirector {
344350
}
345351
const terminal: ReactorAction[] = [
346352
capabilities.checkpoint("subagent-stalled"),
347-
capabilities.reply(forcedStopReport("stalled", this.lastAssistantText)),
353+
capabilities.reply(
354+
forcedStopReport(
355+
"stalled",
356+
this.lastAssistantText,
357+
`no activity for ${Math.round(elapsed / 1000)}s after stall nudge`,
358+
),
359+
),
348360
];
349361
return terminal;
350362
}

src/subagent/report.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,31 @@ export interface SubAgentReport {
112112
findings: string;
113113
blockers: string;
114114
paths: string;
115+
/**
116+
* Machine-readable termination reason for a forced stop (e.g.
117+
* `repetition — window "Groaning. " × 1363`). Rendered as a dedicated
118+
* `Stopped:` line above the envelope; absent on successful completes.
119+
*/
120+
stopped?: string;
121+
}
122+
123+
const STOPPED_LINE_RE = /^Stopped:\s*(.+)$/m;
124+
125+
/** Machine-readable stop reason from a report's `Stopped:` line, or null. */
126+
export function stopReasonFromReport(report: string): string | null {
127+
return parseSubAgentReport(report).stopped ?? null;
115128
}
116129

117130
export function parseSubAgentReport(reply: string): SubAgentReport {
118131
const text = reply.trim();
119132
const sections: Record<string, string> = {};
120133
const headingRe = /^##\s+(Summary|Findings|Blockers|Paths)\s*$/gim;
121134
const matches = [...text.matchAll(headingRe)];
135+
// Only the preamble (before the first heading) can carry the report's own
136+
// Stopped: line — a nested forced-stop report quoted under Findings must
137+
// not be read as this report's reason.
138+
const preamble = matches.length > 0 ? text.slice(0, matches[0]?.index ?? 0) : "";
139+
const stopped = STOPPED_LINE_RE.exec(preamble)?.[1]?.trim();
122140
if (matches.length === 0) {
123141
return {
124142
summary: text.length > 0 ? text : "Sub-agent finished without a textual result.",
@@ -139,14 +157,16 @@ export function parseSubAgentReport(reply: string): SubAgentReport {
139157
findings: sections.findings ?? "",
140158
blockers: sections.blockers ?? "",
141159
paths: sections.paths ?? "",
160+
...(stopped !== undefined && stopped.length > 0 ? { stopped } : {}),
142161
};
143162
}
144163

145164
export function formatSubAgentReport(report: SubAgentReport): string {
146-
const lines: string[] = [
147-
"## Summary",
148-
report.summary.length > 0 ? report.summary : "(no summary)",
149-
];
165+
const lines: string[] = [];
166+
if (report.stopped !== undefined && report.stopped.length > 0) {
167+
lines.push(`Stopped: ${report.stopped}`, "");
168+
}
169+
lines.push("## Summary", report.summary.length > 0 ? report.summary : "(no summary)");
150170
if (report.findings.length > 0) {
151171
lines.push("", "## Findings", report.findings);
152172
}

src/subagent/run.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,22 @@ export function createSubAgentRunController(
229229
};
230230
}
231231

232+
/** Stopped-line detail for a repetition abort: the looped window plus repeat count. */
233+
export function repetitionStopDetail(hit: RepetitionHit): string {
234+
return `window ${JSON.stringify(hit.window.slice(0, 80))} × ${hit.repeats}`;
235+
}
236+
237+
/** String form of an abort signal's reason (cancel detail), or undefined. */
238+
function abortReasonText(signal: AbortSignal): string | undefined {
239+
const reason: unknown = signal.reason;
240+
if (typeof reason === "string" && reason.length > 0) return reason;
241+
// A bare abort() carries a default AbortError — no operator-written cause.
242+
if (reason instanceof Error && reason.name !== "AbortError" && reason.message.length > 0) {
243+
return reason.message;
244+
}
245+
return undefined;
246+
}
247+
232248
/**
233249
* Arm requireEvidence only for CritiqueDirector. Greybeard is also
234250
* intent=review and may spawn-only then envelope; that is not a fake
@@ -756,7 +772,15 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
756772
: repetition.contentless
757773
? `Contentless output: the stream grew with only invisible characters (zero-width flood).\n\n${tail}`
758774
: tail;
759-
return appendActivitySummary(forcedStopReport(reason, partial), toolNamesUsed);
775+
const detail =
776+
repetition.hit !== null
777+
? repetitionStopDetail(repetition.hit)
778+
: repetition.contentless
779+
? "contentless/zero-width flood"
780+
: reason === "deadline" && resolvedDeadlineMs !== undefined
781+
? `${resolvedDeadlineMs}ms elapsed`
782+
: abortReasonText(runController.signal);
783+
return appendActivitySummary(forcedStopReport(reason, partial, detail), toolNamesUsed);
760784
}
761785
}
762786
throw err;

src/subagent/session-store.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,35 @@ describe("parallel tool calls", () => {
259259
expect(store.get(session.id)?.currentToolStartedAt).toBeNull();
260260
});
261261
});
262+
263+
describe("terminal stop reasons", () => {
264+
test("complete() records the report's Stopped line as stopReason", () => {
265+
const store = createSubAgentSessionStore();
266+
const session = store.start({ description: "d", agentId: "a", brief: "b" });
267+
store.complete(
268+
session.id,
269+
'Stopped: repetition — window "Groaning. " × 1363\n\n## Summary\nStopped: degenerate repetition in streamed output (same window looping mid-turn).',
270+
);
271+
const stored = store.get(session.id);
272+
expect(stored?.status).toBe("done");
273+
expect(stored?.stopReason).toBe('repetition — window "Groaning. " × 1363');
274+
});
275+
276+
test("a clean complete has no stopReason", () => {
277+
const store = createSubAgentSessionStore();
278+
const session = store.start({ description: "d", agentId: "a", brief: "b" });
279+
store.complete(session.id, "## Summary\nDone.\n\n## Findings\nx");
280+
expect(store.get(session.id)?.stopReason).toBeUndefined();
281+
});
282+
283+
test("cancel() records the cancel reason as stopReason", () => {
284+
const store = createSubAgentSessionStore();
285+
const withReason = store.start({ description: "d", agentId: "a", brief: "b" });
286+
store.cancel(withReason.id, "Session closed");
287+
expect(store.get(withReason.id)?.stopReason).toBe("cancelled — Session closed");
288+
289+
const bare = store.start({ description: "d2", agentId: "a", brief: "b" });
290+
store.cancel(bare.id);
291+
expect(store.get(bare.id)?.stopReason).toBe("cancelled");
292+
});
293+
});

0 commit comments

Comments
 (0)