fix(agents): strip stale anthropic assistant prefill

This commit is contained in:
Peter Steinberger
2026-04-27 09:36:17 +01:00
parent 3be8e68898
commit e21c909bd0
3 changed files with 66 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex.
- Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex.
- CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody.
- Agents/Anthropic: strip stale trailing assistant prefill turns from outbound replay so context-engine short circuits cannot send unsupported assistant-prefill payloads to provider APIs. Fixes #72556. Thanks @Veda-openclaw.
- Control UI/Dreaming: require explicit confirmation before applying restart-impacting Dreaming mode changes, with restart warning copy and loading feedback. Fixes #63804. (#63807) Thanks @bbddbb1.
- CLI/update: keep the automatic post-update completion refresh on the core-command tree so it no longer stages bundled plugin runtime deps before the Gateway restart path, avoiding `.24` update hangs and 1006 disconnect cascades. Fixes #72665. Thanks @sakalaboator and @He-Pin.
- Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock `ContentBlock` blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu.

View File

@@ -1633,6 +1633,44 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
expect(seenContext.messages).toBe(messages);
});
it("strips trailing assistant prefill turns for Anthropic outbound replay", async () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "earlier question" }],
},
{
role: "assistant",
content: [{ type: "text", text: "stale assistant answer" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateAnthropicTurns: true,
preserveSignatures: true,
dropThinkingBlocks: false,
} as never);
const stream = wrapped(
{ api: "anthropic-messages" } as never,
{ messages } as never,
{} as never,
) as FakeWrappedStream | Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "earlier question" }],
},
]);
expect(seenContext.messages).not.toBe(messages);
});
it("drops signed thinking turns when sibling replay tool calls are not allowlisted", async () => {
const messages = [
{

View File

@@ -525,6 +525,32 @@ function sanitizeAnthropicReplayToolResults(
return changed ? out : messages;
}
function assistantTurnHasReplayToolCall(message: AgentMessage): boolean {
if (!message || typeof message !== "object" || message.role !== "assistant") {
return false;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) {
return false;
}
return content.some((block) => isReplayToolCallBlock(block));
}
function stripTrailingAssistantPrefillTurns(messages: AgentMessage[]): AgentMessage[] {
let end = messages.length;
while (end > 0) {
const message = messages[end - 1];
if (!message || typeof message !== "object" || message.role !== "assistant") {
break;
}
if (assistantTurnHasReplayToolCall(message)) {
break;
}
end -= 1;
}
return end === messages.length ? messages : messages.slice(0, end);
}
function normalizeToolCallIdsInMessage(message: unknown): void {
if (!message || typeof message !== "object") {
return;
@@ -873,6 +899,7 @@ export function wrapStreamFnSanitizeMalformedToolCalls(
nextMessages = sanitizeAnthropicReplayToolResults(nextMessages, {
disallowEmbeddedUserToolResultsForSignedThinkingReplay: allowProviderOwnedThinkingReplay,
});
nextMessages = stripTrailingAssistantPrefillTurns(nextMessages);
}
if (nextMessages === messages) {
return baseFn(model, context, options);