diff --git a/changelog.d/fixes/12920-aging-tool-result-block-order.md b/changelog.d/fixes/12920-aging-tool-result-block-order.md new file mode 100644 index 00000000000..6f53f0e187f --- /dev/null +++ b/changelog.d/fixes/12920-aging-tool-result-block-order.md @@ -0,0 +1 @@ +- **fix(compression):** progressive aging now appends its `[COMPRESSED:aging:…]` annotation after a turn's `tool_result` blocks instead of in front of them, so Anthropic no longer rejects aged conversations with "`tool_use` ids were found without `tool_result` blocks immediately after" ([#12920](https://github.com/diegosouzapw/OmniRoute/pull/12920)). diff --git a/open-sse/services/compression/messageContent.ts b/open-sse/services/compression/messageContent.ts index 5c3acb91e0d..47ea21ebde4 100644 --- a/open-sse/services/compression/messageContent.ts +++ b/open-sse/services/compression/messageContent.ts @@ -22,6 +22,12 @@ export function isTextBlock(value: unknown): value is TextBlock { ); } +export function isToolResultBlock(value: unknown): boolean { + return ( + !!value && typeof value === "object" && (value as { type?: unknown }).type === "tool_result" + ); +} + export function extractTextContent(content: ChatMessageLike["content"]): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -82,7 +88,14 @@ export function replaceTextContent(msg: ChatMessageLike, newText: string): ChatM }); if (!replaced) { - return { ...msg, content: [{ type: "text", text: newText }, ...msg.content] }; + // Anthropic requires every `tool_result` block to sit at the start of the + // user turn that answers a `tool_use`; a text block in front of them makes + // upstream reject the whole request with "tool_use ids were found without + // tool_result blocks immediately after" (#12890). Append the annotation in + // that case, and keep prepending everywhere else. + return msg.content.some(isToolResultBlock) + ? { ...msg, content: [...msg.content, { type: "text", text: newText }] } + : { ...msg, content: [{ type: "text", text: newText }, ...msg.content] }; } return { ...msg, content }; diff --git a/tests/unit/compression/aging-tool-result-order-12890.test.ts b/tests/unit/compression/aging-tool-result-order-12890.test.ts new file mode 100644 index 00000000000..bad0c3e0218 --- /dev/null +++ b/tests/unit/compression/aging-tool-result-order-12890.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + replaceTextContent, + type ChatMessageLike, +} from "../../../open-sse/services/compression/messageContent.ts"; +import { applyAging } from "../../../open-sse/services/compression/progressiveAging.ts"; + +// ─── #12890 — aged tool_result turns must keep tool_result first ───────────── +// The Anthropic Messages API requires the `tool_result` blocks answering a +// `tool_use` to lead the following user message. Aging a tool-result-only user +// turn used to prepend the `[COMPRESSED:aging:…]` annotation, producing +// ["text", "tool_result"] and a 400 from upstream. + +function toolResultTurn(id: string): ChatMessageLike { + return { + role: "user", + content: [{ type: "tool_result", tool_use_id: id, content: "ls: 3 files" }], + }; +} + +function blockTypes(msg: unknown): string[] { + const content = (msg as ChatMessageLike).content; + return Array.isArray(content) ? content.map((b) => (b as { type?: string }).type ?? "") : []; +} + +describe("aging a tool_result turn (#12890)", () => { + it("keeps tool_result first through applyAging", () => { + // distanceFromEnd of index 2 is 5 (> moderate: 3) → the fullSummary tier, + // which is where setContent/replaceTextContent injects the tag. + const messages: ChatMessageLike[] = [ + { role: "user", content: "start the task" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_01", name: "bash", input: {} }], + }, + toolResultTurn("toolu_01"), + { role: "assistant", content: "three files" }, + { role: "user", content: "and now the second one" }, + { role: "assistant", content: "done" }, + { role: "user", content: "thanks" }, + { role: "assistant", content: "you are welcome" }, + ]; + + const { messages: aged } = applyAging(messages); + const types = blockTypes(aged[2]); + + assert.deepEqual(types, ["tool_result", "text"], `got ${JSON.stringify(types)}`); + const annotation = (aged[2] as ChatMessageLike).content as Array<{ text?: string }>; + assert.match(annotation[1].text ?? "", /^\[COMPRESSED:aging:/); + }); + + it("still puts the annotation first when the turn carries no tool_result", () => { + const msg: ChatMessageLike = { + role: "user", + content: [{ type: "image", source: { foo: 1 } }], + }; + + const out = replaceTextContent(msg, "NEWTEXT"); + + assert.deepEqual(blockTypes(out), ["text", "image"]); + }); +});