fix(anthropic): handle server-side tool blocks without panicking#1750
Conversation
02dedda to
6dc895d
Compare
Greptile SummaryThis PR fixes a nil-pointer dereference panic in the Anthropic outbound stream transformer triggered when Claude uses server-side tools (
Confidence Score: 5/5Safe to merge; the nil-pointer panic is correctly fixed in both streaming and non-streaming paths, and the server-tool round-trip is covered by comprehensive new tests. The primary change — replacing the exact The Important Files Changed
Sequence DiagramsequenceDiagram
participant A as Anthropic API
participant OS as OutboundStream
participant LLM as llm.Response
participant IS as InboundStream
participant C as Client
A->>OS: content_block_start(server_tool_use)
OS->>LLM: "ToolCall{id, name, anthropic_type=server_tool_use}"
A->>OS: input_json_delta x N
OS->>LLM: "ToolCall{partial args} x N"
A->>OS: content_block_stop (filtered/dropped)
A->>OS: content_block_start(web_search_tool_result)
OS->>LLM: "InlineToolResult{tool_call_id, content Raw bytes}"
A->>OS: content_block_start(text) + text_delta x N
OS->>LLM: text content x N
LLM->>IS: ToolCall chunks
IS->>C: content_block_start(server_tool_use) + input_json_delta
LLM->>IS: InlineToolResult chunk
IS->>C: content_block_stop + content_block_start(web_search_tool_result) + content_block_stop
LLM->>IS: text chunks
IS->>C: content_block_start(text) + text_delta x N
Reviews (2): Last reviewed commit: "fix(anthropic): handle server-side tool ..." | Re-trigger Greptile |
| err := json.Unmarshal(data, &blocks) | ||
| if err == nil { | ||
| c.MultipleContent = blocks | ||
| // Preserve the original bytes so unknown nested fields (e.g. | ||
| // web_search_result's url/title/encrypted_content) survive a | ||
| // round-trip via MarshalJSON. | ||
| c.Raw = append(c.Raw[:0], data...) | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Raw set unconditionally creates a silent mutation hazard
UnmarshalJSON now records Raw for every array-valued MessageContent. Because MarshalJSON checks if len(c.Raw) > 0 first, any modification to MultipleContent (or Content) after deserialization is silently ignored when the object is marshalled. There is no mechanism to invalidate Raw when the struct fields change.
For current code paths the in-memory structs are read-only after deserialization, so this is not immediately broken. However, it is an invisible footgun: a future caller that deserializes a MessageContent, appends a block, and re-marshals it will get the original bytes back with no error or warning. A safer approach is to restrict Raw to the specific content types that need byte-identical round-tripping (e.g. only set it when the block is a *_tool_result), or clear Raw whenever Content or MultipleContent is assigned.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Acknowledged. This is an intentional trade-off: Raw exists specifically so that *_tool_result content blocks (which contain fields like url, title, encrypted_content not modeled on MessageContentBlock) can survive a byte-identical round-trip through the pipeline.
In practice, all code paths that receive a deserialized MessageContent treat it as read-only — the struct is only built for forwarding, never mutated post-unmarshal. Adding a ClearRaw() invalidation hook or scoping Raw to specific block types would add complexity for a scenario that doesn't arise today.
If a future caller needs to mutate and re-serialize, they would need to call SetRaw(nil) explicitly — but that's a bridge to cross when we get there. Happy to revisit if the maintainers prefer defensive invalidation.
Root cause: outbound_stream.go only recognized plain "tool_use" content blocks, so an Anthropic-native server_tool_use (e.g. web_search) followed by input_json_delta chunks hit a nil map entry and panicked at outbound_stream.go:216. Changes: - Treat any *_tool_use content block as tool-use-like (plain tool_use stays vanilla; anything with a suffix is tagged with anthropic_type + optional anthropic_caller in ToolCall.TransformerMetadata so the block round-trips byte-compatibly). - Treat any *_tool_result content block as tool-result-like. Special results now travel on the assistant llm.Message via a new InlineToolResults slice, carrying original block type, caller, and raw content bytes through TransformerMetadata. - outbound_stream: nil-guard input_json_delta, emit inline tool results as assistant deltas. - aggregator / outbound_convert / inbound_convert / frontend response parser: extend matching so *_tool_use suffix variants all participate in the existing tool-call flow. - Preserve content-block ordering: tag each text / tool_use / tool_result with its original Anthropic block index via anthropic_block_index, and emit in that order on the Anthropic side so server-tool calls stay before the assistant text that narrates them. - inbound_stream: rebuild *_tool_use block type / caller from metadata and emit InlineToolResults as content_block_start + content_block_stop pairs, closing any open text/tool block first. - Add MessageContent.Raw escape hatch populated on both UnmarshalJSON and the round-trip helpers so unknown nested fields (e.g. web_search_result url / title / encrypted_content) survive byte-identical. - Tests: replay production SSE for no-panic assertion; new dedicated round-trip tests for the non-streaming and streaming paths assert block ordering (tool use/result precede assistant text) and wire-level preservation of nested web_search_result fields. Spec: specs/20260420-fix-server-tool-use-panic Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6dc895d to
65e2e97
Compare
|
Thanks for the review! The shadowing issue you identified in
All tests pass, including |
…plj#1750) Root cause: outbound_stream.go only recognized plain "tool_use" content blocks, so an Anthropic-native server_tool_use (e.g. web_search) followed by input_json_delta chunks hit a nil map entry and panicked at outbound_stream.go:216. Changes: - Treat any *_tool_use content block as tool-use-like (plain tool_use stays vanilla; anything with a suffix is tagged with anthropic_type + optional anthropic_caller in ToolCall.TransformerMetadata so the block round-trips byte-compatibly). - Treat any *_tool_result content block as tool-result-like. Special results now travel on the assistant llm.Message via a new InlineToolResults slice, carrying original block type, caller, and raw content bytes through TransformerMetadata. - outbound_stream: nil-guard input_json_delta, emit inline tool results as assistant deltas. - aggregator / outbound_convert / inbound_convert / frontend response parser: extend matching so *_tool_use suffix variants all participate in the existing tool-call flow. - Preserve content-block ordering: tag each text / tool_use / tool_result with its original Anthropic block index via anthropic_block_index, and emit in that order on the Anthropic side so server-tool calls stay before the assistant text that narrates them. - inbound_stream: rebuild *_tool_use block type / caller from metadata and emit InlineToolResults as content_block_start + content_block_stop pairs, closing any open text/tool block first. - Add MessageContent.Raw escape hatch populated on both UnmarshalJSON and the round-trip helpers so unknown nested fields (e.g. web_search_result url / title / encrypted_content) survive byte-identical. - Tests: replay production SSE for no-panic assertion; new dedicated round-trip tests for the non-streaming and streaming paths assert block ordering (tool use/result precede assistant text) and wire-level preservation of nested web_search_result fields. Spec: specs/20260420-fix-server-tool-use-panic Co-authored-by: Yeechan Lu <git@orzfly.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Fix a nil pointer dereference panic in the Anthropic outbound stream transformer when Claude uses server-side tools (e.g.
web_search,code_execution).Problem
When the Anthropic API returns
server_tool_useorweb_search_tool_resultcontent blocks (triggered by features like Claude's web search), AxonHub panics with:The root cause is that
outbound_stream.goonly matched"tool_use"exactly for content block start events. Whenserver_tool_usearrived, it was not tracked — so subsequentinput_json_deltaevents attempted to index into an empty/nil tool call slice.Fix
== "tool_use"check withisAnthropicToolUseLike()that matches any*_tool_usesuffix (coversserver_tool_use,mcp_tool_use, etc.)input_json_deltahandler before accessingstate.toolCalls*_tool_resultcontent blocks (web_search_tool_result,code_execution_tool_result) in both streaming and non-streaming paths