Skip to content

Commit 98ed83f

Browse files
shengtingShengting Xieyayu
authored
fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations (#90908)
* fix(model-fallback): don't rethrow provider-side AbortErrors as user cancellations When the LLM API closes the connection mid-stream, the fetch layer surfaces AbortError("This operation was aborted") with no external abort signal triggered. The old guard `shouldRethrowAbort()` returned false for these errors (because isTimeoutError matched the message), so they fell through to the fallback loop but were never retried — the error propagated up and produced SILENT_REPLY_TOKEN in group sessions, permanently silencing the topic. Replace the guard with a direct check: only rethrow AbortError when the external abort signal is actually set (user/gateway cancellation). Provider-side AbortErrors without an external signal now fall through to the next fallback candidate, giving the system a chance to recover. * fix(cron): forward abort signal into runWithModelFallback Thread the cron executor's abort signal into the shared runWithModelFallback call so that cron timeouts and cancellations stop the fallback chain instead of retrying with the next candidate. Previously, the run callback checked params.abortSignal?.aborted and threw, but runWithModelFallback itself had no signal — so the new guard in model-fallback.ts could not distinguish a caller abort from a provider-side AbortError and would retry silently. Also adds a focused regression test verifying the signal is forwarded. --------- Co-authored-by: Shengting Xie <[email protected]> Co-authored-by: yayu <[email protected]>
1 parent 1bdde66 commit 98ed83f

5 files changed

Lines changed: 152 additions & 26 deletions

File tree

src/agents/embedded-agent-runner/compact.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,7 @@ export async function compactEmbeddedAgentSessionDirect(
514514
agentId: fallbackAgentId,
515515
sessionId: params.sessionId,
516516
sessionKey: fallbackSessionKey,
517+
abortSignal: params.abortSignal,
517518
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
518519
await ensureSelectedAgentHarnessPlugin({
519520
config: params.config,

src/agents/model-fallback.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,6 +2761,8 @@ describe("runWithModelFallback", () => {
27612761

27622762
it("does not fall back on user aborts", async () => {
27632763
const cfg = makeCfg();
2764+
const controller = new AbortController();
2765+
controller.abort(Object.assign(new Error("timeout"), { name: "TimeoutError" }));
27642766
const run = vi
27652767
.fn()
27662768
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
@@ -2771,6 +2773,7 @@ describe("runWithModelFallback", () => {
27712773
cfg,
27722774
provider: "openai",
27732775
model: "gpt-4.1-mini",
2776+
abortSignal: controller.signal,
27742777
run,
27752778
}),
27762779
).rejects.toThrow("aborted");
@@ -2797,6 +2800,94 @@ describe("runWithModelFallback", () => {
27972800
expect(run).toHaveBeenCalledTimes(1);
27982801
});
27992802

2803+
it("does not fall back when user cancels with AbortError reason", async () => {
2804+
const cfg = makeCfg();
2805+
const controller = new AbortController();
2806+
controller.abort(Object.assign(new Error("cancelled"), { name: "AbortError" }));
2807+
const run = vi
2808+
.fn()
2809+
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
2810+
.mockResolvedValueOnce("should not run");
2811+
2812+
await expect(
2813+
runWithModelFallback({
2814+
cfg,
2815+
provider: "openai",
2816+
model: "gpt-4.1-mini",
2817+
abortSignal: controller.signal,
2818+
run,
2819+
}),
2820+
).rejects.toThrow("aborted");
2821+
2822+
expect(run).toHaveBeenCalledTimes(1);
2823+
});
2824+
2825+
it("does not fall back when caller cancellation uses a string reason", async () => {
2826+
const cfg = makeCfg();
2827+
const controller = new AbortController();
2828+
controller.abort("Cancelled by operator.");
2829+
const run = vi
2830+
.fn()
2831+
.mockRejectedValueOnce(Object.assign(new Error("aborted"), { name: "AbortError" }))
2832+
.mockResolvedValueOnce("should not run");
2833+
2834+
await expect(
2835+
runWithModelFallback({
2836+
cfg,
2837+
provider: "openai",
2838+
model: "gpt-4.1-mini",
2839+
abortSignal: controller.signal,
2840+
run,
2841+
}),
2842+
).rejects.toThrow("aborted");
2843+
2844+
expect(run).toHaveBeenCalledTimes(1);
2845+
});
2846+
2847+
it("does not fall back when caller cancellation throws a plain error", async () => {
2848+
const cfg = makeCfg();
2849+
const controller = new AbortController();
2850+
controller.abort("Cancelled by operator.");
2851+
const run = vi
2852+
.fn()
2853+
.mockRejectedValueOnce(new Error("Cancelled by operator."))
2854+
.mockResolvedValueOnce("should not run");
2855+
2856+
await expect(
2857+
runWithModelFallback({
2858+
cfg,
2859+
provider: "openai",
2860+
model: "gpt-4.1-mini",
2861+
abortSignal: controller.signal,
2862+
run,
2863+
}),
2864+
).rejects.toThrow("Cancelled by operator.");
2865+
2866+
expect(run).toHaveBeenCalledTimes(1);
2867+
});
2868+
2869+
it("falls back when AbortError comes from the LLM provider (no external signal)", async () => {
2870+
const cfg = makeProviderFallbackCfg("openai");
2871+
const run = vi
2872+
.fn()
2873+
.mockRejectedValueOnce(
2874+
Object.assign(new Error("This operation was aborted"), { name: "AbortError" }),
2875+
)
2876+
.mockResolvedValueOnce({ payloads: [{ text: "fallback ok" }] });
2877+
2878+
const result = await runWithModelFallback({
2879+
cfg,
2880+
provider: "openai",
2881+
model: "gpt-4.1-mini",
2882+
run,
2883+
});
2884+
2885+
expect(result.result).toEqual({ payloads: [{ text: "fallback ok" }] });
2886+
expect(run).toHaveBeenCalledTimes(2);
2887+
expect(result.attempts[0]?.provider).toBe("openai");
2888+
expect(result.attempts[0]?.error).toBe("This operation was aborted");
2889+
});
2890+
28002891
it("does not fall back when the caller abort signal timed out", async () => {
28012892
const cfg = makeCfg();
28022893
const timeoutReason = new Error("chat run timed out");
@@ -2854,6 +2945,37 @@ describe("runWithModelFallback", () => {
28542945
expect(classifyResult).toHaveBeenCalledTimes(1);
28552946
});
28562947

2948+
it("does not fall back when a user AbortError is classified from the result", async () => {
2949+
const cfg = makeProviderFallbackCfg("openai");
2950+
const abortReason = new Error("chat run cancelled");
2951+
abortReason.name = "AbortError";
2952+
const controller = new AbortController();
2953+
controller.abort(abortReason);
2954+
const run = vi
2955+
.fn()
2956+
.mockResolvedValueOnce({ payloads: [] })
2957+
.mockResolvedValueOnce({ payloads: [{ text: "fallback should not run" }] });
2958+
const classifyResult = vi.fn(() => ({
2959+
message: "This operation was aborted",
2960+
reason: "timeout" as const,
2961+
code: "terminal_abort",
2962+
}));
2963+
2964+
await expect(
2965+
runWithModelFallback({
2966+
cfg,
2967+
provider: "openai",
2968+
model: "m1",
2969+
abortSignal: controller.signal,
2970+
run,
2971+
classifyResult,
2972+
}),
2973+
).rejects.toThrow("This operation was aborted");
2974+
2975+
expect(run).toHaveBeenCalledTimes(1);
2976+
expect(classifyResult).toHaveBeenCalledTimes(1);
2977+
});
2978+
28572979
it("does not fall back when a restart abort is classified from the result", async () => {
28582980
const cfg = makeProviderFallbackCfg("openai");
28592981
const controller = new AbortController();

src/agents/model-fallback.ts

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import {
3939
describeFailoverError,
4040
isFailoverError,
4141
isNonProviderRuntimeCoordinationError,
42-
isTimeoutError,
4342
} from "./failover-error.js";
4443
import {
4544
shouldAllowCooldownProbeForReason,
@@ -189,25 +188,6 @@ type ModelFallbackRunFn<T> = (
189188
options?: ModelFallbackRunOptions,
190189
) => Promise<T>;
191190

192-
/**
193-
* Fallback abort check. Only treats explicit AbortError names as user aborts.
194-
* Message-based checks (e.g., "aborted") can mask timeouts and skip fallback.
195-
*/
196-
function isFallbackAbortError(err: unknown): boolean {
197-
if (!err || typeof err !== "object") {
198-
return false;
199-
}
200-
if (isFailoverError(err)) {
201-
return false;
202-
}
203-
const name = "name" in err ? String(err.name) : "";
204-
return name === "AbortError";
205-
}
206-
207-
function shouldRethrowAbort(err: unknown): boolean {
208-
return isFallbackAbortError(err) && !isTimeoutError(err);
209-
}
210-
211191
function isTerminalAbort(signal: AbortSignal | undefined): boolean {
212192
if (!signal?.aborted) {
213193
return false;
@@ -225,6 +205,10 @@ function isTerminalAbort(signal: AbortSignal | undefined): boolean {
225205
return reason.name === "ClientDisconnectError";
226206
}
227207

208+
function isCallerAbortSignal(signal: AbortSignal | undefined): boolean {
209+
return signal?.aborted === true;
210+
}
211+
228212
function createModelCandidateCollector(allowlist: Set<string> | null | undefined): {
229213
candidates: ModelCandidate[];
230214
addExplicitCandidate: (candidate: ModelCandidate) => void;
@@ -367,7 +351,10 @@ async function runFallbackCandidate<T>(params: {
367351
if (isNonProviderRuntimeCoordinationError(err)) {
368352
throw err;
369353
}
370-
if (isTerminalAbort(params.abortSignal)) {
354+
if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) {
355+
throw err;
356+
}
357+
if (isAgentRunRestartAbortReason(err)) {
371358
throw err;
372359
}
373360
// Normalize abort-wrapped rate-limit errors (e.g. Google Vertex RESOURCE_EXHAUSTED)
@@ -378,9 +365,6 @@ async function runFallbackCandidate<T>(params: {
378365
sessionId: params.attribution?.sessionId,
379366
lane: params.attribution?.lane,
380367
});
381-
if (shouldRethrowAbort(err) && !normalizedFailover) {
382-
throw err;
383-
}
384368
return { ok: false, error: normalizedFailover ?? err };
385369
}
386370
}
@@ -430,7 +414,7 @@ async function runFallbackAttempt<T>(params: {
430414
attribution: params.attribution,
431415
});
432416
if (classifiedError) {
433-
if (isTerminalAbort(params.abortSignal)) {
417+
if (isTerminalAbort(params.abortSignal) || isCallerAbortSignal(params.abortSignal)) {
434418
throw toErrorObject(classifiedError, "Non-Error thrown");
435419
}
436420
const preserveResultOnExhaustion =
@@ -1323,7 +1307,8 @@ function shouldDiscardDeferredSessionSuspension(params: {
13231307
}): boolean {
13241308
return (
13251309
isTerminalAbort(params.abortSignal) ||
1326-
shouldRethrowAbort(params.error) ||
1310+
isCallerAbortSignal(params.abortSignal) ||
1311+
isAgentRunRestartAbortReason(params.error) ||
13271312
isCommandLaneTaskTimeoutError(params.error) ||
13281313
isNonProviderRuntimeCoordinationError(params.error) ||
13291314
isLikelyContextOverflowError(formatErrorMessage(params.error))

src/cron/isolated-agent/run-executor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export function createCronPromptExecutor(params: {
283283
agentDir: params.agentDir,
284284
agentId: params.agentId,
285285
sessionKey: params.runSessionKey,
286+
abortSignal: params.abortSignal,
286287
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
287288
await ensureSelectedAgentHarnessPlugin({
288289
config: params.cfgWithAgentDefaults,

src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,4 +665,21 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)",
665665

666666
expect(capturedFallbacksOverride).toEqual(["openai/gpt-4o"]);
667667
});
668+
669+
it("forwards the cron abort signal into runWithModelFallback", async () => {
670+
const controller = new AbortController();
671+
let capturedAbortSignal: AbortSignal | undefined;
672+
runWithModelFallbackMock.mockImplementation(
673+
async (params: { provider: string; model: string; abortSignal?: AbortSignal }) => {
674+
capturedAbortSignal = params.abortSignal;
675+
return makeSuccessfulRunResult();
676+
},
677+
);
678+
679+
controller.abort(new Error("cron: job execution timed out"));
680+
681+
await runCronIsolatedAgentTurn(makeParams({ abortSignal: controller.signal }));
682+
683+
expect(capturedAbortSignal).toBe(controller.signal);
684+
});
668685
});

0 commit comments

Comments
 (0)