mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
fix(combo): flatten Anthropic tool messages + tool history to prevent upstream 503 (#4648)
Integrated into release/v3.8.37 — flattenToolHistory helper (combo anti-503). Cherry-picked onto release tip; tests 9/9 green.
This commit is contained in:
committed by
GitHub
parent
5b1bb72483
commit
dcf8af0698
116
open-sse/utils/flattenToolHistory.ts
Normal file
116
open-sse/utils/flattenToolHistory.ts
Normal file
@@ -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<T extends Message>(
|
||||
messages: ReadonlyArray<T | null | undefined>
|
||||
): 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;
|
||||
}
|
||||
161
tests/unit/combo-flatten-anthropic-tool-messages.test.ts
Normal file
161
tests/unit/combo-flatten-anthropic-tool-messages.test.ts
Normal file
@@ -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<Record<string, unknown> | 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user