Skip to content

Commit d2ccdcd

Browse files
Rotate the submit_result turn token on every steering followup (#967)
1 parent e42d1d2 commit d2ccdcd

7 files changed

Lines changed: 262 additions & 10 deletions

File tree

src/subagent/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
evaluateSubAgentStop,
99
forcedStopReport,
1010
formatSubAgentReport,
11+
formatTurnTokenNotice,
1112
parseSubAgentReport,
1213
appendSubAgentParentHints,
1314
EMPTY_THRASH_STATE,
@@ -1297,6 +1298,27 @@ describe("SubAgentDirector stall management", () => {
12971298
});
12981299
});
12991300

1301+
describe("submit_result turn token notice", () => {
1302+
test("the dispatch brief embeds the shared token notice verbatim", () => {
1303+
const token = "01a09856-4dd3-7209-a3df-d7e543dc4ffe";
1304+
const brief = buildDispatchBrief({
1305+
description: "token probe",
1306+
prompt: "do the thing",
1307+
turnToken: token,
1308+
});
1309+
// Byte-identity: the brief and followup steers render the same contract
1310+
// through one shared function, so a worker can never see two wordings.
1311+
expect(brief).toContain(formatTurnTokenNotice(token));
1312+
expect(formatTurnTokenNotice(token)).toContain(
1313+
"A mismatched token means this turn was superseded",
1314+
);
1315+
// Non-leaf dispatches state no token.
1316+
expect(
1317+
buildDispatchBrief({ description: "plain", prompt: "do the thing" }),
1318+
).not.toContain("## Turn token");
1319+
});
1320+
});
1321+
13001322
describe("buildDispatchBrief typed spawn contract", () => {
13011323
test("renders Intent, Success criteria, Do not, and report_focus only when set", () => {
13021324
const full = buildDispatchBrief({

src/subagent/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export {
4545
buildDispatchBrief,
4646
demoteNestedReportHeadings,
4747
formatSubAgentReport,
48+
formatTurnTokenNotice,
4849
hasPlanFindings,
4950
hasReportEnvelope,
5051
parseSubAgentReport,

src/subagent/report.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ export interface DispatchBrief {
6161
turnToken?: string;
6262
}
6363

64+
/**
65+
* States the active submit_result token. Shared by the initial dispatch brief
66+
* and followup steers so both turns state the same contract.
67+
*/
68+
export function formatTurnTokenNotice(turnToken: string): string {
69+
return [
70+
"## Turn token",
71+
turnToken,
72+
`If you call submit_result, pass turn_token="${turnToken}" exactly. A mismatched token means this turn was superseded — do not resubmit under it.`,
73+
].join("\n");
74+
}
75+
6476
export function buildDispatchBrief(brief: DispatchBrief): string {
6577
const parts: string[] = [
6678
`# Dispatch brief: ${brief.description}`,
@@ -103,12 +115,7 @@ export function buildDispatchBrief(brief: DispatchBrief): string {
103115
}
104116
parts.push("", "## Report shape", ...reportLines);
105117
if (brief.turnToken !== undefined && brief.turnToken.length > 0) {
106-
parts.push(
107-
"",
108-
"## Turn token",
109-
brief.turnToken,
110-
`If you call submit_result, pass turn_token="${brief.turnToken}" exactly. A mismatched token means this turn was superseded — do not resubmit under it.`,
111-
);
118+
parts.push("", formatTurnTokenNotice(brief.turnToken));
112119
}
113120
return parts.join("\n");
114121
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Steering rotation: runSubAgent mints the submit_result turn token at leaf
3+
* dispatch and rotates it on every followup steer, so a worker holding the
4+
* dispatched token cannot submit after its turn was superseded. The pure
5+
* evaluator half (old token rejected, budget reset) is covered in
6+
* submit-result.test.ts; this test drives the real runSubAgent wiring end to
7+
* end — the one seam the pure tests cannot see is whether followup actually
8+
* mints, swaps, and re-states the token. Same stub-agent pattern as
9+
* followup-live-agent.test.ts.
10+
*/
11+
import { describe, expect, test } from "bun:test";
12+
import { mkdtemp } from "node:fs/promises";
13+
import { tmpdir } from "node:os";
14+
import { join } from "node:path";
15+
16+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
17+
import { defined } from "../../tests/helpers/defined.js";
18+
import { createPermissionGate } from "../permission/gate.js";
19+
import type { RunSubAgentParams } from "./types.js";
20+
21+
const testPermissionGate = createPermissionGate({
22+
approvals: [],
23+
interactive: false,
24+
skipPermissions: true,
25+
reactorGated: false,
26+
});
27+
28+
/** First send hangs (the run stays alive for steering); later sends resolve. */
29+
function createRotatingStubAgent(sendLog: string[]) {
30+
return {
31+
async send(content: string, optsSend?: { signal?: AbortSignal }) {
32+
sendLog.push(content);
33+
if (sendLog.length === 1) {
34+
return await new Promise((_, reject) => {
35+
if (optsSend?.signal?.aborted === true) {
36+
reject(
37+
optsSend.signal.reason instanceof Error
38+
? optsSend.signal.reason
39+
: new Error("aborted"),
40+
);
41+
return;
42+
}
43+
optsSend?.signal?.addEventListener(
44+
"abort",
45+
() => {
46+
const reason = defined(optsSend.signal).reason;
47+
reject(reason instanceof Error ? reason : new Error("aborted"));
48+
},
49+
{ once: true },
50+
);
51+
});
52+
}
53+
return {
54+
type: "reply" as const,
55+
reply: `reply #${sendLog.length}`,
56+
turn: { role: "assistant", content: [] },
57+
};
58+
},
59+
stream: () =>
60+
(async function* () {
61+
yield* [];
62+
})(),
63+
deliver: () => undefined,
64+
close: async () => undefined,
65+
setSource: () => undefined,
66+
setSources: () => undefined,
67+
history: async () => [],
68+
checkpoints: async () => [],
69+
readAt: async () => [],
70+
blobReader: {},
71+
};
72+
}
73+
74+
function tokenOfSend(send: string): string | undefined {
75+
return send.match(/^## Turn token\n(.+)$/m)?.[1];
76+
}
77+
78+
describe("submit_result token rotation on steering", () => {
79+
test("followup rotates the leaf turn token and states the replacement in the steer", async () => {
80+
const cwd = await mkdtemp(join(tmpdir(), "cl6946-token-rotation-"));
81+
const sendLog: string[] = [];
82+
83+
const outcome = await withMockedModuleDuring(
84+
import.meta.resolve("../agent/live-tool-dispatch.js"),
85+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
86+
...real,
87+
createAgentWithLiveToolDispatch: async () =>
88+
createRotatingStubAgent(sendLog) as unknown as Awaited<
89+
ReturnType<typeof real.createAgentWithLiveToolDispatch>
90+
>,
91+
}),
92+
async () => {
93+
const { runSubAgent } = await import("./run.js");
94+
95+
let handles:
96+
| {
97+
close: (ms?: number) => Promise<void>;
98+
interrupt: () => void;
99+
followup: (message: string) => Promise<string>;
100+
}
101+
| undefined;
102+
103+
const params: RunSubAgentParams = {
104+
cwd,
105+
workdirBase: join(cwd, ".ctx"),
106+
permissionGate: testPermissionGate,
107+
provider: {
108+
providerName: "test",
109+
baseURL: "http://localhost",
110+
model: "test-model",
111+
},
112+
description: "token rotation probe",
113+
prompt: "hold for steering",
114+
persist: true,
115+
tier: "leaf",
116+
onAgentReady: (h) => {
117+
handles = h;
118+
},
119+
};
120+
121+
const runPromise = runSubAgent(params);
122+
for (let i = 0; i < 500 && sendLog.length < 1; i++) {
123+
await new Promise((resolve) => setTimeout(resolve, 1));
124+
}
125+
if (handles === undefined) throw new Error("onAgentReady never fired");
126+
127+
const reply = await handles.followup("new orders: pivot to X");
128+
// followup replaced the per-turn interrupt controller, so interrupt()
129+
// can no longer reach the still-hung first send — close() aborts the
130+
// run controller instead and settles the run for cleanup. Both can
131+
// reject with the abort reason; settlement is all we need.
132+
await handles.close().catch(() => undefined);
133+
await runPromise.catch(() => undefined);
134+
return { reply };
135+
},
136+
);
137+
138+
expect(outcome.reply).toBe("reply #2");
139+
expect(sendLog.length).toBe(2);
140+
141+
// The dispatched brief states the first token; the steer carries the
142+
// followup message plus a DIFFERENT token — the old one dies here.
143+
const dispatched = tokenOfSend(defined(sendLog[0]));
144+
const steered = tokenOfSend(defined(sendLog[1]));
145+
expect(dispatched).toBeDefined();
146+
expect(steered).toBeDefined();
147+
expect(defined(steered)).not.toBe(defined(dispatched));
148+
expect(defined(sendLog[1]).startsWith("new orders: pivot to X")).toBe(true);
149+
expect(defined(sendLog[1])).toContain(`turn_token="${defined(steered)}"`);
150+
});
151+
});

src/subagent/run.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
appendActivitySummary,
117117
buildDispatchBrief,
118118
formatSubAgentReport,
119+
formatTurnTokenNotice,
119120
parseSubAgentReport,
120121
subAgentToolName,
121122
} from "./report.js";
@@ -140,6 +141,7 @@ import { createReadAgentTraceTool } from "./trace-tool.js";
140141
import {
141142
createSubmitResultState,
142143
evaluateSubmitResult,
144+
resetSubmitResultTurn,
143145
SUBMIT_RESULT_MAX_CORRECTIONS,
144146
} from "./submit-result.js";
145147
import {
@@ -578,8 +580,9 @@ async function runSubAgentInner(
578580
const permissionGate = workerPermissionGate(params.permissionGate);
579581
// Identifies this dispatch to submit_result so a submission survives
580582
// only for the turn it was spawned under — a stale call from a redirected
581-
// orchestrator (echoing an old token) is rejected.
582-
const turnToken = params.tier === "leaf" ? generateSessionId() : undefined;
583+
// orchestrator (echoing an old token) is rejected. Steering (followup)
584+
// rotates it: the old token dies with the superseded turn.
585+
let turnToken = params.tier === "leaf" ? generateSessionId() : undefined;
583586
const submitResultState = createSubmitResultState();
584587
const askDirectorState = createAskDirectorState();
585588
const spawnRegistry = createSubAgentSpawnRegistryPlugin();
@@ -747,8 +750,15 @@ async function runSubAgentInner(
747750
handler: async (
748751
rawArgs: Record<string, unknown>,
749752
): Promise<string> => {
753+
// Read the live binding (not the dispatch-time value): steering
754+
// rotates turnToken, so a submission under the old token lands here
755+
// and must be rejected as stale.
756+
const currentToken = turnToken;
757+
if (currentToken === undefined) {
758+
return "Error: this run has no turn token, so submit_result cannot verify freshness.";
759+
}
750760
const outcome = evaluateSubmitResult({
751-
turnToken,
761+
turnToken: currentToken,
752762
submittedToken: rawArgs.turn_token,
753763
result: rawArgs.result,
754764
...(params.reportType !== undefined
@@ -1353,7 +1363,16 @@ async function runSubAgentInner(
13531363
const followup = async (message: string): Promise<string> => {
13541364
resetAskDirectorTurn(askDirectorState);
13551365
interruptController = new AbortController();
1356-
const result = await sendWithProviderFailure(message, {
1366+
// A steer supersedes the dispatched turn: mint a fresh submit_result
1367+
// token (the handler closure reads this binding, so the old token is
1368+
// rejected from here on) and hand the worker the replacement.
1369+
let steered = message;
1370+
if (turnToken !== undefined) {
1371+
turnToken = generateSessionId();
1372+
resetSubmitResultTurn(submitResultState);
1373+
steered = `${message}\n\n${formatTurnTokenNotice(turnToken)}`;
1374+
}
1375+
const result = await sendWithProviderFailure(steered, {
13571376
signal: sendAbortSignal(),
13581377
});
13591378
if (terminalProviderError !== undefined) {

src/subagent/submit-result.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type } from "arktype";
55
import {
66
createSubmitResultState,
77
evaluateSubmitResult,
8+
resetSubmitResultTurn,
89
} from "./submit-result.js";
910

1011
const TOKEN = "turn-abc123";
@@ -86,3 +87,46 @@ describe("evaluateSubmitResult", () => {
8687
expect(capped.message).toContain("correction cap");
8788
});
8889
});
90+
91+
describe("submit_result turn rotation", () => {
92+
test("a steered turn rejects the old token and resets the correction budget", () => {
93+
const state = createSubmitResultState();
94+
const outputType = type({ verdict: "'pass'|'fail'" });
95+
96+
// The dispatched turn burns one correction on a malformed result.
97+
const bad = evaluateSubmitResult({
98+
turnToken: TOKEN,
99+
submittedToken: TOKEN,
100+
result: { verdict: "maybe" },
101+
outputType,
102+
state,
103+
});
104+
expect(bad.ok).toBe(false);
105+
expect(state.corrections).toBe(1);
106+
107+
// Steering rotates the token and resets the budget (run.ts followup).
108+
const rotated = "turn-def456";
109+
resetSubmitResultTurn(state);
110+
111+
const stale = evaluateSubmitResult({
112+
turnToken: rotated,
113+
submittedToken: TOKEN,
114+
result: { verdict: "pass" },
115+
outputType,
116+
state,
117+
});
118+
expect(stale.ok).toBe(false);
119+
expect(stale.message).toContain("turn_token does not match");
120+
expect(state.corrections).toBe(0);
121+
122+
const fresh = evaluateSubmitResult({
123+
turnToken: rotated,
124+
submittedToken: rotated,
125+
result: { verdict: "pass" },
126+
outputType,
127+
state,
128+
});
129+
expect(fresh.ok).toBe(true);
130+
expect(fresh.message).toBe("Result accepted.");
131+
});
132+
});

src/subagent/submit-result.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export function createSubmitResultState(): SubmitResultState {
1919
return { corrections: 0 };
2020
}
2121

22+
/**
23+
* Steering starts a new turn: drop the finished turn's correction count so the
24+
* replacement token gets a full budget. The caller mints the new token.
25+
*/
26+
export function resetSubmitResultTurn(state: SubmitResultState): void {
27+
state.corrections = 0;
28+
}
29+
2230
export interface SubmitResultInput {
2331
/** This turn's token, generated by runSubAgent at dispatch time. */
2432
turnToken: string;

0 commit comments

Comments
 (0)