fix(agent): retry empty anthropic-compatible replies

This commit is contained in:
Peter Steinberger
2026-05-15 08:51:54 +01:00
parent 930852af29
commit be166b9ae4
3 changed files with 72 additions and 8 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu.
- Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors.
- Discord: report unresolved configured bot-token SecretRefs during startup instead of treating the account as unconfigured. (#82009) Thanks @giodl73-repo.
- CLI/config: preserve numeric-looking object keys such as Discord guild IDs during `config patch` recursive merges. (#81999) Thanks @giodl73-repo.

View File

@@ -817,6 +817,75 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expectWarnMessageWith("empty response detected");
});
it("retries empty Anthropic-compatible stop turns even when the provider is not Kimi", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedResolveModelAsync.mockResolvedValue({
model: {
id: "claude-opus-4-7",
provider: "sub2api",
contextWindow: 200000,
api: "anthropic-messages",
},
error: null,
authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
});
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
api: "anthropic-messages",
stopReason: "stop",
provider: "sub2api",
model: "claude-opus-4-7",
content: [],
usage: {
input: 2048,
output: 3100,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 5148,
},
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: ["Visible Anthropic-compatible answer."],
lastAssistant: {
role: "assistant",
api: "anthropic-messages",
stopReason: "stop",
provider: "sub2api",
model: "claude-opus-4-7",
content: [{ type: "text", text: "Visible Anthropic-compatible answer." }],
usage: {
input: 2300,
output: 8,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2308,
},
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
);
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "sub2api",
model: "claude-opus-4-7",
runId: "run-empty-anthropic-compatible-stop-continuation",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
const secondCall = runAttemptCall(1);
expect(secondCall.prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION);
expectWarnMessageWith("empty response detected");
});
it("surfaces an error after exhausting empty-response retries", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValue(

View File

@@ -131,7 +131,6 @@ const GEMINI_INCOMPLETE_TURN_MODEL_ID_PATTERN = /^gemini(?:[.-]|$)/;
// Ollama native `/api/chat` can finish with only thinking/internal blocks when
// constrained, but it should not inherit the stricter planning-only/ack prompts.
const OLLAMA_INCOMPLETE_TURN_PROVIDER_ID_PATTERN = /^ollama(?:-|$)/;
const KIMI_INCOMPLETE_TURN_PROVIDER_ID_PATTERN = /^kimi(?:-|$)/;
const DEFAULT_PLANNING_ONLY_RETRY_LIMIT = 1;
const STRICT_AGENTIC_PLANNING_ONLY_RETRY_LIMIT = 2;
// Allow one immediate continuation plus one follow-up continuation before
@@ -620,14 +619,9 @@ function shouldApplyNonVisibleTurnRetryGuard(params: {
if (shouldApplyPlanningOnlyRetryGuard(params)) {
return true;
}
if (normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "openai-completions") {
return true;
}
if (
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "anthropic-messages" &&
KIMI_INCOMPLETE_TURN_PROVIDER_ID_PATTERN.test(
normalizeLowercaseStringOrEmpty(params.provider ?? ""),
)
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "openai-completions" ||
normalizeLowercaseStringOrEmpty(params.modelApi ?? "") === "anthropic-messages"
) {
return true;
}