Skip to content

Commit d0798e9

Browse files
committed
Preserve sub-agent nudges through compaction
1 parent ffd58a9 commit d0798e9

2 files changed

Lines changed: 115 additions & 11 deletions

File tree

src/subagent/nudge-director.test.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@ import type {
55
ReactorInboundEvent,
66
ReactorState,
77
} from "@intx/types/runtime";
8+
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
89
import { SubAgentDirector } from "./nudge-director.js";
910

1011
const state = { turns: [] } as unknown as ReactorState;
12+
const longState = {
13+
turns: Array.from(
14+
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
15+
() => ({ role: "user", content: [], timestamp: 0 }),
16+
),
17+
} as unknown as ReactorState;
1118

1219
function capabilities(): ReactorCapabilities {
1320
return {
@@ -26,7 +33,11 @@ function capabilities(): ReactorCapabilities {
2633
};
2734
}
2835

29-
function inferenceDone(callIds: string[]): ReactorInboundEvent {
36+
function inferenceDone(
37+
callIds: string[],
38+
inputTokens = 0,
39+
pathForId: (id: string) => string = (id) => `${id}.ts`,
40+
): ReactorInboundEvent {
3041
return {
3142
type: "inference.done",
3243
turn: {
@@ -37,11 +48,11 @@ function inferenceDone(callIds: string[]): ReactorInboundEvent {
3748
type: "tool_call",
3849
id,
3950
name: "read_file",
40-
arguments: { path: `${id}.ts` },
51+
arguments: { path: pathForId(id) },
4152
})),
4253
},
43-
usage: { input: 0, output: 0 },
44-
source: "test",
54+
usage: { input: inputTokens, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
55+
source: { model: "test-model" },
4556
} as unknown as ReactorInboundEvent;
4657
}
4758

@@ -52,6 +63,13 @@ function toolDone(callId: string, isError = false): ReactorInboundEvent {
5263
} as unknown as ReactorInboundEvent;
5364
}
5465

66+
function messageReceived(content: string): ReactorInboundEvent {
67+
return {
68+
type: "message.received",
69+
message: { role: "user", content },
70+
} as unknown as ReactorInboundEvent;
71+
}
72+
5573
function actions(result: ReactorAction | ReactorAction[]): ReactorAction[] {
5674
return Array.isArray(result) ? result : [result];
5775
}
@@ -130,4 +148,87 @@ describe("SubAgentDirector tool failure recovery", () => {
130148
);
131149
expect(ephemeralTexts(infer)).toBeUndefined();
132150
});
151+
152+
test("retains recovery through compaction and consumes it once on continuation infer", async () => {
153+
let continuations = 0;
154+
const director = new SubAgentDirector(
155+
"system",
156+
[],
157+
() => {
158+
continuations++;
159+
},
160+
30,
161+
);
162+
const caps = capabilities();
163+
164+
await director.decide(inferenceDone(["failed-at-threshold"], 999_999), longState, caps);
165+
const compact = actions(
166+
await director.decide(toolDone("failed-at-threshold", true), longState, caps),
167+
);
168+
expect(compact.some((action) => action.type === "infer")).toBe(false);
169+
expect(compact).toEqual([
170+
{ type: "checkpoint", message: "tool-done" },
171+
{ type: "compact", compactor: "pruning-compactor", reason: "context-threshold" },
172+
]);
173+
expect(continuations).toBe(1);
174+
175+
const resumed = inferAction(
176+
await director.decide(messageReceived(""), longState, caps),
177+
);
178+
const resumedTexts = ephemeralTexts(resumed);
179+
expect(resumedTexts).toHaveLength(1);
180+
expect(resumedTexts?.[0]).toContain("A tool call failed");
181+
182+
const later = inferAction(
183+
await director.decide(messageReceived(""), longState, caps),
184+
);
185+
expect(ephemeralTexts(later)).toBeUndefined();
186+
});
187+
188+
test("near-budget wrap-up nudge wins over failed-tool recovery", async () => {
189+
const director = new SubAgentDirector("system", [], undefined, 3);
190+
const caps = capabilities();
191+
192+
await director.decide(inferenceDone(["near-budget-failure"]), state, caps);
193+
const texts = ephemeralTexts(
194+
inferAction(await director.decide(toolDone("near-budget-failure", true), state, caps)),
195+
);
196+
197+
expect(texts).toHaveLength(1);
198+
expect(texts?.[0]).toContain("close to your turn budget");
199+
expect(texts?.[0]).toContain("write your final report now");
200+
expect(texts?.[0]).not.toContain("A tool call failed");
201+
expect(texts?.[0]).not.toContain("change the arguments or approach");
202+
});
203+
204+
test("failed-tool recovery supersedes soft re-read guidance", async () => {
205+
const director = new SubAgentDirector("system", [], undefined, 30);
206+
const caps = capabilities();
207+
const callIds = [
208+
"shared-1",
209+
"shared-2",
210+
"shared-3",
211+
"unique-1",
212+
"unique-2",
213+
"unique-3",
214+
"unique-4",
215+
"failed-last",
216+
];
217+
218+
await director.decide(
219+
inferenceDone(callIds, 0, (id) => id.startsWith("shared-") ? "shared.ts" : `${id}.ts`),
220+
state,
221+
caps,
222+
);
223+
for (const callId of callIds.slice(0, -1)) {
224+
await director.decide(toolDone(callId), state, caps);
225+
}
226+
const texts = ephemeralTexts(
227+
inferAction(await director.decide(toolDone("failed-last", true), state, caps)),
228+
);
229+
230+
expect(texts).toHaveLength(1);
231+
expect(texts?.[0]).toContain("A tool call failed");
232+
expect(texts?.[0]).not.toContain("re-reading the same paths");
233+
});
133234
});

src/subagent/nudge-director.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export class SubAgentDirector extends DefaultDirector {
148148
capabilities: ReactorCapabilities,
149149
): Promise<ReactorAction | ReactorAction[]> {
150150
if (this.compaction.resumeAfterCompact(event)) {
151-
return capabilities.infer();
151+
return this.applyPendingNudge([capabilities.infer()], capabilities);
152152
}
153153
const idleCompact = this.compaction.interceptIdleContinuation(event, capabilities);
154154
if (idleCompact !== null) return idleCompact;
@@ -248,16 +248,19 @@ export class SubAgentDirector extends DefaultDirector {
248248
if (event.type === "tool.done") {
249249
this.lastActivityAt = this.now();
250250
this.consecutiveStalls = 0;
251-
if (event.result.isError === true) {
251+
if (
252+
event.result.isError === true &&
253+
this.pendingNudgeText !== REPORT_FORCED_WRAP_UP_NUDGE
254+
) {
255+
// Recovery is more specific than re-read guidance, but mandatory wrap-up wins.
252256
this.pendingNudgeText = TOOL_FAILURE_RECOVERY_NUDGE;
253257
}
254258
}
255259
const base = await super.decide(event, state, capabilities);
256-
const actions = this.applyPendingNudge(
257-
Array.isArray(base) ? base : [base],
258-
capabilities,
259-
);
260-
return this.compaction.interceptActions(event, actions, capabilities) ?? actions;
260+
const baseActions = Array.isArray(base) ? base : [base];
261+
const compacted = this.compaction.interceptActions(event, baseActions, capabilities);
262+
if (compacted !== null) return compacted;
263+
return this.applyPendingNudge(baseActions, capabilities);
261264
}
262265

263266
/**

0 commit comments

Comments
 (0)