mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 17:41:33 -06:00
fix: fallback on safe prompt timeouts (#96142)
(cherry picked from commit 0da26499da)
This commit is contained in:
committed by
Dallin Romney
parent
23f1f130f8
commit
3aa5229691
@@ -0,0 +1,99 @@
|
||||
// Coverage for handing replay-safe plugin-harness prompt timeouts to model fallback.
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { makeModelFallbackCfg } from "../test-helpers/model-fallback-config-fixture.js";
|
||||
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
|
||||
import {
|
||||
loadRunOverflowCompactionHarness,
|
||||
MockedFailoverError,
|
||||
mockedClassifyFailoverReason,
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
|
||||
describe("runEmbeddedAgent prompt timeout fallback handoff", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetRunOverflowCompactionHarnessMocks();
|
||||
});
|
||||
|
||||
it("throws FailoverError for replay-safe harness-owned prompt timeouts when model fallbacks are configured", async () => {
|
||||
mockedClassifyFailoverReason.mockReturnValue("timeout");
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
promptError: new Error("LLM request timed out."),
|
||||
promptErrorSource: "prompt",
|
||||
}),
|
||||
);
|
||||
|
||||
const promise = runEmbeddedAgent({
|
||||
...overflowBaseRunParams,
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
runId: "run-prompt-timeout-fallback",
|
||||
config: makeModelFallbackCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.4",
|
||||
fallbacks: ["anthropic/claude-opus-4-6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
|
||||
await expect(promise).rejects.toThrow("LLM request timed out.");
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces replay-invalid prompt timeouts instead of handing them to model fallback", async () => {
|
||||
mockedClassifyFailoverReason.mockReturnValue("timeout");
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
promptError: new Error("LLM request timed out."),
|
||||
promptErrorSource: "prompt",
|
||||
promptTimeoutOutcome: {
|
||||
message: "Harness abandoned the timed-out turn after provider activity.",
|
||||
replayInvalid: true,
|
||||
livenessState: "abandoned",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await runEmbeddedAgent({
|
||||
...overflowBaseRunParams,
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
runId: "run-prompt-timeout-replay-invalid",
|
||||
config: makeModelFallbackCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.4",
|
||||
fallbacks: ["anthropic/claude-opus-4-6"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect(thrown).not.toBeInstanceOf(MockedFailoverError);
|
||||
expect(String((thrown as Error | undefined)?.message)).toContain("LLM request timed out.");
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -3116,6 +3116,12 @@ async function runEmbeddedAgentInternal(
|
||||
);
|
||||
const promptFailoverFailure =
|
||||
promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider });
|
||||
const promptTimeoutFallbackSafe =
|
||||
promptErrorSource === "prompt" &&
|
||||
promptFailoverReason === "timeout" &&
|
||||
!attempt.codexAppServerFailure &&
|
||||
attempt.promptTimeoutOutcome?.replayInvalid !== true &&
|
||||
attempt.replayMetadata.replaySafe;
|
||||
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
|
||||
const failedPromptProfileId = lastProfileId;
|
||||
const logPromptFailoverDecision = createFailoverDecisionLogger({
|
||||
@@ -3147,6 +3153,7 @@ async function runEmbeddedAgentInternal(
|
||||
failoverFailure: promptFailoverFailure,
|
||||
failoverReason: promptFailoverReason,
|
||||
harnessOwnsTransport: pluginHarnessOwnsTransport,
|
||||
promptTimeoutFallbackSafe,
|
||||
profileRotated: false,
|
||||
});
|
||||
if (
|
||||
@@ -3186,6 +3193,7 @@ async function runEmbeddedAgentInternal(
|
||||
failoverFailure: promptFailoverFailure,
|
||||
failoverReason: promptFailoverReason,
|
||||
harnessOwnsTransport: pluginHarnessOwnsTransport,
|
||||
promptTimeoutFallbackSafe,
|
||||
profileRotated: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -581,6 +581,44 @@ describe("resolveRunFailoverDecision", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back on fallback-safe harness-owned prompt timeouts", () => {
|
||||
expect(
|
||||
resolveRunFailoverDecision({
|
||||
stage: "prompt",
|
||||
aborted: false,
|
||||
externalAbort: false,
|
||||
fallbackConfigured: true,
|
||||
failoverFailure: true,
|
||||
failoverReason: "timeout",
|
||||
harnessOwnsTransport: true,
|
||||
promptTimeoutFallbackSafe: true,
|
||||
profileRotated: true,
|
||||
}),
|
||||
).toEqual({
|
||||
action: "fallback_model",
|
||||
reason: "timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces fallback-safe harness-owned prompt timeouts when no fallback is configured", () => {
|
||||
expect(
|
||||
resolveRunFailoverDecision({
|
||||
stage: "prompt",
|
||||
aborted: false,
|
||||
externalAbort: false,
|
||||
fallbackConfigured: false,
|
||||
failoverFailure: true,
|
||||
failoverReason: "timeout",
|
||||
harnessOwnsTransport: true,
|
||||
promptTimeoutFallbackSafe: true,
|
||||
profileRotated: true,
|
||||
}),
|
||||
).toEqual({
|
||||
action: "surface_error",
|
||||
reason: "timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces error on LLM idle timeout when no fallback is configured and rotation is exhausted", () => {
|
||||
expect(
|
||||
resolveRunFailoverDecision({
|
||||
|
||||
@@ -50,6 +50,7 @@ type PromptDecisionParams = {
|
||||
failoverFailure: boolean;
|
||||
failoverReason: FailoverReason | null;
|
||||
harnessOwnsTransport?: boolean;
|
||||
promptTimeoutFallbackSafe?: boolean;
|
||||
profileRotated: boolean;
|
||||
};
|
||||
|
||||
@@ -179,6 +180,14 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R
|
||||
};
|
||||
}
|
||||
if (params.harnessOwnsTransport && params.failoverReason === "timeout") {
|
||||
// Plugin harness lifecycle timeouts must stay inside the harness boundary;
|
||||
// only prompt request timeouts proven replay-safe may enter model fallback.
|
||||
if (params.promptTimeoutFallbackSafe === true && params.fallbackConfigured) {
|
||||
return {
|
||||
action: "fallback_model",
|
||||
reason: "timeout",
|
||||
};
|
||||
}
|
||||
return {
|
||||
action: "surface_error",
|
||||
reason: params.failoverReason,
|
||||
|
||||
Reference in New Issue
Block a user