Repository navigation
fix(claude): replay Anthropic server-tool exchanges on the translated lane (carry #7205) #7251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bcd98cd
ca29529
ecda97d
c77fcfb
bc0df7e
50d6815
d067a99
be851db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,6 +87,72 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { | |
| return tools.length > 0 ? tools : undefined; | ||
| } | ||
|
|
||
| /** Validate original call/result order without inventing inputs or pairing a later call backwards. */ | ||
| function replayableServerRun(run: readonly Rec[]): boolean { | ||
| const pending = new Map<string, Rec>(); | ||
| const completed = new Set<string>(); | ||
| for (const block of run) { | ||
| if (block.type === "server_tool_use") { | ||
| const { id, name, input } = block; | ||
| if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id) | ||
| || (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false; | ||
| const prototype = Object.getPrototypeOf(input); | ||
| const required = input[name === "web_search" ? "query" : "url"]; | ||
| if ((prototype !== Object.prototype && prototype !== null) | ||
| || typeof required !== "string" || required.trim().length === 0) return false; | ||
| pending.set(id, block); | ||
| } else { | ||
| const id = block.tool_use_id; | ||
| if (typeof id !== "string") return false; | ||
| const call = pending.get(id); | ||
| if (!call || block.type !== `${call.name}_tool_result` | ||
| || (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Inspect replay insertion and any result-content validation before the Anthropic wire.
rg -n -C 6 'messagesToAnthropicFormat|_anthropicServerReplay|web_search_tool_result|web_fetch_tool_result' \
src/adapters/anthropic.ts src/server/claude-messages.ts src/claude/inbound.tsRepository: lidge-jun/opencodex Length of output: 4795 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- inbound-content-options.ts: replay validator and collector ---'
nl -ba src/claude/inbound-content-options.ts | sed -n '1,230p'
printf '%s\n' '--- anthropic.ts: formatter and replay insertion ---'
nl -ba src/adapters/anthropic.ts | sed -n '735,880p'
printf '%s\n' '--- anthropic.ts: request construction and serialization ---'
nl -ba src/adapters/anthropic.ts | sed -n '990,1055p'
printf '%s\n' '--- relevant result/content handling references ---'
rg -n -F --glob '*.ts' -e 'web_search_tool_result' -e 'web_fetch_tool_result' -e '_anthropicServerReplay' -e 'messagesToAnthropicFormat' src tests || test "$?" -eq 1Repository: lidge-jun/opencodex Length of output: 33281 🌐 Web query:
💡 Result: Reject malformed result content before accepting a replay run. When a matching result has Validate each result type’s content shape before accepting the replay run. Preserve valid error objects and empty search-result arrays. 🤖 Prompt for AI Agents |
||
| pending.delete(id); | ||
| completed.add(id); | ||
| } | ||
| } | ||
| return completed.size > 0 && pending.size === 0; | ||
| } | ||
|
|
||
| /** | ||
| * Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` / | ||
| * `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so | ||
| * an Anthropic destination re-inserts them out of body, keyed by the block that follows each run | ||
| * in its message: `call:<tool_use id>`, `text:<text>`, `thinking:<signature>` or | ||
| * `redacted:<data>`, all of which survive translation. An anchor that occurs more than once in the | ||
| * assistant history (string content included) cannot name one owner, so its run stays dropped, | ||
| * as it was before replay existed. A run with none, an unpaired call, a | ||
| * non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped. | ||
| */ | ||
| export function replayedAnthropicServerBlocks(raw: unknown): Map<string, Rec[]> | undefined { | ||
| if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined; | ||
| const out = new Map<string, Rec[]>(); | ||
| const seen = new Map<string, number>(); | ||
| const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1); | ||
| for (const message of raw.messages) { | ||
| if (!isRec(message) || message.role !== "assistant") continue; | ||
| if (typeof message.content === "string") count(`text:${message.content}`); | ||
| if (!Array.isArray(message.content)) continue; | ||
| let run: Rec[] = []; | ||
| for (const block of message.content.filter(isRec)) { | ||
| if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") { | ||
| // The caller's breakpoints are dropped on this lane, as for the declared tools above. | ||
| run.push({ ...block, cache_control: undefined }); | ||
| continue; | ||
| } | ||
| const anchor = block.type === "tool_use" ? `call:${String(block.id)}` | ||
| : block.type === "text" ? `text:${String(block.text)}` | ||
| : block.type === "thinking" ? `thinking:${String(block.signature)}` | ||
| : block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined; | ||
| if (anchor) count(anchor); | ||
| if (anchor && replayableServerRun(run)) out.set(anchor, run); | ||
| run = []; | ||
| } | ||
| } | ||
| for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor); | ||
| return out.size > 0 ? out : undefined; | ||
| } | ||
|
|
||
| export function toolChoiceToResponses(choice: unknown, body: Rec): void { | ||
| if (!isRec(choice)) return; | ||
| if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 13616
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 42720
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 42249
🌐 Web query:
Anthropic official web search tool server_tool_use caller field dynamic filtering web_search_tool_result tool_use_id schema💡 Result:
Preserve the streamed server-tool call block before emitting a pair.
serverSearchstores only the call ID and streamed input. The result path then rebuildsusefrom the result type, which drops fields such as Anthropic’s dynamic-filteringcaller. The same branch also accepts a same-ID result without checking that its tool type matches the original call.Retain the original call block, require the call name to match the result type, and override only its streamed
input.🐛 Suggested fix
🤖 Prompt for AI Agents