Skip to content

Commit e52cac0

Browse files
Stop retry amplification: director no longer re-wraps harness-exhausted retries (#595)
* Stop director recovery from re-wrapping harness-exhausted retries The harness's own retry policy already retries and exhausts timeout/retryable/quota_exhausted errors (up to 3 attempts) before an inference.error of one of those categories reaches the director. The director's recovery layer re-issued another full-context infer() call for the same categories, multiplying with the harness's own attempts instead of composing with them -- up to 9 identical full-context sends per logical turn with no wall-clock ceiling. Director recovery now only handles internal-recovery-abort, the one category the harness never retries on its own, so the two layers no longer multiply. Attempt counts are logged on each recovery. * Give an exhausted timeout its own reply preamble The vendored DefaultDirector's ERROR_PREAMBLE map has no timeout entry and falls back to the fatal ('unrecoverable inference error') wording. CL-6910 makes an exhausted timeout the routine terminal state instead of a rarity, so intercept it in ChatDirector and reply with accurate, calm wording instead of patching the vendored map.
1 parent 6c64f65 commit e52cac0

5 files changed

Lines changed: 260 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2929
the subtree-scoping rule for those is written and tested but not yet wired to
3030
a live call site. `task()` is unchanged and still the only spawn verb.
3131

32+
- **Retry recovery no longer multiplies with the harness's own retries.** The
33+
director's inference-recovery layer previously re-issued a full-context
34+
`infer()` call for `timeout`/`retryable` errors even though the harness's
35+
own retry policy already retries and exhausts those categories before
36+
surfacing them — compounding to up to 9 identical full-context sends per
37+
logical turn in the worst case. The director now only recovers
38+
internal-recovery aborts (a category the harness never retries on its
39+
own), so the two layers no longer multiply. Attempt counts are now logged
40+
on each recovery so retry storms are visible in traces.
41+
3242
### Fixed
3343

3444
- **Interrupting a turn no longer risks a startup crash.** If an interrupt hit

src/agent/director.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,3 +1083,155 @@ describe("ChatDirector tool-only loop protection", () => {
10831083
});
10841084
});
10851085
});
1086+
1087+
// CL-6910: the harness's own retry policy (vendor/intx-inference/src/
1088+
// retry-policy.ts) already owns `timeout`/`retryable`/`quota_exhausted` and
1089+
// exhausts its full attempt budget (3 attempts) before an `inference.error`
1090+
// of one of those categories ever reaches the director. The director must
1091+
// not re-wrap those categories in another `capabilities.infer()` call — that
1092+
// multiplied the two layers' attempt budgets (up to 9 identical full-context
1093+
// sends per turn) instead of composing them. `aborted` (internal-recovery)
1094+
// is the one category the harness never retries at all, so it remains the
1095+
// director's to recover, and that recovery does not compound with harness
1096+
// attempts.
1097+
function inferenceErrorEvent(
1098+
category: "retryable" | "timeout" | "aborted" | "quota_exhausted",
1099+
raw?: unknown,
1100+
): ReactorInboundEvent {
1101+
return {
1102+
type: "inference.error",
1103+
error: { category, message: "boom", raw },
1104+
} as unknown as ReactorInboundEvent;
1105+
}
1106+
1107+
describe("ChatDirector inference-error recovery (CL-6910)", () => {
1108+
const providerlessPolicy = { providerName: "test-provider" };
1109+
1110+
test.each(["retryable", "timeout", "quota_exhausted"] as const)(
1111+
"does not re-issue inference for a %s error already exhausted by the harness",
1112+
async (category) => {
1113+
const director = createChatDirector("system", [], {
1114+
onTasksChange: () => {},
1115+
provider: providerlessPolicy,
1116+
});
1117+
const capabilities = makeCapabilities();
1118+
1119+
const actions = actionsArray(
1120+
await director.decide(inferenceErrorEvent(category), mockState, capabilities),
1121+
);
1122+
1123+
// No additional full-context send: the base director's terminal
1124+
// checkpoint + reply is the only outcome, not another `infer`.
1125+
expect(actions.some((a) => a.type === "infer")).toBe(false);
1126+
expect(actions.some((a) => a.type === "reply")).toBe(true);
1127+
},
1128+
);
1129+
1130+
test("still recovers on internal-recovery abort, bounded by MAX_INFERENCE_RECOVERIES", async () => {
1131+
const director = createChatDirector("system", [], {
1132+
onTasksChange: () => {},
1133+
provider: providerlessPolicy,
1134+
});
1135+
const capabilities = makeCapabilities();
1136+
const internalAbort = inferenceErrorEvent("aborted", { origin: "internal-recovery" });
1137+
1138+
// Recovery 1 of 2: re-issues inference.
1139+
const first = actionsArray(await director.decide(internalAbort, mockState, capabilities));
1140+
expect(first.some((a) => a.type === "infer")).toBe(true);
1141+
1142+
// Recovery 2 of 2: re-issues inference.
1143+
const second = actionsArray(await director.decide(internalAbort, mockState, capabilities));
1144+
expect(second.some((a) => a.type === "infer")).toBe(true);
1145+
1146+
// Budget exhausted: no further infer, terminal reply instead.
1147+
const third = actionsArray(await director.decide(internalAbort, mockState, capabilities));
1148+
expect(third.some((a) => a.type === "infer")).toBe(false);
1149+
expect(third.some((a) => a.type === "reply")).toBe(true);
1150+
});
1151+
1152+
test("an unrelated aborted error (not internal-recovery) is not recovered by the director", async () => {
1153+
const director = createChatDirector("system", [], {
1154+
onTasksChange: () => {},
1155+
provider: providerlessPolicy,
1156+
});
1157+
const capabilities = makeCapabilities();
1158+
1159+
const actions = actionsArray(
1160+
await director.decide(
1161+
inferenceErrorEvent("aborted", { origin: "user-stop" }),
1162+
mockState,
1163+
capabilities,
1164+
),
1165+
);
1166+
expect(actions.some((a) => a.type === "infer")).toBe(false);
1167+
});
1168+
1169+
test("inference-recovery budget resets at the next turn boundary", async () => {
1170+
const director = createChatDirector("system", [], {
1171+
onTasksChange: () => {},
1172+
provider: providerlessPolicy,
1173+
});
1174+
const capabilities = makeCapabilities();
1175+
const internalAbort = inferenceErrorEvent("aborted", { origin: "internal-recovery" });
1176+
1177+
await director.decide(internalAbort, mockState, capabilities);
1178+
await director.decide(internalAbort, mockState, capabilities);
1179+
// Budget exhausted for this turn.
1180+
const exhausted = actionsArray(await director.decide(internalAbort, mockState, capabilities));
1181+
expect(exhausted.some((a) => a.type === "infer")).toBe(false);
1182+
1183+
// A fresh turn boundary (inference.done) resets the budget.
1184+
await director.decide(toolOnlyTurn("post-boundary"), mockState, capabilities);
1185+
const afterBoundary = actionsArray(
1186+
await director.decide(internalAbort, mockState, capabilities),
1187+
);
1188+
expect(afterBoundary.some((a) => a.type === "infer")).toBe(true);
1189+
});
1190+
1191+
// Bounds the worst-case number of on-wire full-context sends per logical
1192+
// turn across the two layers that can legitimately fire: the harness's
1193+
// own retry policy (up to 3 attempts per `infer()` call — see
1194+
// vendor/intx-inference/src/retry-policy.ts MAX_ATTEMPTS) and the
1195+
// director's internal-recovery-only budget (up to 2 extra `infer()`
1196+
// calls). Before this fix, `retryable`/`timeout` re-entered this same
1197+
// director budget on top of the harness's exhausted 3, multiplying to 9.
1198+
// After this fix, `retryable`/`timeout`/`quota_exhausted` are harness-only
1199+
// (bounded at 3, asserted against createDefaultRetryPolicy behavior in
1200+
// retry-policy.test.ts), and `aborted` is director-only: each of the
1201+
// director's up-to-3 infer() calls (1 initial + 2 recoveries) is a single
1202+
// harness attempt because the harness's own policy never retries
1203+
// `aborted`. Worst case across a turn that alternates categories is
1204+
// bounded, not open-ended, and never reaches 9.
1205+
test("worst case: director-owned recovery path issues at most 1 + MAX_INFERENCE_RECOVERIES infer calls", async () => {
1206+
const director = createChatDirector("system", [], {
1207+
onTasksChange: () => {},
1208+
provider: providerlessPolicy,
1209+
});
1210+
const capabilities = makeCapabilities();
1211+
const internalAbort = inferenceErrorEvent("aborted", { origin: "internal-recovery" });
1212+
1213+
let inferCount = 0;
1214+
for (let i = 0; i < 10; i++) {
1215+
const actions = actionsArray(await director.decide(internalAbort, mockState, capabilities));
1216+
if (actions.some((a) => a.type === "infer")) inferCount++;
1217+
else break;
1218+
}
1219+
expect(inferCount).toBe(2); // MAX_INFERENCE_RECOVERIES
1220+
});
1221+
1222+
test("timeout category produces the timeout preamble, not the fatal fallback", async () => {
1223+
const director = createChatDirector("system", [], {
1224+
onTasksChange: () => {},
1225+
provider: providerlessPolicy,
1226+
});
1227+
const capabilities = makeCapabilities();
1228+
1229+
const actions = actionsArray(
1230+
await director.decide(inferenceErrorEvent("timeout"), mockState, capabilities),
1231+
);
1232+
const reply = actions.find((a) => a.type === "reply");
1233+
expect(reply).toBeDefined();
1234+
expect((reply as { content: string }).content).toContain("did not respond in time");
1235+
expect((reply as { content: string }).content).not.toContain("unrecoverable inference error");
1236+
});
1237+
});

