From dcf8af0698652a8f0416bb711c9fd16ddd53fad3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:32:12 -0300 Subject: [PATCH] fix(combo): flatten Anthropic tool messages + tool history to prevent upstream 503 (#4648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.37 — flattenToolHistory helper (combo anti-503). Cherry-picked onto release tip; tests 9/9 green. --- open-sse/utils/flattenToolHistory.ts | 116 +++++++++++++ ...bo-flatten-anthropic-tool-messages.test.ts | 161 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 open-sse/utils/flattenToolHistory.ts create mode 100644 tests/unit/combo-flatten-anthropic-tool-messages.test.ts diff --git a/open-sse/utils/flattenToolHistory.ts b/open-sse/utils/flattenToolHistory.ts new file mode 100644 index 0000000000..62b82bfa1d --- /dev/null +++ b/open-sse/utils/flattenToolHistory.ts @@ -0,0 +1,116 @@ +/** + * Flatten tool turns (OpenAI tool/function role + tool_calls, and + * Anthropic-style tool_use / tool_result content blocks) into plain + * assistant prose. + * + * Why: when a combo leg (or any prose-only fan-out) strips the tools + * definitions but the prior history still carries structured tool turns, + * agentic models keep emitting tool_calls — returning empty prose and + * triggering an upstream 503. Flattening keeps the context but removes + * the tool-loop trigger. + * + * Pure function. Does not mutate input. + * + * Ported from upstream decolua/9router PR #1910 (commits 86162eeb + 9ab14e77). + */ +import { extractTextContent } from "../translator/helpers/geminiHelper.ts"; + +export const TOOL_CALL_PREFIX = "[Called tools: "; +export const TOOL_RESULT_PREFIX = "[Tool result: "; + +type ContentBlock = { + type?: string; + text?: string; + name?: string; + content?: unknown; + [k: string]: unknown; +}; + +type ToolCall = { + function?: { name?: string }; + name?: string; + [k: string]: unknown; +}; + +type Message = { + role?: string; + content?: unknown; + tool_calls?: ToolCall[]; + [k: string]: unknown; +}; + +function isMessage(m: unknown): m is Message { + return m != null && typeof m === "object"; +} + +export function flattenToolHistory( + messages: ReadonlyArray +): Message[] { + const out: Message[] = []; + for (const raw of messages) { + if (!isMessage(raw)) continue; + const msg = raw as Message; + + // OpenAI tool / function role -> assistant prose + if (msg.role === "tool" || msg.role === "function") { + const text = + extractTextContent(msg.content) || String(msg.content ?? ""); + out.push({ + role: "assistant", + content: `${TOOL_RESULT_PREFIX}${text}]`, + }); + continue; + } + + // OpenAI assistant with structured tool_calls -> flatten into prose + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + const { tool_calls, ...rest } = msg; + const names = tool_calls + .map((c) => c?.function?.name || c?.name || "tool") + .join(", "); + const base = + extractTextContent(rest.content) || + (typeof rest.content === "string" ? rest.content : ""); + out.push({ + ...rest, + content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]`, + }); + continue; + } + + // Anthropic-style tool_use / tool_result blocks in content array + if (Array.isArray(msg.content)) { + const blocks = msg.content as ContentBlock[]; + const hasToolUse = blocks.some((c) => c?.type === "tool_use"); + const hasToolResult = blocks.some((c) => c?.type === "tool_result"); + if (hasToolUse || hasToolResult) { + const textParts: string[] = []; + const toolNames: string[] = []; + const toolResults: string[] = []; + for (const block of blocks) { + if (block?.type === "text" && typeof block.text === "string") { + textParts.push(block.text); + } else if (block?.type === "tool_use") { + toolNames.push(block.name || "tool"); + } else if (block?.type === "tool_result") { + toolResults.push( + extractTextContent(block.content) || String(block.content ?? "") + ); + } + } + let newContent = textParts.join("\n"); + if (toolNames.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`; + } + if (toolResults.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`; + } + out.push({ ...msg, content: newContent }); + continue; + } + } + + out.push(msg); + } + return out; +} diff --git a/tests/unit/combo-flatten-anthropic-tool-messages.test.ts b/tests/unit/combo-flatten-anthropic-tool-messages.test.ts new file mode 100644 index 0000000000..b42d747e1f --- /dev/null +++ b/tests/unit/combo-flatten-anthropic-tool-messages.test.ts @@ -0,0 +1,161 @@ +/** + * Tests for flattenToolHistory — a defensive normalizer that flattens + * structured tool turns (OpenAI tool_calls + tool role messages, and + * Anthropic-style tool_use / tool_result content blocks) into plain + * assistant prose. + * + * Why this matters in combo legs: when a combo's panel/expert leg is asked + * to emit prose (tools stripped) but the prior history still carries tool + * call structures, agentic models keep emitting tool_calls — returning + * empty prose and triggering an upstream 503. Flattening the history + * preserves context but removes the tool-loop trigger. + * + * Ported from upstream decolua/9router commits 86162eeb + 9ab14e77 (PR #1910). + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + flattenToolHistory, + TOOL_CALL_PREFIX, + TOOL_RESULT_PREFIX, +} from "../../open-sse/utils/flattenToolHistory.ts"; + +describe("flattenToolHistory", () => { + it("flattens OpenAI tool role messages into assistant prose", () => { + const msgs = [ + { role: "user", content: "find files" }, + { + role: "assistant", + content: "", + tool_calls: [{ id: "c1", type: "function", function: { name: "find" } }], + }, + { role: "tool", tool_call_id: "c1", content: "['a.js']" }, + { role: "user", content: "describe it" }, + ]; + const out = flattenToolHistory(msgs); + assert.equal(out.length, 4); + assert.equal(out[0].role, "user"); + // assistant tool_calls flattened + assert.equal(out[1].tool_calls, undefined); + assert.ok(typeof out[1].content === "string"); + assert.ok((out[1].content as string).includes("find")); + assert.ok((out[1].content as string).includes(TOOL_CALL_PREFIX)); + // tool role -> assistant prose + assert.equal(out[2].role, "assistant"); + assert.ok((out[2].content as string).includes("['a.js']")); + assert.ok((out[2].content as string).includes(TOOL_RESULT_PREFIX)); + assert.deepEqual(out[3], { role: "user", content: "describe it" }); + }); + + it("flattens Anthropic-style tool_use and tool_result content blocks", () => { + const msgs = [ + { role: "user", content: "do it" }, + { + role: "assistant", + content: [ + { type: "text", text: "ok" }, + { type: "tool_use", id: "t1", name: "run" }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "done" }], + }, + ]; + const out = flattenToolHistory(msgs); + assert.equal(out.length, 3); + // assistant Anthropic tool_use flattened + assert.equal(out[1].content, `ok\n${TOOL_CALL_PREFIX}run]`); + // user tool_result flattened (preserved role; content becomes prose) + assert.equal(out[2].content, `${TOOL_RESULT_PREFIX}done]`); + }); + + it("preserves messages without tool turns unchanged", () => { + const msgs = [ + { role: "system", content: "you are helpful" }, + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ]; + const out = flattenToolHistory(msgs); + assert.deepEqual(out, msgs); + }); + + it("filters out null/undefined entries", () => { + const msgs = [ + { role: "user", content: "a" }, + null, + undefined, + { role: "assistant", content: "b" }, + ] as Array | null | undefined>; + const out = flattenToolHistory(msgs); + assert.equal(out.length, 2); + }); + + it("flattens function role (legacy) into assistant prose", () => { + const msgs = [ + { role: "user", content: "q" }, + { role: "function", name: "f", content: "result" }, + ]; + const out = flattenToolHistory(msgs); + assert.equal(out[1].role, "assistant"); + assert.ok((out[1].content as string).includes("result")); + }); + + it("handles assistant with text content + tool_calls (preserves the text)", () => { + const msgs = [ + { + role: "assistant", + content: "thinking out loud", + tool_calls: [{ function: { name: "search" } }, { function: { name: "fetch" } }], + }, + ]; + const out = flattenToolHistory(msgs); + assert.equal(out[0].tool_calls, undefined); + assert.equal(out[0].content, `thinking out loud\n${TOOL_CALL_PREFIX}search, fetch]`); + }); + + it("handles Anthropic tool_use with no text block (only tool calls)", () => { + const msgs = [ + { + role: "assistant", + content: [ + { type: "tool_use", id: "t1", name: "alpha" }, + { type: "tool_use", id: "t2", name: "beta" }, + ], + }, + ]; + const out = flattenToolHistory(msgs); + assert.equal(out[0].content, `${TOOL_CALL_PREFIX}alpha, beta]`); + }); + + it("handles Anthropic tool_result content as array of text blocks", () => { + const msgs = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "t1", + content: [{ type: "text", text: "file: a.js" }], + }, + ], + }, + ]; + const out = flattenToolHistory(msgs); + assert.equal(out[0].content, `${TOOL_RESULT_PREFIX}file: a.js]`); + }); + + it("is a pure function (does not mutate input)", () => { + const msgs = [ + { role: "tool", tool_call_id: "c1", content: "x" }, + { + role: "assistant", + content: "", + tool_calls: [{ function: { name: "n" } }], + }, + ]; + const snapshot = JSON.parse(JSON.stringify(msgs)); + flattenToolHistory(msgs); + assert.deepEqual(msgs, snapshot); + }); +});