-
-
Notifications
You must be signed in to change notification settings - Fork 10.1k
fix(combo): flatten Anthropic tool messages + tool history to prevent upstream 503 #4648
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
|
Comment on lines
+86
to
+110
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If an Anthropic-style content array contains non-text/non-tool blocks (such as We should check if any non-text/non-tool blocks exist. If they do, we should map the blocks individually (replacing only the tool blocks with text blocks) to preserve the other blocks. If they do not, we can safely flatten the array into a single string as originally designed. if (hasToolUse || hasToolResult) {
const hasNonTextNonTool = blocks.some(
(b) => b && b.type !== "text" && b.type !== "tool_use" && b.type !== "tool_result"
);
if (hasNonTextNonTool) {
const newContent = blocks.map((block) => {
if (block?.type === "tool_use") {
return {
type: "text",
text: `${TOOL_CALL_PREFIX}${block.name || "tool"}]`,
};
}
if (block?.type === "tool_result") {
const text =
extractTextContent(block.content) || String(block.content ?? "");
return {
type: "text",
text: `${TOOL_RESULT_PREFIX}${text}]`,
};
}
return block;
});
out.push({ ...msg, content: newContent });
} else {
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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]`); | ||
| }); | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a unit test to verify that non-text/non-tool blocks (such as images) are correctly preserved when flattening Anthropic-style tool messages. it("preserves non-text/non-tool blocks (like images) in Anthropic content arrays", () => {
const msgs = [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "xyz" } },
{ type: "tool_result", tool_use_id: "t1", content: "done" },
],
},
];
const out = flattenToolHistory(msgs);
assert.equal(out.length, 1);
assert.ok(Array.isArray(out[0].content));
const content = out[0].content as any[];
assert.equal(content.length, 2);
assert.deepEqual(content[0], {
type: "image",
source: { type: "base64", media_type: "image/jpeg", data: "xyz" },
});
assert.deepEqual(content[1], {
type: "text",
text: `${TOOL_RESULT_PREFIX}done]`,
});
}); |
||
| 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); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When converting
toolorfunctionrole messages toassistantprose, any custom metadata or tracing properties attached to the original message object are lost because we only construct a new object withroleandcontent. Spreading the remaining properties (while explicitly omittingtool_call_idsince it is invalid onassistantmessages) preserves this metadata.