src/agent/director.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -677,22 +677,55 @@ class ChatDirectorImpl extends DefaultDirector {
677677
const recovery = this.compaction.interceptOverflow(event, capabilities);
678678
if (recovery !== null) return recovery;
679679

680+
// Only `aborted` (internal-recovery-abort) lands here: the harness's own
681+
// retry policy already owns `timeout`/`retryable`/`quota_exhausted` and
682+
// has exhausted its own attempt budget (up to MAX_ATTEMPTS full-context
683+
// sends, see vendor/intx-inference/src/retry-policy.ts) before an
684+
// `inference.error` of one of those categories ever reaches the
685+
// director. Re-wrapping an already-exhausted harness retry in another
686+
// `capabilities.infer()` call multiplied the two budgets instead of
687+
// composing them (up to 9 identical full-context sends per turn,
688+
// CL-6910) without recovering anything the harness had not already
689+
// tried. Internal-recovery-abort is different: the harness's default
690+
// policy never retries `aborted` at all, so this remains the only
691+
// layer that owns that category, and it does not compound with the
692+
// harness's own attempts.
680693
if (
681694
event.type === "inference.error" &&
682-
(event.error.category === "timeout" ||
683-
event.error.category === "retryable" ||
684-
(event.error.category === "aborted" && isInternalRecoveryAbort(event)))
695+
event.error.category === "aborted" &&
696+
isInternalRecoveryAbort(event)
685697
) {
686698
if (this.inferenceRecoveries < MAX_INFERENCE_RECOVERIES) {
687699
this.inferenceRecoveries++;
700+
logger.warn`inference-recovery attempt=${String(this.inferenceRecoveries)} max=${String(MAX_INFERENCE_RECOVERIES)} category=${event.error.category}`;
688701
return [capabilities.checkpoint("inference-recovery"), capabilities.infer()];
689702
}
703+
logger.warn`inference-recovery-exhausted max=${String(MAX_INFERENCE_RECOVERIES)} category=${event.error.category}`;
690704
return [
691705
capabilities.checkpoint("inference-recovery-exhausted"),
692706
capabilities.reply("The request could not recover. Send a message to resume."),
693707
];
694708
}
695709

710+
// The vendored DefaultDirector's inference.error preamble map
711+
// (vendor/intx-inference/src/default-director.ts, ERROR_PREAMBLE) has no
712+
// `timeout` entry, so it falls back to the `fatal` wording ("...
713+
// unrecoverable inference error"). Before CL-6910, a `timeout` reaching
714+
// the director was rare (the harness retried it first, then the director
715+
// recovered it again — see the block above), so operators almost never
716+
// saw that fallback text. Now an exhausted `timeout` routinely lands here
717+
// as a terminal reply, so the misleading "unrecoverable" wording would
718+
// become the routine message for an ordinary timeout. Intercept it here
719+
// with accurate, calm wording rather than patching the vendored map.
720+
if (event.type === "inference.error" && event.error.category === "timeout") {
721+
return [
722+
capabilities.checkpoint("inference-error"),
723+
capabilities.reply(
724+
"This agent's request timed out because the inference provider did not respond in time. The request was retried and gave up.",
725+
),
726+
];
727+
}
728+
696729
// Both nudge budgets are monotonic per inbound user message rather than
697730
// resetting on "real" tool work. Classifying a tool call as progress is
698731
// gameable: a weak model learns that any tool call (including a no-op

src/agent/retry-policy.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,57 @@ describe("createCorbitsRetryPolicy", () => {
129129
expect(await policy(bare429)).toEqual({ kind: "retry", delayMs: 500 });
130130
});
131131

132+
// CL-6910: the harness only surfaces `inference.error` to the director
133+
// once this policy returns `abort` — so the attempt cap here IS the
134+
// on-wire send cap for these categories (the director no longer re-wraps
135+
// them, see director.test.ts). Bound at 3 sends for each error class the
136+
// ticket names: rate limit (quota_exhausted), gateway error and malformed
137+
// response (both normalized to retryable/protocol_mismatch here).
138+
test("rate limit (quota_exhausted) aborts by the 3rd attempt — bounds harness sends to 3", async () => {
139+
const policy = createCorbitsRetryPolicy();
140+
const situation = (attempt: number) => ({
141+
attempt,
142+
elapsedMs: 0,
143+
error: {
144+
category: "quota_exhausted" as const,
145+
message: "Too Many Requests",
146+
statusCode: 429,
147+
retryAfterMs: 10,
148+
},
149+
});
150+
expect(await policy(situation(1))).toEqual({ kind: "retry", delayMs: 10 });
151+
expect(await policy(situation(2))).toEqual({ kind: "retry", delayMs: 10 });
152+
expect(await policy(situation(3))).toEqual({ kind: "abort" });
153+
});
154+
155+
test("gateway error (retryable) aborts by the 3rd attempt — bounds harness sends to 3", async () => {
156+
const policy = createCorbitsRetryPolicy();
157+
const situation = (attempt: number) => ({
158+
attempt,
159+
elapsedMs: 0,
160+
error: { category: "retryable" as const, message: "gateway timeout" },
161+
});
162+
expect(await policy(situation(1))).toEqual({ kind: "retry", delayMs: 500 });
163+
expect(await policy(situation(2))).toEqual({ kind: "retry", delayMs: 1000 });
164+
expect(await policy(situation(3))).toEqual({ kind: "abort" });
165+
});
166+
167+
test("malformed response (HTML gateway page) aborts by the 3rd attempt — bounds harness sends to 3", async () => {
168+
const policy = createCorbitsRetryPolicy();
169+
const situation = (attempt: number) => ({
170+
attempt,
171+
elapsedMs: 0,
172+
error: {
173+
category: "protocol_mismatch" as const,
174+
message: "malformed JSON in SSE data payload",
175+
raw: HTML_503,
176+
},
177+
});
178+
expect(await policy(situation(1))).toEqual({ kind: "retry", delayMs: 500 });
179+
expect(await policy(situation(2))).toEqual({ kind: "retry", delayMs: 1000 });
180+
expect(await policy(situation(3))).toEqual({ kind: "abort" });
181+
});
182+
132183
test("live providerId getter: xAI → non-xAI stops remapping bare 429", async () => {
133184
let current: string | undefined = "xai/thegreataxios";
134185
const policy = createCorbitsRetryPolicy({ providerId: () => current });

src/director.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -460,23 +460,23 @@ describe("chatDirector compaction", () => {
460460
expect(resumed.some((a) => a.type === "infer")).toBe(true);
461461
});
462462

463-
test("retries recoverable inference failures within a bounded budget", async () => {
463+
// CL-6910: `timeout`/`retryable` are owned entirely by the harness's own
464+
// retry policy, which already retries and exhausts them before an
465+
// `inference.error` of one of those categories ever reaches the director.
466+
// The director re-issuing another `infer()` here used to multiply with
467+
// the harness's own attempts (up to 9 identical full-context sends per
468+
// turn); it now falls through to the base director's terminal
469+
// checkpoint + reply instead of recovering.
470+
test("does not re-issue inference for a timeout already exhausted by the harness", async () => {
464471
const director = chatDirectorWithContinuation();
465472
const timeout = {
466473
type: "inference.error",
467474
error: { category: "timeout", message: "request timed out" },
468475
} as unknown as ReactorInboundEvent;
469476

470-
for (let i = 0; i < 2; i++) {
471-
const actions = actionsArray(await director.decide(timeout, longState, mockCapabilities));
472-
expect(actions.some((action) => action.type === "infer")).toBe(true);
473-
}
474-
475-
const exhausted = actionsArray(await director.decide(timeout, longState, mockCapabilities));
476-
expect(exhausted).toEqual([
477-
{ type: "checkpoint", message: "inference-recovery-exhausted" },
478-
{ type: "reply", content: "The request could not recover. Send a message to resume." },
479-
]);
477+
const actions = actionsArray(await director.decide(timeout, longState, mockCapabilities));
478+
expect(actions.some((action) => action.type === "infer")).toBe(false);
479+
expect(actions.some((action) => action.type === "reply")).toBe(true);
480480
});
481481

482482
test("recovers an internally aborted inference but keeps explicit abort terminal", async () => {

0 commit comments

Comments
 (0)