Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions open-sse/utils/flattenToolHistory.ts
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;
}
Comment on lines +55 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When converting tool or function role messages to assistant prose, any custom metadata or tracing properties attached to the original message object are lost because we only construct a new object with role and content. Spreading the remaining properties (while explicitly omitting tool_call_id since it is invalid on assistant messages) preserves this metadata.

Suggested change
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;
}
if (msg.role === "tool" || msg.role === "function") {
const { role, content, tool_call_id, ...rest } = msg;
const text =
extractTextContent(content) || String(content ?? "");
out.push({
...rest,
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If an Anthropic-style content array contains non-text/non-tool blocks (such as image blocks for multimodal inputs), converting the entire content array into a single flattened string will silently drop those blocks, breaking vision or other multimodal capabilities in the combo leg.

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;
}
161 changes: 161 additions & 0 deletions tests/unit/combo-flatten-anthropic-tool-messages.test.ts
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]`);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
});
});
Loading