Skip to content

Commit 31747ec

Browse files
committed
Serialize parallel Claude tool results atomically
1 parent e44f211 commit 31747ec

2 files changed

Lines changed: 52 additions & 4 deletions

File tree

‎src/lib/providers/claude.js‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -492,12 +492,25 @@ function toAnthropicBody(body, model, stream) {
492492
) ?? -1;
493493
const preferredCacheBases = [];
494494

495+
const userBlocksWithToolResultsFirst = (blocks) => {
496+
const toolResults = blocks.filter((block) => block?.type === "tool_result");
497+
if (!toolResults.length) return blocks;
498+
return [...toolResults, ...blocks.filter((block) => block?.type !== "tool_result")];
499+
};
500+
495501
const pushMessage = (role, blocks) => {
496502
if (!blocks.length) return;
497-
// Merge consecutive same-role messages only when no tool blocks (simple text chats)
498-
const hasTool =
499-
blocks.some((b) => b.type === "tool_use" || b.type === "tool_result");
500503
const last = messages[messages.length - 1];
504+
// Anthropic requires every result for one parallel assistant tool batch in
505+
// the immediately following *single* user message, with tool_result blocks
506+
// before text/images. OpenAI-compatible clients may emit each result and
507+
// supplemental image evidence as separate consecutive user messages.
508+
if (role === "user" && last?.role === "user" && Array.isArray(last.content)) {
509+
last.content = userBlocksWithToolResultsFirst([...last.content, ...blocks]);
510+
return;
511+
}
512+
// Keep the prior simple-text coalescing behavior for assistant messages.
513+
const hasTool = blocks.some((b) => b.type === "tool_use" || b.type === "tool_result");
501514
if (
502515
last &&
503516
last.role === role &&
@@ -508,7 +521,7 @@ function toAnthropicBody(body, model, stream) {
508521
last.content.push(...blocks);
509522
return;
510523
}
511-
messages.push({ role, content: blocks });
524+
messages.push({ role, content: role === "user" ? userBlocksWithToolResultsFirst(blocks) : blocks });
512525
};
513526

514527
for (let sourceIndex = 0; sourceIndex < sourceMessages.length; sourceIndex++) {

‎tests/gateway.test.js‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,41 @@ describe("format translation", () => {
913913
assert.equal(body.messages[2].content[0].tool_use_id, "call_1");
914914
});
915915

916+
it("groups parallel tool results and supplemental images into one Anthropic user message", () => {
917+
const body = claude.toAnthropicBody(
918+
{
919+
messages: [
920+
{ role: "user", content: "inspect both images" },
921+
{
922+
role: "assistant",
923+
content: null,
924+
tool_calls: [
925+
{ id: "call_a", type: "function", function: { name: "view_image", arguments: '{"path":"a.png"}' } },
926+
{ id: "call_b", type: "function", function: { name: "view_image", arguments: '{"path":"b.png"}' } },
927+
{ id: "call_c", type: "function", function: { name: "run_command", arguments: '{"command":"echo ok"}' } },
928+
],
929+
},
930+
{ role: "tool", tool_call_id: "call_a", content: "viewed a" },
931+
{ role: "user", content: [{ type: "text", text: "image a" }, { type: "image_url", image_url: { url: "data:image/png;base64,YQ==" } }] },
932+
{ role: "tool", tool_call_id: "call_b", content: "viewed b" },
933+
{ role: "user", content: [{ type: "text", text: "image b" }, { type: "image_url", image_url: { url: "data:image/png;base64,Yg==" } }] },
934+
{ role: "tool", tool_call_id: "call_c", content: "ok" },
935+
],
936+
max_tokens: 64,
937+
},
938+
"claude-opus-5-5",
939+
false
940+
);
941+
942+
assert.deepEqual(body.messages.map((message) => message.role), ["user", "assistant", "user"]);
943+
assert.deepEqual(
944+
body.messages[2].content.filter((block) => block.type === "tool_result").map((block) => block.tool_use_id),
945+
["call_a", "call_b", "call_c"]
946+
);
947+
assert.deepEqual(body.messages[2].content.slice(0, 3).map((block) => block.type), ["tool_result", "tool_result", "tool_result"]);
948+
assert.equal(body.messages[2].content.filter((block) => block.type === "image").length, 2);
949+
});
950+
916951
it("anthropic tool_use → openai tool_calls", () => {
917952
const out = claude.fromAnthropicJson(
918953
{

0 commit comments

Comments
 (0)