Skip to content

Commit bd8f5d1

Browse files
committed
Preserve sub-agent nudges through overflow recovery
1 parent d0798e9 commit bd8f5d1

2 files changed

Lines changed: 138 additions & 6 deletions

File tree

src/subagent/nudge-director.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ function inferAction(result: ReactorAction | ReactorAction[]): Extract<ReactorAc
8282
return infer;
8383
}
8484

85+
function overflowError(message = "context window exceeded"): ReactorInboundEvent {
86+
return {
87+
type: "inference.error",
88+
error: { category: "context_overflow", message },
89+
partial: { text: "" },
90+
} as unknown as ReactorInboundEvent;
91+
}
92+
8593
function ephemeralTexts(infer: Extract<ReactorAction, { type: "infer" }>): string[] | undefined {
8694
const options = infer.options as
8795
| { ephemeralTurns?: Array<{ content: Array<{ text?: string }> }> }
@@ -231,4 +239,109 @@ describe("SubAgentDirector tool failure recovery", () => {
231239
expect(texts?.[0]).toContain("A tool call failed");
232240
expect(texts?.[0]).not.toContain("re-reading the same paths");
233241
});
242+
243+
test("overflow after consume restores recovery once on continuation infer", async () => {
244+
let continuations = 0;
245+
const director = new SubAgentDirector(
246+
"system",
247+
[],
248+
() => {
249+
continuations++;
250+
},
251+
30,
252+
);
253+
const caps = capabilities();
254+
255+
await director.decide(inferenceDone(["failed-then-overflow"]), state, caps);
256+
const texts = ephemeralTexts(
257+
inferAction(await director.decide(toolDone("failed-then-overflow", true), state, caps)),
258+
);
259+
expect(texts).toHaveLength(1);
260+
expect(texts?.[0]).toContain("A tool call failed");
261+
262+
const compact = actions(await director.decide(overflowError(), state, caps));
263+
expect(compact.some((action) => action.type === "infer")).toBe(false);
264+
expect(compact).toEqual([
265+
{ type: "compact", compactor: "pruning-compactor", reason: "context-overflow" },
266+
]);
267+
expect(continuations).toBe(1);
268+
269+
const resumed = inferAction(await director.decide(messageReceived(""), state, caps));
270+
const resumedTexts = ephemeralTexts(resumed);
271+
expect(resumedTexts).toHaveLength(1);
272+
expect(resumedTexts?.[0]).toContain("A tool call failed");
273+
274+
const later = inferAction(await director.decide(messageReceived(""), state, caps));
275+
expect(ephemeralTexts(later)).toBeUndefined();
276+
});
277+
278+
test("successful nudged infer then later overflow does not resurrect recovery", async () => {
279+
let continuations = 0;
280+
const director = new SubAgentDirector(
281+
"system",
282+
[],
283+
() => {
284+
continuations++;
285+
},
286+
30,
287+
);
288+
const caps = capabilities();
289+
290+
await director.decide(inferenceDone(["failed-then-done"]), state, caps);
291+
const recovered = ephemeralTexts(
292+
inferAction(await director.decide(toolDone("failed-then-done", true), state, caps)),
293+
);
294+
expect(recovered).toHaveLength(1);
295+
expect(recovered?.[0]).toContain("A tool call failed");
296+
297+
await director.decide(inferenceDone(["later-success"]), state, caps);
298+
const afterSuccess = inferAction(
299+
await director.decide(toolDone("later-success"), state, caps),
300+
);
301+
expect(ephemeralTexts(afterSuccess)).toBeUndefined();
302+
303+
const compact = actions(await director.decide(overflowError(), state, caps));
304+
expect(compact.some((action) => action.type === "infer")).toBe(false);
305+
expect(compact).toEqual([
306+
{ type: "compact", compactor: "pruning-compactor", reason: "context-overflow" },
307+
]);
308+
expect(continuations).toBe(1);
309+
310+
const resumed = inferAction(await director.decide(messageReceived(""), state, caps));
311+
expect(ephemeralTexts(resumed)).toBeUndefined();
312+
});
313+
314+
test("retains wrap-up through compaction and consumes it once on continuation infer", async () => {
315+
let continuations = 0;
316+
const director = new SubAgentDirector(
317+
"system",
318+
[],
319+
() => {
320+
continuations++;
321+
},
322+
3,
323+
);
324+
const caps = capabilities();
325+
326+
await director.decide(inferenceDone(["near-budget-success"], 999_999), longState, caps);
327+
const compact = actions(
328+
await director.decide(toolDone("near-budget-success"), longState, caps),
329+
);
330+
expect(compact.some((action) => action.type === "infer")).toBe(false);
331+
expect(compact).toEqual([
332+
{ type: "checkpoint", message: "tool-done" },
333+
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
334+
]);
335+
expect(continuations).toBe(1);
336+
337+
const resumed = inferAction(await director.decide(messageReceived(""), longState, caps));
338+
const resumedTexts = ephemeralTexts(resumed);
339+
expect(resumedTexts).toHaveLength(1);
340+
expect(resumedTexts?.[0]).toContain("close to your turn budget");
341+
expect(resumedTexts?.[0]).toContain("write your final report now");
342+
expect(resumedTexts?.[0]).not.toContain("A tool call failed");
343+
344+
const later = inferAction(await director.decide(messageReceived(""), longState, caps));
345+
expect(ephemeralTexts(later)).toBeUndefined();
346+
});
234347
});

