Skip to content

Commit 053e734

Browse files
authored
fix(ai): keep Anthropic stream tool arguments after server tool blocks (#984)
Stream delta and stop events address content blocks by their index in the message. The accumulator looked them up in content_blocks, which skips block types it does not format (server_tool_use, web_search_tool_result, ...). After a web search, a client tool call's input_json_delta events pointed past the end of the list (arguments captured as {}) or at a different tool block. Keep a list that lines up with the stream index and use it for tool deltas and finalization. content_blocks and the captured output are unchanged. Co-authored-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
1 parent c2470c0 commit 053e734

3 files changed

Lines changed: 97 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Keep streamed Anthropic tool call arguments when the response also contains server tool blocks, such as web search.

‎posthog/ai/anthropic/_anthropic_stream.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ def __init__(self) -> None:
2121
self.tools_in_progress: Dict[str, ToolInProgress] = {}
2222
self.current_text_block: Optional[StreamingContentBlock] = None
2323
self.stop_reason: Optional[str] = None
24+
# Delta and stop events address blocks by their index in the message. That
25+
# index also counts blocks content_blocks leaves out (server_tool_use,
26+
# web_search_tool_result, ...), so keep a list that lines up with it.
27+
self._blocks_by_index: List[StreamingContentBlock] = []
2428

2529
def consume(self, event: Any) -> None:
2630
event_usage = extract_anthropic_usage_from_event(event)
@@ -29,6 +33,8 @@ def consume(self, event: Any) -> None:
2933
if getattr(event, "type", None) == "content_block_start":
3034
block, tool = handle_anthropic_content_block_start(event)
3135

36+
self._blocks_by_index.append(block or {})
37+
3238
if block:
3339
self.content_blocks.append(block)
3440
if block.get("type") in ("text", "thinking"):
@@ -45,12 +51,14 @@ def consume(self, event: Any) -> None:
4551
if delta_text:
4652
self.accumulated_content += delta_text
4753

48-
handle_anthropic_tool_delta(event, self.content_blocks, self.tools_in_progress)
54+
handle_anthropic_tool_delta(
55+
event, self._blocks_by_index, self.tools_in_progress
56+
)
4957

5058
if getattr(event, "type", None) == "content_block_stop":
5159
self.current_text_block = None
5260
finalize_anthropic_tool_input(
53-
event, self.content_blocks, self.tools_in_progress
61+
event, self._blocks_by_index, self.tools_in_progress
5462
)
5563

5664
if getattr(event, "type", None) == "message_delta":

‎posthog/test/ai/anthropic/test_anthropic.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,88 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
12121212
assert "output_tokens" in props["$ai_usage"]
12131213

12141214

1215+
def test_streaming_tool_call_after_server_tool_blocks(mock_client):
1216+
"""Stream events address content blocks by their index in the message. Blocks the
1217+
wrapper does not format (server_tool_use, web_search_tool_result) still take an
1218+
index, so a later client tool call must keep its streamed arguments."""
1219+
from anthropic.types import (
1220+
InputJSONDelta,
1221+
ServerToolUseBlock,
1222+
ToolUseBlock,
1223+
WebSearchToolResultBlock,
1224+
)
1225+
1226+
def content_block(index, block, deltas=()):
1227+
yield RawContentBlockStartEvent(
1228+
type="content_block_start", index=index, content_block=block
1229+
)
1230+
for delta in deltas:
1231+
yield RawContentBlockDeltaEvent(
1232+
type="content_block_delta", index=index, delta=delta
1233+
)
1234+
yield RawContentBlockStopEvent(type="content_block_stop", index=index)
1235+
1236+
def stream_generator():
1237+
yield from content_block(
1238+
0,
1239+
ServerToolUseBlock(
1240+
type="server_tool_use", id="srvtoolu_1", name="web_search", input={}
1241+
),
1242+
[
1243+
InputJSONDelta(
1244+
type="input_json_delta", partial_json='{"query": "weather sf"}'
1245+
)
1246+
],
1247+
)
1248+
yield from content_block(
1249+
1,
1250+
WebSearchToolResultBlock(
1251+
type="web_search_tool_result", tool_use_id="srvtoolu_1", content=[]
1252+
),
1253+
)
1254+
yield from content_block(
1255+
2,
1256+
TextBlock(type="text", text=""),
1257+
[TextDelta(type="text_delta", text="Booking a table.")],
1258+
)
1259+
yield from content_block(
1260+
3,
1261+
ToolUseBlock(type="tool_use", id="toolu_1", name="book_table", input={}),
1262+
[
1263+
InputJSONDelta(type="input_json_delta", partial_json='{"city": '),
1264+
InputJSONDelta(type="input_json_delta", partial_json='"SF"}'),
1265+
],
1266+
)
1267+
1268+
with patch(
1269+
"anthropic.resources.Messages.create",
1270+
return_value=stream_generator(),
1271+
):
1272+
client = Anthropic(api_key="test-key", posthog_client=mock_client)
1273+
response = client.messages.create(
1274+
model="claude-sonnet-4-20250514",
1275+
messages=[{"role": "user", "content": "Book dinner if it's sunny"}],
1276+
stream=True,
1277+
posthog_distinct_id="test-id",
1278+
)
1279+
list(response)
1280+
1281+
props = mock_client.capture.call_args[1]["properties"]
1282+
assert props["$ai_output_choices"] == [
1283+
{
1284+
"role": "assistant",
1285+
"content": [
1286+
{"type": "text", "text": "Booking a table."},
1287+
{
1288+
"type": "function",
1289+
"id": "toolu_1",
1290+
"function": {"name": "book_table", "arguments": {"city": "SF"}},
1291+
},
1292+
],
1293+
}
1294+
]
1295+
1296+
12151297
def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools):
12161298
"""Test that tool calls are properly captured in async streaming mode."""
12171299
import asyncio

0 commit comments

Comments
 (0)