Skip to content

Commit 15b6e5b

Browse files
Thread structured stop reasons through sub-agent dispatch (CL-6946 part 2) (#610)
Replaces prose-matching of forced-stop reports (isXxxSubAgentReport family, per-reason parent hint functions, classifyBriefSalvage(string)) with a structured ForcedStopReason value threaded through runSubAgent's return and the task tool result's detail field. The parent chat director and task-tool dispatch path now classify salvage outcomes and select hint text from that typed value instead of parsing report text.
1 parent 27f3381 commit 15b6e5b

18 files changed

Lines changed: 302 additions & 339 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2121
`search_agents` with no supported opt-in.
2222
- Removed the default 30-turn leaf sub-agent ceiling; an unset `maxTurns` now runs unbounded (explicit budgets still apply).
2323
- Deleted two unenforced orchestrator prompt rules: a "4 workers at once" fan-out cap and a same-agent lane-disjointness rule.
24+
- Sub-agent forced-stop outcomes (turn budget, no-progress, deadline,
25+
cancelled, etc.) are now classified from the structured stop reason the run
26+
reports directly, not by re-parsing the parent-facing report's prose.
27+
Removes the `isXxxSubAgentReport` classifier family and per-reason parent
28+
hint functions in favor of a single structured switch.
2429

2530
## [0.2.108] - 2026-08-24
2631

src/agent/director.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,16 @@ function taskTurn(id: string): ReactorInboundEvent {
111111
// "successful leaf tool.done" progress signal.
112112
function taskDoneEvent(
113113
callId: string,
114-
options: { isError?: boolean; content?: string } = {},
114+
options: { isError?: boolean; content?: string; stopReason?: string } = {},
115115
): ReactorInboundEvent {
116116
return {
117117
type: "tool.done",
118-
result: { callId, isError: options.isError ?? false, content: options.content ?? "ok" },
118+
result: {
119+
callId,
120+
isError: options.isError ?? false,
121+
content: options.content ?? "ok",
122+
...(options.stopReason !== undefined ? { detail: { stopReason: options.stopReason } } : {}),
123+
},
119124
} as unknown as ReactorInboundEvent;
120125
}
121126

@@ -866,7 +871,7 @@ describe("ChatDirector tool-only loop protection", () => {
866871
await director.decide(
867872
{
868873
type: "tool.done",
869-
result: { callId: "task-1", content: salvage },
874+
result: { callId: "task-1", content: salvage, detail: { stopReason: "no-ship" } },
870875
} as unknown as ReactorInboundEvent,
871876
mockState,
872877
capabilities,
@@ -1071,7 +1076,10 @@ describe("ChatDirector tool-only loop protection", () => {
10711076
await director.decide(taskTurn(id), mockState, capabilities);
10721077
const result = actionsArray(
10731078
await director.decide(
1074-
taskDoneEvent(id, { content: forcedStopReport("no-progress", "x") }),
1079+
taskDoneEvent(id, {
1080+
content: forcedStopReport("no-progress", "x"),
1081+
stopReason: "no-progress",
1082+
}),
10751083
mockState,
10761084
capabilities,
10771085
),

src/agent/director.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { PRESENT_VIEW_PRIMITIVES_GUIDANCE } from "./tool-schema-normalize.js";
3333
import { isOperatorOriginated } from "./message-provenance.js";
3434
import { classifyBriefSalvage, isHardBlockSalvage } from "../subagent/brief-dispatch.js";
35+
import type { ForcedStopReason } from "../subagent/stop-policy.js";
3536
import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js";
3637

3738
// Fired when turnsSinceUserMessage reaches TURNS_SINCE_USER_MESSAGE_BACKSTOP.
@@ -933,8 +934,11 @@ class ChatDirectorImpl extends DefaultDirector {
933934

934935
if (event.type === "tool.done" && this.pendingTaskCallIds.has(event.result.callId)) {
935936
this.pendingTaskCallIds.delete(event.result.callId);
936-
const body = typeof event.result.content === "string" ? event.result.content : "";
937-
const salvage = classifyBriefSalvage(body);
937+
const detail = event.result.detail as { stopReason?: ForcedStopReason } | undefined;
938+
const salvage = classifyBriefSalvage({
939+
...(detail?.stopReason !== undefined ? { stopReason: detail.stopReason } : {}),
940+
wasCancelled: false,
941+
});
938942
if (salvage !== null && isHardBlockSalvage(salvage) && !this.salvageNudgeFired) {
939943
this.salvageNudgeFired = true;
940944
this.pendingSalvageNudge = PRIMARY_SALVAGE_NUDGE;
@@ -958,9 +962,8 @@ class ChatDirectorImpl extends DefaultDirector {
958962
// the backstop forever — once the cap is exhausted, leaf successes
959963
// stop resetting the interval and the nudge/pause escalation
960964
// eventually forces an operator checkpoint. Credit also requires the
961-
// tool result content to actually be a string: non-string content is
962-
// coerced to "" above only for salvage classification (an empty body
963-
// classifies as success), which must not also buy backstop credit.
965+
// tool result content to actually be a string, independent of the
966+
// structured salvage classification above.
964967
if (
965968
!event.result.isError &&
966969
salvage === null &&

src/perf/permission-subagent-spans.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ describe("subagent spans", () => {
235235
const open = snapshot().filter((s) => s.name === "subagent" && s.endNs === undefined);
236236
expect(open).toHaveLength(1);
237237
expect(open[0]!.tags?.subagent_id).toBe("call-sa-1");
238-
return "## Summary\n\nok\n";
238+
return { report: "## Summary\n\nok\n" };
239239
},
240240
});
241241
if (tool.kind !== "full") throw new Error("expected full tool");
@@ -281,7 +281,7 @@ describe("subagent spans", () => {
281281
cwd: "/repo",
282282
getWorkdirBase: () => "/repo/.corbits",
283283
provider,
284-
run: async () => "## Summary\n\nchild done\n",
284+
run: async () => ({ report: "## Summary\n\nchild done\n" }),
285285
});
286286
if (tool.kind !== "full") throw new Error("expected full tool");
287287

@@ -346,7 +346,7 @@ describe("subagent spans", () => {
346346
useWorktree: true,
347347
run: async () => {
348348
runEntered = true;
349-
return "## Summary\n\nshould not run\n";
349+
return { report: "## Summary\n\nshould not run\n" };
350350
},
351351
});
352352
if (tool.kind !== "full") throw new Error("expected full tool");

src/subagent/agent-fleet.test.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "./agent-fleet.js";
99
import { createSubAgentSessionStore } from "./session-store.js";
1010
import { createPermissionGate } from "../permission/gate.js";
11-
import type { RunSubAgentParams } from "./types.js";
11+
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
1212

1313
const testPermissionGate = createPermissionGate({
1414
approvals: [],
@@ -37,7 +37,7 @@ function deferred<T>(): {
3737
}
3838

3939
function makeDeps(
40-
run: (params: RunSubAgentParams) => Promise<string>,
40+
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>,
4141
opts: { cwd?: string } = {},
4242
): AgentFleetDeps {
4343
return {
@@ -75,7 +75,7 @@ async function callTool(
7575

7676
describe("spawn_agent", () => {
7777
test("returns immediately with a running agent_id without waiting for the worker", async () => {
78-
const gate = deferred<string>();
78+
const gate = deferred<RunSubAgentResult>();
7979
const deps = makeDeps(async () => gate.promise);
8080
const spawn = createSpawnAgentTool(deps);
8181

@@ -94,13 +94,17 @@ describe("spawn_agent", () => {
9494
// Worker is still pending; store confirms it has not finished.
9595
expect(deps.sessions.get(result.agent_id as string)?.status).toBe("running");
9696

97-
gate.resolve("done");
97+
gate.resolve({ report: "done" });
9898
});
9999
});
100100

101101
describe("spawn_agent + wait_agents", () => {
102102
test("wait_agents on one target returns once it completes while siblings keep running", async () => {
103-
const gates = [deferred<string>(), deferred<string>(), deferred<string>()];
103+
const gates = [
104+
deferred<RunSubAgentResult>(),
105+
deferred<RunSubAgentResult>(),
106+
deferred<RunSubAgentResult>(),
107+
];
104108
let callIndex = 0;
105109
const deps = makeDeps(async () => {
106110
const i = callIndex++;
@@ -116,7 +120,7 @@ describe("spawn_agent + wait_agents", () => {
116120
);
117121
const ids = spawned.map((s) => s.agent_id as string);
118122

119-
gates[0]!.resolve("first report");
123+
gates[0]!.resolve({ report: "first report" });
120124

121125
const waited = await callTool(wait, { targets: [ids[0]], timeout_ms: 5000 });
122126
expect(waited.timed_out).toBe(false);
@@ -129,12 +133,12 @@ describe("spawn_agent + wait_agents", () => {
129133
expect(deps.sessions.get(ids[1]!)?.status).toBe("running");
130134
expect(deps.sessions.get(ids[2]!)?.status).toBe("running");
131135

132-
gates[1]!.resolve("second");
133-
gates[2]!.resolve("third");
136+
gates[1]!.resolve({ report: "second" });
137+
gates[2]!.resolve({ report: "third" });
134138
});
135139

136140
test("wait_agents times out on a still-running agent without cancelling it, and can be called again", async () => {
137-
const gate = deferred<string>();
141+
const gate = deferred<RunSubAgentResult>();
138142
const deps = makeDeps(async () => gate.promise);
139143
const spawn = createSpawnAgentTool(deps);
140144
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
@@ -155,7 +159,7 @@ describe("spawn_agent + wait_agents", () => {
155159
expect(deps.sessions.get(id)?.status).toBe("running");
156160

157161
// A second wait still works cleanly (either another timeout, or completion).
158-
gate.resolve("finished");
162+
gate.resolve({ report: "finished" });
159163
const second = await callTool(wait, { targets: [id], timeout_ms: 5000 });
160164
expect(second.timed_out).toBe(false);
161165
const secondResults = second.results as {
@@ -168,7 +172,7 @@ describe("spawn_agent + wait_agents", () => {
168172
});
169173

170174
test("wait_agents with no targets waits on all currently running spawned agents", async () => {
171-
const gates = [deferred<string>(), deferred<string>()];
175+
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
172176
let callIndex = 0;
173177
const deps = makeDeps(async () => gates[callIndex++]!.promise);
174178
const spawn = createSpawnAgentTool(deps);
@@ -177,14 +181,14 @@ describe("spawn_agent + wait_agents", () => {
177181
await callTool(spawn, { description: "a", prompt: "do it", intent: "explore" });
178182
await callTool(spawn, { description: "b", prompt: "do it", intent: "explore" });
179183

180-
gates[0]!.resolve("a done");
184+
gates[0]!.resolve({ report: "a done" });
181185
const result = await callTool(wait, { timeout_ms: 5000 });
182186
expect(result.timed_out).toBe(false);
183187
const results = result.results as { status: string }[];
184188
expect(results).toHaveLength(2);
185189
expect(results.some((r) => r.status === "done")).toBe(true);
186190

187-
gates[1]!.resolve("b done");
191+
gates[1]!.resolve({ report: "b done" });
188192
});
189193

190194
test("reports survive well past the session store's display cap (20) until wait_agents collects them", async () => {
@@ -193,7 +197,7 @@ describe("spawn_agent + wait_agents", () => {
193197
// them is collected, proving fleetRecords — not the store — is what
194198
// wait_agents actually reads from.
195199
const COUNT = 25;
196-
const deps = makeDeps(async () => "irrelevant");
200+
const deps = makeDeps(async () => ({ report: "irrelevant" }));
197201
const spawn = createSpawnAgentTool(deps);
198202
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
199203

@@ -226,7 +230,7 @@ describe("spawn_agent + wait_agents", () => {
226230

227231
describe("spawn_agent write-lane isolation", () => {
228232
test("refuses a second concurrent implement-intent spawn against the same cwd", async () => {
229-
const gate = deferred<string>();
233+
const gate = deferred<RunSubAgentResult>();
230234
const deps = makeDeps(async () => gate.promise, { cwd: "/repo" });
231235
const spawn = createSpawnAgentTool(deps);
232236

@@ -246,11 +250,11 @@ describe("spawn_agent write-lane isolation", () => {
246250
expect(second.content).toContain("Error:");
247251
expect(second.content).toContain(first.agent_id as string);
248252

249-
gate.resolve("done");
253+
gate.resolve({ report: "done" });
250254
});
251255

252256
test("does not refuse a second concurrent explore-intent spawn against the same cwd", async () => {
253-
const deps = makeDeps(async () => "explored", { cwd: "/repo" });
257+
const deps = makeDeps(async () => ({ report: "explored" }), { cwd: "/repo" });
254258
const spawn = createSpawnAgentTool(deps);
255259

256260
const first = await callTool(spawn, {
@@ -269,7 +273,7 @@ describe("spawn_agent write-lane isolation", () => {
269273
});
270274

271275
test("releases the write lane once the implement worker finishes, allowing another", async () => {
272-
const deps = makeDeps(async () => "built", { cwd: "/repo" });
276+
const deps = makeDeps(async () => ({ report: "built" }), { cwd: "/repo" });
273277
const spawn = createSpawnAgentTool(deps);
274278
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
275279

src/subagent/agent-fleet.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ import { resolveEffortForRole } from "../provider/reasoning-effort.js";
6262
import { isCodexProviderName } from "../config/codex-providers.js";
6363
import { buildDispatchBrief, type TaskIntent } from "./report.js";
6464
import type { SubAgentSessionStore } from "./session-store.js";
65-
import type { RunSubAgentParams, SubAgentProvider, SubAgentSandboxDeps } from "./types.js";
65+
import type {
66+
RunSubAgentParams,
67+
RunSubAgentResult,
68+
SubAgentProvider,
69+
SubAgentSandboxDeps,
70+
} from "./types.js";
6671
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
6772
import { classifyAgentName } from "../telemetry/classify.js";
6873

@@ -212,7 +217,7 @@ export type AgentFleetDeps = SubAgentSandboxDeps & {
212217
cwd: string;
213218
getWorkdirBase: () => string;
214219
provider: SubAgentProvider | (() => SubAgentProvider);
215-
run: (params: RunSubAgentParams) => Promise<string>;
220+
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>;
216221
sessions: SubAgentSessionStore;
217222
fleetRecords: FleetRecordsHandle;
218223
settings?: Settings | (() => Settings | undefined);
@@ -462,8 +467,8 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
462467
.then((result) => {
463468
releaseWriteLane();
464469
if (childCtl.signal.aborted) return;
465-
deps.fleetRecords.resolve(session.id, result);
466-
deps.sessions.complete(session.id, result);
470+
deps.fleetRecords.resolve(session.id, result.report);
471+
deps.sessions.complete(session.id, result.report);
467472
})
468473
.catch((err) => {
469474
releaseWriteLane();

src/subagent/brief-dispatch.ts

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,15 @@
1212
*/
1313

1414
import type { TaskIntent } from "./report.js";
15-
import {
16-
isDeadlineSubAgentReport,
17-
isForcedStopSubAgentReport,
18-
isNeverActedSubAgentReport,
19-
isNeverEditedSubAgentReport,
20-
isNoProgressSubAgentReport,
21-
isNoShipSubAgentReport,
22-
isRepetitionSubAgentReport,
23-
isTurnBudgetSubAgentReport,
24-
} from "./stop-policy.js";
15+
import type { ForcedStopReason } from "./stop-policy.js";
2516

2617
/** Salvage classes that must not be re-dispatched with an identical brief. */
2718
export type HardBlockSalvage =
2819
"no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";
2920

30-
export type BriefSalvageKind =
31-
HardBlockSalvage | "turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report";
21+
// Every forced-stop reason a leaf can report maps 1:1 onto a salvage kind
22+
// the parent ledger cares about.
23+
export type BriefSalvageKind = ForcedStopReason;
3224

3325
export interface TaskBriefFingerprintInput {
3426
prompt: string;
@@ -64,38 +56,19 @@ export function isHardBlockSalvage(kind: BriefSalvageKind): kind is HardBlockSal
6456
return HARD_BLOCK_SALVAGES.has(kind);
6557
}
6658

67-
/** True when the worker returned a stall salvage report. */
68-
export function isStalledSubAgentReport(report: string): boolean {
69-
return isForcedStopSubAgentReport(report, "stalled");
70-
}
71-
72-
/** True when the worker returned a cancel salvage report. */
73-
export function isCancelledSubAgentReport(report: string): boolean {
74-
return isForcedStopSubAgentReport(report, "cancelled");
75-
}
76-
77-
/** True when the worker returned an incomplete-report salvage (narration, no envelope). */
78-
export function isIncompleteReportSubAgentReport(report: string): boolean {
79-
return isForcedStopSubAgentReport(report, "incomplete-report");
80-
}
81-
8259
/**
83-
* Classify a sub-agent tool result body as a salvage kind the parent ledger cares
84-
* about. Returns null for normal completes (or unrecognized envelopes).
60+
* Classify a completed dispatch as a salvage kind the parent ledger cares
61+
* about, from the structured stop reason the run reported directly — never
62+
* by matching the report body's prose. `wasCancelled` (observed independently,
63+
* e.g. via the parent's own abort signal) takes precedence since a parent
64+
* cancel can race a run that never got to report its own reason.
8565
*/
86-
export function classifyBriefSalvage(report: string): BriefSalvageKind | null {
87-
// Order: more specific salvage phrases first.
88-
if (isNoShipSubAgentReport(report)) return "no-ship";
89-
if (isRepetitionSubAgentReport(report)) return "repetition";
90-
if (isNeverEditedSubAgentReport(report)) return "never-edited";
91-
if (isNeverActedSubAgentReport(report)) return "never-acted";
92-
if (isNoProgressSubAgentReport(report)) return "no-progress";
93-
if (isTurnBudgetSubAgentReport(report)) return "turn-budget";
94-
if (isDeadlineSubAgentReport(report)) return "deadline";
95-
if (isStalledSubAgentReport(report)) return "stalled";
96-
if (isCancelledSubAgentReport(report)) return "cancelled";
97-
if (isIncompleteReportSubAgentReport(report)) return "incomplete-report";
98-
return null;
66+
export function classifyBriefSalvage(input: {
67+
stopReason?: ForcedStopReason;
68+
wasCancelled: boolean;
69+
}): BriefSalvageKind | null {
70+
if (input.wasCancelled) return "cancelled";
71+
return input.stopReason ?? null;
9972
}
10073

10174
/**

0 commit comments

Comments
 (0)