Skip to content

Commit b8c12d7

Browse files
zzstoatzzclaude
andauthored
Fix IndexError in streaming when ToolCallPartDelta is present (#1208)
* Fix IndexError in streaming when ToolCallPartDelta is present When pydantic-ai streams incomplete tool calls (ToolCallPartDelta), the event.index refers to the position in the internal _parts list, but get_parts() filters out these incomplete parts. This causes an IndexError when trying to access get_parts()[index]. The fix uses the internal _parts list directly for snapshots, since that's what the event indices refer to. Fixes #1207 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add defensive check for None snapshot when accessing tool_call_id --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d36853a commit b8c12d7

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

src/marvin/engine/streaming.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,14 @@ def _process_pydantic_event(
189189
tools_map: dict[str, Callable[..., Any]],
190190
end_turn_tools_map: dict[str, EndTurn],
191191
) -> Event | None:
192-
def _get_snapshot(index: int) -> ModelResponsePart:
193-
return parts_manager.get_parts()[index]
192+
def _get_snapshot(index: int) -> ModelResponsePart | None:
193+
# Use the internal _parts list directly since event.index refers to that.
194+
# get_parts() filters out ToolCallPartDelta objects which causes index mismatch.
195+
# Note: _parts can contain both ModelResponsePart and ToolCallPartDelta
196+
if index < len(parts_manager._parts):
197+
return parts_manager._parts[index]
198+
# This should never happen with valid events from pydantic-ai
199+
return None
194200

195201
# Handle Part Start Events
196202
if isinstance(event, PartStartEvent):
@@ -227,7 +233,7 @@ def _get_snapshot(index: int) -> ModelResponsePart:
227233
tool_call_id=event.part.tool_call_id,
228234
),
229235
snapshot=snapshot,
230-
tool_call_id=snapshot.tool_call_id,
236+
tool_call_id=snapshot.tool_call_id if snapshot else None,
231237
tool=tools_map.get(event.part.tool_name),
232238
)
233239
# Handle Part Delta Events
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for the streaming module."""
2+
3+
from pydantic_ai._parts_manager import ModelResponsePartsManager
4+
from pydantic_ai.messages import PartDeltaEvent, ToolCallPartDelta
5+
6+
from marvin import Agent
7+
from marvin.engine.streaming import _process_pydantic_event
8+
9+
10+
def test_get_snapshot_with_incomplete_tool_call():
11+
"""Test that _get_snapshot handles ToolCallPartDelta correctly.
12+
13+
This tests the fix for issue #1207 where accessing get_parts()[index]
14+
would raise IndexError when the part at that index is a ToolCallPartDelta
15+
that gets filtered out by get_parts().
16+
"""
17+
# Setup
18+
parts_manager = ModelResponsePartsManager()
19+
actor = Agent(name="test")
20+
tools_map = {}
21+
end_turn_tools_map = {}
22+
23+
# Create an incomplete tool call (ToolCallPartDelta with no tool name)
24+
# This simulates what happens when streaming starts a tool call
25+
parts_manager.handle_tool_call_delta(
26+
vendor_part_id=0,
27+
tool_name=None, # No tool name yet - creates ToolCallPartDelta
28+
args="",
29+
tool_call_id=None,
30+
)
31+
32+
# Verify the setup - we should have a ToolCallPartDelta in _parts
33+
# but get_parts() should return empty
34+
assert len(parts_manager._parts) == 1
35+
assert len(parts_manager.get_parts()) == 0
36+
37+
# Create a PartDeltaEvent that references index 0
38+
event = PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta="{"))
39+
40+
# Process the event - this should NOT raise IndexError
41+
# Before the fix, this would fail with "list index out of range"
42+
result = _process_pydantic_event(
43+
event=event,
44+
actor=actor,
45+
parts_manager=parts_manager,
46+
tools_map=tools_map,
47+
end_turn_tools_map=end_turn_tools_map,
48+
)
49+
50+
# The result should be a ToolCallDeltaEvent with the snapshot
51+
assert result is not None
52+
assert hasattr(result, "snapshot")
53+
# The snapshot should be the ToolCallPartDelta from _parts[0]
54+
assert result.snapshot == parts_manager._parts[0]

0 commit comments

Comments
 (0)