fix(claude): drop orphan tool_result after fixToolAdjacency strip (discussion #2410)

Discussion #2410 reports Claude returning 400 for sequences like:
  assistant: tool_use(id=X)
  user: <plain text>           ← breaks adjacency
  user: tool_result(id=X)

The previous round added `fixToolAdjacency` (commit 44d9abac9) which
correctly strips the orphan tool_use from the assistant message. But
that left the now-unmatched tool_result intact, so the upstream
rejected the request with:

  messages.N.content.M: unexpected `tool_use_id` found in `tool_result`
  blocks: X. Each tool_result block must have a corresponding tool_use
  block in the previous message.

Fix: after running `fixToolAdjacency`, re-run `fixToolPairs` to drop
the orphaned tool_result blocks. All three call sites updated:
  - contextManager.purifyHistory (both inside the binary-search loop
    and the final pass)
  - BaseExecutor message-prep (Claude path)
  - claudeCodeCompatible request signer

Also tightens an unrelated dynamic-key access in
readNestedString (claudeCodeCompatible) to satisfy the prototype-
pollution scanner triggered by the post-tool semgrep hook.
This commit is contained in:
diegosouzapw
2026-05-20 00:57:10 -03:00
parent 3ff3e3dd15
commit fbf37ae0da
3 changed files with 18 additions and 3 deletions

View File

@@ -894,7 +894,10 @@ export class BaseExecutor {
// Only apply for Claude/Claude-compatible — OpenAI allows results
// spread across multiple subsequent messages.
const isClaude = this.provider === "claude" || isClaudeCodeCompatible(this.provider);
const adjacent = isClaude ? fixToolAdjacency(fixed) : fixed;
// For Claude, fixToolAdjacency may strip tool_use blocks whose
// tool_result isn't in the next message; re-run fixToolPairs to
// drop any tool_result orphaned by that strip (discussion #2410).
const adjacent = isClaude ? fixToolPairs(fixToolAdjacency(fixed)) : fixed;
tb.messages = stripTrailingAssistantOrphanToolUse(adjacent);
}
}

View File

@@ -378,7 +378,11 @@ export async function buildAndSignClaudeCodeRequest(
if (Array.isArray(b.messages)) {
const fixed = fixToolPairs(b.messages as Record<string, unknown>[]);
const adjacent = fixToolAdjacency(fixed);
b.messages = stripTrailingAssistantOrphanToolUse(adjacent);
// fixToolAdjacency can leave orphan tool_result blocks behind when it
// strips a tool_use whose tool_result wasn't in the next message.
// Re-pair to drop those orphans (discussion #2410).
const cleaned = fixToolPairs(adjacent);
b.messages = stripTrailingAssistantOrphanToolUse(cleaned);
}
}
@@ -1158,7 +1162,9 @@ function readNestedString(
if (!current || typeof current !== "object" || Array.isArray(current)) {
return null;
}
current = (current as Record<string, unknown>)[key];
if (key === "__proto__" || key === "constructor" || key === "prototype") return null;
if (!Object.prototype.hasOwnProperty.call(current, key)) return null;
current = Reflect.get(current as object, key);
}
return toNonEmptyString(current);
}

View File

@@ -267,6 +267,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
let candidate = [...system, ...nonSystem.slice(-keep)];
candidate = fixToolPairs(candidate);
candidate = fixToolAdjacency(candidate);
// Re-run pair fix: fixToolAdjacency may have stripped tool_use blocks, leaving
// orphan tool_results that Claude rejects ("tool_result without preceding tool_use").
candidate = fixToolPairs(candidate);
candidate = stripTrailingAssistantOrphanToolUse(candidate);
const tokens = estimateTokens(JSON.stringify(candidate));
if (tokens <= targetTokens) break;
@@ -276,6 +279,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
let result = [...system, ...nonSystem.slice(-keep)];
result = fixToolPairs(result);
result = fixToolAdjacency(result);
// Re-run pair fix to drop any tool_result whose matching tool_use was removed by
// fixToolAdjacency (discussion #2410 — orphan tool_result -> upstream 400).
result = fixToolPairs(result);
result = stripTrailingAssistantOrphanToolUse(result);
// Add summary of dropped messages