Skip to content

Commit 7eaf4a5

Browse files
committed
Flush coalesced tool-failure audits on terminal paths
1 parent 1a2925f commit 7eaf4a5

2 files changed

Lines changed: 70 additions & 9 deletions

File tree

src/subagent/nudge-director.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,53 @@ describe("SubAgentDirector tool failure recovery", () => {
209209
expect(records).toEqual([{ id: "tool-failure-recovery", count: 2 }]);
210210
});
211211

212+
test("a single failed tool audit omits the count field", async () => {
213+
const director = new SubAgentDirector("system", [], undefined, 30);
214+
const caps = capabilities();
215+
const records: { id: string; count: number | null }[] = [];
216+
director.observeInterventions((event) => {
217+
records.push({ id: event.id, count: event.count ?? null });
218+
});
219+
220+
await director.decide(inferenceDone(["fail-a"]), state, caps);
221+
await director.decide(toolDone("fail-a", true), state, caps);
222+
await director.decide(inferenceDoneText(REPORT_ENVELOPE), state, caps);
223+
224+
expect(records).toEqual([{ id: "tool-failure-recovery", count: null }]);
225+
});
226+
227+
test("flushes an undelivered recovery burst when the run goes terminal", async () => {
228+
const director = new SubAgentDirector("system", [], undefined, 30);
229+
const caps = capabilities();
230+
const records: { id: string; count?: number }[] = [];
231+
director.observeInterventions((event) => {
232+
records.push(
233+
event.count === undefined
234+
? { id: event.id }
235+
: { id: event.id, count: event.count },
236+
);
237+
});
238+
239+
// ok-c stays pending so the armed recovery nudge never reaches an infer.
240+
await director.decide(
241+
inferenceDone(["fail-a", "fail-b", "ok-c"]),
242+
state,
243+
caps,
244+
);
245+
await director.decide(toolDone("fail-a", true), state, caps);
246+
await director.decide(toolDone("fail-b", true), state, caps);
247+
expect(records).toEqual([]);
248+
249+
const result = actions(
250+
await director.decide(inferenceDoneText(REPORT_ENVELOPE), state, caps),
251+
);
252+
expect(result).toContainEqual({
253+
type: "checkpoint",
254+
message: "subagent-complete",
255+
});
256+
expect(records).toEqual([{ id: "tool-failure-recovery", count: 2 }]);
257+
});
258+
212259
test("successful tool result has no ephemeral recovery turn", async () => {
213260
const director = new SubAgentDirector("system", [], undefined, 30);
214261
const caps = capabilities();

src/subagent/nudge-director.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ export class SubAgentDirector extends DefaultDirector {
325325

326326
if (stop === "complete") {
327327
this.reportReplied = true;
328+
this.flushToolFailureRecoveryAudit();
328329
const terminal: ReactorAction[] = [
329330
capabilities.checkpoint("subagent-complete"),
330331
capabilities.reply(lastText(content)),
@@ -384,6 +385,7 @@ export class SubAgentDirector extends DefaultDirector {
384385
});
385386
this.onForcedStop("incomplete-report");
386387
this.reportReplied = true;
388+
this.flushToolFailureRecoveryAudit();
387389
const terminal: ReactorAction[] = [
388390
capabilities.checkpoint("subagent-incomplete-report"),
389391
capabilities.reply(
@@ -480,6 +482,7 @@ export class SubAgentDirector extends DefaultDirector {
480482
});
481483
this.onForcedStop("stalled");
482484
this.reportReplied = true;
485+
this.flushToolFailureRecoveryAudit();
483486
const terminal: ReactorAction[] = [
484487
capabilities.checkpoint("subagent-stalled"),
485488
capabilities.reply(
@@ -492,6 +495,25 @@ export class SubAgentDirector extends DefaultDirector {
492495
return terminal;
493496
}
494497

498+
/**
499+
* Write the coalesced tool-failure-recovery audit once the burst ends —
500+
* when the armed nudge lands on an infer, or when the run goes terminal
501+
* (complete / forced stop / stalled) with the nudge still undelivered.
502+
* Without the terminal-path flush a burst that is never followed by an
503+
* infer would vanish from the audit trail entirely.
504+
*/
505+
private flushToolFailureRecoveryAudit(): void {
506+
if (this.pendingToolFailureRecoveryCount === 0) return;
507+
const count = this.pendingToolFailureRecoveryCount;
508+
this.pendingToolFailureRecoveryCount = 0;
509+
this.interventions({
510+
id: "tool-failure-recovery",
511+
class: "nudge",
512+
...(count > 1 ? { count } : {}),
513+
state: this.interventionState(),
514+
});
515+
}
516+
495517
/**
496518
* Rewrite the infer action in a fall-through actions batch to carry the
497519
* armed nudge, once — this matches the infer after report-forced or
@@ -507,15 +529,7 @@ export class SubAgentDirector extends DefaultDirector {
507529
const text = this.pendingNudgeText;
508530
this.pendingNudgeText = null;
509531
this.lastConsumedNudgeText = text;
510-
if (this.pendingToolFailureRecoveryCount > 0) {
511-
this.interventions({
512-
id: "tool-failure-recovery",
513-
class: "nudge",
514-
count: this.pendingToolFailureRecoveryCount,
515-
state: this.interventionState(),
516-
});
517-
this.pendingToolFailureRecoveryCount = 0;
518-
}
532+
this.flushToolFailureRecoveryAudit();
519533
const existing = actions[inferIndex] as Extract<
520534
ReactorAction,
521535
{ type: "infer" }

0 commit comments

Comments
 (0)