Skip to content

Commit 18a0f10

Browse files
chenaoxdmoonbox3
authored andcommitted
fix(anthropic): fix duplicate ToolCallStartEvent in streaming tool calls (microsoft#3051)
When processing `input_json_delta` events, the Anthropic client was passing the tool name from the previous `tool_use` event. This caused ag-ui's `_handle_function_call_content` to emit a `ToolCallStartEvent` for every streaming chunk (since it triggers on `if content.name:`). This fix changes the behavior to pass an empty string for `name` in `input_json_delta` events, matching OpenAI's behavior where streaming argument chunks have `name=""`. The initial `tool_use` event still provides the tool name, so only one `ToolCallStartEvent` is emitted. Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
1 parent c82feed commit 18a0f10

3 files changed

Lines changed: 90 additions & 2 deletions

File tree

python/packages/ag-ui/tests/test_events_comprehensive.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,42 @@ async def test_tool_call_streaming_args():
152152
assert events1[0].tool_call_id == events2[0].tool_call_id == events3[0].tool_call_id
153153

154154

155+
async def test_streaming_tool_call_no_duplicate_start_events():
156+
"""Test that streaming tool calls emit exactly one ToolCallStartEvent.
157+
158+
This is a regression test for the Anthropic streaming fix where input_json_delta
159+
events were incorrectly passing the tool name, causing duplicate ToolCallStartEvents.
160+
161+
The correct behavior is:
162+
- Initial FunctionCallContent with name -> emits ToolCallStartEvent
163+
- Subsequent FunctionCallContent with name="" -> emits only ToolCallArgsEvent
164+
165+
See: https://github.com/microsoft/agent-framework/pull/3051
166+
"""
167+
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
168+
169+
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
170+
171+
# Simulate streaming tool call: first chunk has name, subsequent chunks have name=""
172+
update1 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="get_weather", call_id="call_789")])
173+
update2 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='{"loc":')])
174+
update3 = AgentRunResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='"SF"}')])
175+
176+
events1 = await bridge.from_agent_run_update(update1)
177+
events2 = await bridge.from_agent_run_update(update2)
178+
events3 = await bridge.from_agent_run_update(update3)
179+
180+
# Count all ToolCallStartEvents - should be exactly 1
181+
all_events = events1 + events2 + events3
182+
tool_call_start_count = sum(1 for e in all_events if e.type == "TOOL_CALL_START")
183+
assert tool_call_start_count == 1, f"Expected 1 ToolCallStartEvent, got {tool_call_start_count}"
184+
185+
# Verify event types
186+
assert events1[0].type == "TOOL_CALL_START"
187+
assert events2[0].type == "TOOL_CALL_ARGS"
188+
assert events3[0].type == "TOOL_CALL_ARGS"
189+
190+
155191
async def test_tool_result_with_dict():
156192
"""Test FunctionResultContent with dict result."""
157193
from agent_framework_ag_ui._events import AgentFrameworkEventBridge

python/packages/anthropic/agent_framework_anthropic/_chat_client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -832,11 +832,16 @@ def _parse_contents_from_anthropic(
832832
)
833833
)
834834
case "input_json_delta":
835-
call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "")
835+
# For streaming argument deltas, only pass call_id and arguments.
836+
# Pass empty string for name - it causes ag-ui to emit duplicate ToolCallStartEvents
837+
# since it triggers on `if content.name:`. The initial tool_use event already
838+
# provides the name, so deltas should only carry incremental arguments.
839+
# This matches OpenAI's behavior where streaming chunks have name="".
840+
call_id, _ = self._last_call_id_name if self._last_call_id_name else ("", "")
836841
contents.append(
837842
FunctionCallContent(
838843
call_id=call_id,
839-
name=name,
844+
name="",
840845
arguments=content_block.partial_json,
841846
raw_representation=content_block,
842847
)

python/packages/anthropic/tests/test_anthropic_client.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,53 @@ def test_parse_contents_from_anthropic_tool_use(mock_anthropic_client: MagicMock
595595
assert result[0].name == "get_weather"
596596

597597

598+
def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name(mock_anthropic_client: MagicMock) -> None:
599+
"""Test that input_json_delta events have empty name to prevent duplicate ToolCallStartEvents.
600+
601+
When streaming tool calls, the initial tool_use event provides the name,
602+
and subsequent input_json_delta events should have name="" to prevent
603+
ag-ui from emitting duplicate ToolCallStartEvents.
604+
"""
605+
chat_client = create_test_anthropic_client(mock_anthropic_client)
606+
607+
# First, simulate a tool_use event that sets _last_call_id_name
608+
tool_use_content = MagicMock()
609+
tool_use_content.type = "tool_use"
610+
tool_use_content.id = "call_123"
611+
tool_use_content.name = "get_weather"
612+
tool_use_content.input = {}
613+
614+
result = chat_client._parse_contents_from_anthropic([tool_use_content])
615+
assert len(result) == 1
616+
assert isinstance(result[0], FunctionCallContent)
617+
assert result[0].call_id == "call_123"
618+
assert result[0].name == "get_weather" # Initial event has name
619+
620+
# Now simulate input_json_delta events (argument streaming)
621+
delta_content_1 = MagicMock()
622+
delta_content_1.type = "input_json_delta"
623+
delta_content_1.partial_json = '{"location":'
624+
625+
result = chat_client._parse_contents_from_anthropic([delta_content_1])
626+
assert len(result) == 1
627+
assert isinstance(result[0], FunctionCallContent)
628+
assert result[0].call_id == "call_123"
629+
assert result[0].name == "" # Delta events should have empty name
630+
assert result[0].arguments == '{"location":'
631+
632+
# Another delta
633+
delta_content_2 = MagicMock()
634+
delta_content_2.type = "input_json_delta"
635+
delta_content_2.partial_json = '"San Francisco"}'
636+
637+
result = chat_client._parse_contents_from_anthropic([delta_content_2])
638+
assert len(result) == 1
639+
assert isinstance(result[0], FunctionCallContent)
640+
assert result[0].call_id == "call_123"
641+
assert result[0].name == "" # Still empty name for subsequent deltas
642+
assert result[0].arguments == '"San Francisco"}'
643+
644+
598645
# Stream Processing Tests
599646

600647

0 commit comments

Comments
 (0)