src/subagent/nudge-director.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,21 @@ export class SubAgentDirector extends DefaultDirector {
9595
consecutiveIdentical: 0,
9696
};
9797
private thrashState: ThrashState = EMPTY_THRASH_STATE;
98-
// Set on a report-forced or re-read-nudge turn so the follow-up infer (after
99-
// the pending tool calls from THIS turn have executed) carries the nudge.
100-
// Cannot attach the nudge to this turn's own infer: the model just emitted
101-
// tool_use blocks, and every provider requires tool_result before the next
102-
// turn — a bare nudge here would send an invalid conversation.
98+
// Armed for wrap-up (report-forced), failed-tool recovery, or re-read so the
99+
// follow-up infer (after pending tool calls from THIS turn have executed)
100+
// carries the nudge. Cannot attach the nudge to this turn's own infer: the
101+
// model just emitted tool_use blocks, and every provider requires tool_result
102+
// before the next turn — a bare nudge here would send an invalid conversation.
103+
// Survives both proactive compact (interceptActions leaves pending armed) and
104+
// overflow compact (interceptOverflow re-arms from lastConsumedNudgeText if
105+
// the infer that consumed pending never completed).
103106
private pendingNudgeText: string | null = null;
107+
// The text applyPendingNudge last attached to a returned infer. Overflow of
108+
// that infer means the model never saw it, so interceptOverflow re-arms
109+
// pending from this when pending is still null. Cleared on a successful
110+
// turn boundary so a later overflow cannot resurrect a nudge the model
111+
// already completed.
112+
private lastConsumedNudgeText: string | null = null;
104113
// Soft re-read-nudge is one-shot per run; thrash hard-stop still fires later
105114
// if the leaf ignores it and keeps re-reading.
106115
private reReadNudgeFired = false;
@@ -153,7 +162,15 @@ export class SubAgentDirector extends DefaultDirector {
153162
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);
154163
if (idleCompact !== null) return idleCompact;
155164
const recovery = this.compaction.interceptOverflow(event, capabilities);
156-
if (recovery !== null) return recovery;
165+
if (recovery !== null) {
166+
// The infer that consumed pending never completed, so the model did not
167+
// see the nudge. Re-arm it for resumeAfterCompact unless a newer wrap-up
168+
// (or other pending) is already waiting.
169+
if (this.pendingNudgeText === null && this.lastConsumedNudgeText !== null) {
170+
this.pendingNudgeText = this.lastConsumedNudgeText;
171+
}
172+
return recovery;
173+
}
157174

158175
const stallOutcome = this.checkStallPing(event, capabilities);
159176
if (stallOutcome !== null) return stallOutcome;
@@ -163,6 +180,7 @@ export class SubAgentDirector extends DefaultDirector {
163180
// prefers provider usage when present.
164181
this.compaction.syncFromTurns(state.turns);
165182
if (onTurnBoundary(event)) {
183+
this.lastConsumedNudgeText = null;
166184
this.lastActivityAt = this.now();
167185
this.consecutiveStalls = 0;
168186
this.compaction.noteInferenceDone(event, state.turns);
@@ -318,6 +336,7 @@ export class SubAgentDirector extends DefaultDirector {
318336
if (inferIndex === -1) return actions;
319337
const text = this.pendingNudgeText;
320338
this.pendingNudgeText = null;
339+
this.lastConsumedNudgeText = text;
321340
const existing = actions[inferIndex] as Extract<ReactorAction, { type: "infer" }>;
322341
const rewritten = [...actions];
323342
rewritten[inferIndex] = capabilities.infer(withEphemeralNudge(existing.options, text));

0 commit comments

Comments
 (0)