mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-22 19:57:07 +00:00
fix: add tool_use/tool_result adjacency guard for Claude API
Claude requires tool_result in the IMMEDIATELY next message after tool_use. Existing fixToolPairs() checks global ID presence but not adjacency — keeping tool_use blocks whose tool_result exists later in the array but not in the next message, causing 400 errors. Add fixToolAdjacency() that runs after fixToolPairs() to remove tool_use blocks where the next message has no matching tool_result. Pipeline: fixToolPairs → fixToolAdjacency → stripTrailingAssistantOrphanToolUse Closes #2382 Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
This commit is contained in:
parent
eb83eaf25f
commit
44d9abac96
4 changed files with 219 additions and 4 deletions
|
|
@ -14,7 +14,11 @@ import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestD
|
|||
import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts";
|
||||
import { applySystemTransformPipeline, PROVIDER_CLAUDE } from "../services/systemTransforms.ts";
|
||||
import { fixToolPairs, stripTrailingAssistantOrphanToolUse } from "../services/contextManager.ts";
|
||||
import {
|
||||
fixToolPairs,
|
||||
fixToolAdjacency,
|
||||
stripTrailingAssistantOrphanToolUse,
|
||||
} from "../services/contextManager.ts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CLAUDE_CODE_VERSION,
|
||||
|
|
@ -874,7 +878,8 @@ export class BaseExecutor {
|
|||
const tb = transformedBody as Record<string, unknown>;
|
||||
if (Array.isArray(tb?.messages)) {
|
||||
const fixed = fixToolPairs(tb.messages as Record<string, unknown>[]);
|
||||
tb.messages = stripTrailingAssistantOrphanToolUse(fixed);
|
||||
const adjacent = fixToolAdjacency(fixed);
|
||||
tb.messages = stripTrailingAssistantOrphanToolUse(adjacent);
|
||||
}
|
||||
}
|
||||
let bodyString = JSON.stringify(transformedBody);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import {
|
|||
} from "./claudeCodeConstraints.ts";
|
||||
import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
|
||||
import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts";
|
||||
import { fixToolPairs, stripTrailingAssistantOrphanToolUse } from "./contextManager.ts";
|
||||
import {
|
||||
fixToolPairs,
|
||||
fixToolAdjacency,
|
||||
stripTrailingAssistantOrphanToolUse,
|
||||
} from "./contextManager.ts";
|
||||
|
||||
/**
|
||||
* `anthropic-compatible-cc-*` targets Anthropic relay gateways that only accept
|
||||
|
|
@ -373,7 +377,8 @@ export async function buildAndSignClaudeCodeRequest(
|
|||
const b = body as Record<string, unknown>;
|
||||
if (Array.isArray(b.messages)) {
|
||||
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(fixed);
|
||||
const adjacent = fixToolAdjacency(fixed);
|
||||
b.messages = stripTrailingAssistantOrphanToolUse(adjacent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -401,6 +401,69 @@ export function fixToolPairs(messages: Record<string, unknown>[]) {
|
|||
.filter(Boolean) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjacency guard: Claude requires `tool_result` in the IMMEDIATELY NEXT
|
||||
* message after `tool_use`, not just somewhere later in the array.
|
||||
*
|
||||
* `fixToolPairs` checks global ID presence but not adjacency. This function
|
||||
* runs after `fixToolPairs` and removes `tool_use` blocks from assistant
|
||||
* messages where the next message does not contain a matching `tool_result`.
|
||||
*/
|
||||
export function fixToolAdjacency(messages: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
if (messages.length <= 1) return messages;
|
||||
|
||||
const result: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const nextMsg = messages[i + 1];
|
||||
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content) && nextMsg) {
|
||||
// Collect tool_result IDs from the NEXT message
|
||||
const nextToolResultIds = new Set<string>();
|
||||
if (nextMsg.role === "tool" && nextMsg.tool_call_id) {
|
||||
nextToolResultIds.add(String(nextMsg.tool_call_id));
|
||||
}
|
||||
if (nextMsg.role === "user" && Array.isArray(nextMsg.content)) {
|
||||
for (const block of nextMsg.content as Record<string, unknown>[]) {
|
||||
if (block.type === "tool_result" && block.tool_use_id) {
|
||||
nextToolResultIds.add(String(block.tool_use_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tool_use blocks: only keep if next message has matching tool_result
|
||||
const filteredContent = (msg.content as Record<string, unknown>[]).filter(
|
||||
(block) => block.type !== "tool_use" || !block.id || nextToolResultIds.has(String(block.id))
|
||||
);
|
||||
|
||||
if (filteredContent.length !== (msg.content as unknown[]).length) {
|
||||
// Also filter tool_calls array if present
|
||||
const newMsg: Record<string, unknown> = { ...msg, content: filteredContent };
|
||||
if (Array.isArray(newMsg.tool_calls)) {
|
||||
newMsg.tool_calls = (newMsg.tool_calls as Record<string, unknown>[]).filter(
|
||||
(tc: Record<string, unknown>) => !tc.id || nextToolResultIds.has(String(tc.id))
|
||||
);
|
||||
}
|
||||
// Drop assistant message if it became empty
|
||||
const hasContent =
|
||||
typeof newMsg.content === "string"
|
||||
? (newMsg.content as string).trim().length > 0
|
||||
: Array.isArray(newMsg.content) && (newMsg.content as unknown[]).length > 0;
|
||||
const hasToolCalls = Array.isArray(newMsg.tool_calls) && newMsg.tool_calls.length > 0;
|
||||
if (!hasContent && !hasToolCalls) continue;
|
||||
result.push(newMsg);
|
||||
} else {
|
||||
result.push(msg);
|
||||
}
|
||||
} else {
|
||||
result.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream-send guard: after `fixToolPairs`, strip a trailing assistant
|
||||
* message whose only/remaining content is an orphan `tool_use` block.
|
||||
|
|
|
|||
142
tests/unit/fix-tool-adjacency.test.ts
Normal file
142
tests/unit/fix-tool-adjacency.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { fixToolPairs, fixToolAdjacency, stripTrailingAssistantOrphanToolUse } =
|
||||
await import("../../open-sse/services/contextManager.ts");
|
||||
|
||||
// ─── fixToolAdjacency ───────────────────────────────────────────────────────
|
||||
|
||||
test("fixToolAdjacency: removes tool_use when next message has no matching tool_result", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "let me check" },
|
||||
{ type: "tool_use", id: "toolu_abc", name: "search", input: {} },
|
||||
],
|
||||
},
|
||||
// Next message is user with tool_result for DIFFERENT id
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_xyz", content: "result for xyz" }],
|
||||
},
|
||||
];
|
||||
|
||||
const fixed = fixToolAdjacency(messages);
|
||||
// toolu_abc should be removed because next message doesn't have tool_result for it
|
||||
const assistantContent = fixed[1].content;
|
||||
const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use");
|
||||
assert.equal(toolUseBlocks.length, 0);
|
||||
assert.equal(assistantContent.length, 1); // only text remains
|
||||
});
|
||||
|
||||
test("fixToolAdjacency: keeps tool_use when next message has matching tool_result", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "let me check" },
|
||||
{ type: "tool_use", id: "toolu_abc", name: "search", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_abc", content: "found it" }],
|
||||
},
|
||||
];
|
||||
|
||||
const fixed = fixToolAdjacency(messages);
|
||||
const assistantContent = fixed[1].content;
|
||||
const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use");
|
||||
assert.equal(toolUseBlocks.length, 1);
|
||||
assert.equal(toolUseBlocks[0].id, "toolu_abc");
|
||||
});
|
||||
|
||||
test("fixToolAdjacency: drops empty assistant message after removing orphan tool_use", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_abc", name: "search", input: {} }],
|
||||
},
|
||||
// Next message has no tool_result at all
|
||||
{ role: "user", content: "what's up?" },
|
||||
];
|
||||
|
||||
const fixed = fixToolAdjacency(messages);
|
||||
// assistant message should be dropped since it only had orphan tool_use
|
||||
assert.equal(fixed.length, 2);
|
||||
assert.equal(fixed[0].role, "user");
|
||||
assert.equal(fixed[1].role, "user");
|
||||
});
|
||||
|
||||
test("fixToolAdjacency: handles OpenAI format tool role messages", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_abc", name: "search", input: {} }],
|
||||
},
|
||||
// OpenAI format: role=tool with tool_call_id
|
||||
{ role: "tool", tool_call_id: "toolu_abc", content: "found it" },
|
||||
];
|
||||
|
||||
const fixed = fixToolAdjacency(messages);
|
||||
const assistantContent = fixed[1].content;
|
||||
const toolUseBlocks = assistantContent.filter((b) => b.type === "tool_use");
|
||||
assert.equal(toolUseBlocks.length, 1); // kept because next message matches
|
||||
});
|
||||
|
||||
test("fixToolAdjacency: reproduces the exact bug - messages.26 orphan", () => {
|
||||
// Exact scenario: fixToolPairs keeps tool_use because result ID exists globally,
|
||||
// but tool_result is NOT in the immediately next message.
|
||||
//
|
||||
// setup: assistant(tool_use:abc) → user(text) → user(tool_result:abc)
|
||||
// fixToolPairs: keeps tool_use (ID exists in toolResultIds from msg[3])
|
||||
// Claude rejects: msg[2] has no tool_result for abc (adjacency violation)
|
||||
const messages = [
|
||||
{ role: "user", content: "do something" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tooluse_hX96f1h1ZrkVoLpLI0szxn",
|
||||
name: "bash",
|
||||
input: { command: "ls" },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Next message is plain user text — no tool_result for the tool_use above
|
||||
{ role: "user", content: "what's next?" },
|
||||
// Later message has the matching tool_result (not adjacent!)
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tooluse_hX96f1h1ZrkVoLpLI0szxn",
|
||||
content: "ls output",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// fixToolPairs keeps it (global check: ID exists in toolResultIds from msg[3])
|
||||
const pairsFixed = fixToolPairs(messages);
|
||||
const pairsAssistant = pairsFixed[1];
|
||||
const pairsToolUse = Array.isArray(pairsAssistant.content)
|
||||
? (pairsAssistant.content as any[]).filter((b: any) => b.type === "tool_use")
|
||||
: [];
|
||||
assert.equal(pairsToolUse.length, 1, "fixToolPairs keeps orphan (global check)");
|
||||
|
||||
// fixToolAdjacency removes it (adjacency check: next msg has no matching tool_result)
|
||||
const adjacencyFixed = fixToolAdjacency(pairsFixed);
|
||||
const adjacencyAssistant = adjacencyFixed[1];
|
||||
const adjacencyToolUse = Array.isArray(adjacencyAssistant.content)
|
||||
? (adjacencyAssistant.content as any[]).filter((b: any) => b.type === "tool_use")
|
||||
: [];
|
||||
assert.equal(adjacencyToolUse.length, 0, "fixToolAdjacency removes orphan (adjacency check)");
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue