From fbf37ae0daf3de05638787a718a796f03c834eb9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 20 May 2026 00:57:10 -0300 Subject: [PATCH] fix(claude): drop orphan tool_result after fixToolAdjacency strip (discussion #2410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discussion #2410 reports Claude returning 400 for sequences like: assistant: tool_use(id=X) user: ← 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. --- open-sse/executors/base.ts | 5 ++++- open-sse/services/claudeCodeCompatible.ts | 10 ++++++++-- open-sse/services/contextManager.ts | 6 ++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index c91bc71e1f..7e656f311a 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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); } } diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 1e41b03843..6b7bd98b04 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -378,7 +378,11 @@ export async function buildAndSignClaudeCodeRequest( if (Array.isArray(b.messages)) { const fixed = fixToolPairs(b.messages as Record[]); 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)[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); } diff --git a/open-sse/services/contextManager.ts b/open-sse/services/contextManager.ts index 8c203ddf48..c31215c7d8 100644 --- a/open-sse/services/contextManager.ts +++ b/open-sse/services/contextManager.ts @@ -267,6 +267,9 @@ function purifyHistory(messages: Record[], 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[], 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