Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 121 additions & 12 deletions src/agents/models/chatcmpl_stream_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def _open_thinking_block(self) -> dict[str, Any]:
class _BufferedToolCall:
"""Accumulates a streamed Chat Completions function tool call."""

index: int
index: int | None
call_id: str | None = None
name: str | None = None
arguments: str = ""
Expand Down Expand Up @@ -307,14 +307,67 @@ def _delta_has_passthrough_output(delta: ChoiceDelta | None) -> bool:

@staticmethod
def _accumulate_tool_call_delta(
buffered_calls: dict[int, _BufferedToolCall],
buffered_calls: dict[int | None, _BufferedToolCall],
tool_call_delta: ChoiceDeltaToolCall,
) -> None:
tool_call_index = tool_call_delta.index
function_name = tool_call_delta.function.name if tool_call_delta.function else None
matching_indexes = [
index
for index, buffered_call in buffered_calls.items()
if tool_call_delta.id and buffered_call.call_id == tool_call_delta.id
]

if isinstance(tool_call_index, int):
if matching_indexes and matching_indexes[0] != tool_call_index:
raise ModelBehaviorError(
"Chat Completions tool call index and ID identified different buffered calls."
)
elif matching_indexes:
tool_call_index = matching_indexes[0]
elif tool_call_delta.id:
if None in buffered_calls and buffered_calls[None].call_id:
raise ModelBehaviorError(
"Chat Completions tool call delta omitted an index for a new call while "
"another index-less call was being buffered."
)
tool_call_index = None
elif function_name:
if None in buffered_calls:
if buffered_calls[None].name:
raise ModelBehaviorError(
"Chat Completions tool call delta omitted both index and ID, so its "
"function-call owner could not be attributed safely."
)
elif any(call.name == function_name for call in buffered_calls.values()):
raise ModelBehaviorError(
"Chat Completions tool call delta omitted both index and ID, so its "
"function-call owner could not be attributed safely."
)
else:
tool_call_index = None
elif len(buffered_calls) == 1:
tool_call_index = next(iter(buffered_calls))
elif len(buffered_calls) > 1:
raise ModelBehaviorError(
"Chat Completions tool call delta omitted both index and ID while multiple "
"function calls were being buffered."
)

buffered_call = buffered_calls.setdefault(
tool_call_delta.index,
_BufferedToolCall(index=tool_call_delta.index),
tool_call_index,
_BufferedToolCall(index=tool_call_index),
)

if tool_call_delta.id and buffered_call.call_id not in (None, tool_call_delta.id):
raise ModelBehaviorError(
"Chat Completions tool call delta changed the ID of a buffered call."
)
if function_name and buffered_call.name not in (None, function_name):
raise ModelBehaviorError(
"Chat Completions tool call delta changed the name of a buffered function call."
)

if tool_call_delta.id:
buffered_call.call_id = tool_call_delta.id

Expand All @@ -341,6 +394,8 @@ def _accumulate_tool_call_delta(
@staticmethod
def _buffered_tool_call_delta(
buffered_call: _BufferedToolCall,
*,
fallback_index: int = 0,
) -> ChoiceDeltaToolCall:
if not buffered_call.call_id:
raise ModelBehaviorError(
Expand All @@ -353,7 +408,7 @@ def _buffered_tool_call_delta(
)

tool_call_delta = ChoiceDeltaToolCall(
index=buffered_call.index,
index=buffered_call.index if isinstance(buffered_call.index, int) else fallback_index,
id=buffered_call.call_id,
function=ChoiceDeltaToolCallFunction(
name=buffered_call.name,
Expand All @@ -374,11 +429,23 @@ def _buffered_tool_call_delta(
def _buffered_tool_calls_chunk(
cls,
template_chunk: ChatCompletionChunk,
buffered_calls: dict[int, _BufferedToolCall],
buffered_calls: dict[int | None, _BufferedToolCall],
passthrough_tool_call_indexes: set[int | None],
) -> ChatCompletionChunk:
ordered_calls = sorted(
buffered_calls.values(),
key=lambda call: (
not isinstance(call.index, int),
call.index if isinstance(call.index, int) else 0,
),
)
used_indexes = {
index for index in passthrough_tool_call_indexes if isinstance(index, int)
} | {call.index for call in ordered_calls if isinstance(call.index, int)}
fallback_index = max(used_indexes, default=-1) + 1
tool_call_deltas = [
cls._buffered_tool_call_delta(buffered_call)
for _, buffered_call in sorted(buffered_calls.items())
cls._buffered_tool_call_delta(buffered_call, fallback_index=fallback_index)
for buffered_call in ordered_calls
Comment on lines 446 to +448

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge unindexed continuation deltas with the active call

When a provider supplies index on the opening delta but omits it on a later arguments-only delta, accumulation creates separate entries for the numeric index and None. This replay processes them as separate calls, so the None entry lacks call_id and name and _buffered_tool_call_delta raises instead of returning the otherwise complete function call. This is an unreliable-chunk pattern covered by buffer_streamed_tool_calls; when exactly one active call makes the association unambiguous, merge the unindexed continuation into it and reserve rejection for ambiguous multi-call streams.

AGENTS.md reference: AGENTS.md:L147-L148

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9930941. An arguments-only unindexed continuation now merges into the existing unindexed slot or the sole active buffered function call. If multiple indexed calls make ownership ambiguous, buffering raises ModelBehaviorError before replay. I added regression tests for both the unambiguous merge and ambiguous rejection paths.

]
choice = Choice(
index=0,
Expand All @@ -393,8 +460,8 @@ async def buffer_tool_call_stream(
stream: AsyncIterator[ChatCompletionChunk],
) -> AsyncIterator[ChatCompletionChunk]:
"""Buffer streamed function tool-call deltas until they are complete."""
buffered_calls: dict[int, _BufferedToolCall] = {}
passthrough_tool_call_indexes: set[int] = set()
buffered_calls: dict[int | None, _BufferedToolCall] = {}
passthrough_tool_call_indexes: set[int | None] = set()
saw_passthrough_tool_call = False
last_chunk: ChatCompletionChunk | None = None

Expand All @@ -418,12 +485,50 @@ async def buffer_tool_call_stream(
if tool_call_deltas := (delta.tool_calls if delta and delta.tool_calls else None):
remaining_tool_calls: list[ChoiceDeltaToolCall] = []
for tool_call_delta in tool_call_deltas:
if tool_call_delta.index in passthrough_tool_call_indexes:
matches_buffered_id = bool(
tool_call_delta.id
and any(
buffered_call.call_id == tool_call_delta.id
for buffered_call in buffered_calls.values()
)
)
is_unindexed_function_delta = tool_call_delta.index is None and (
tool_call_delta.function is not None
or tool_call_delta.type == "function"
or matches_buffered_id
)
if (
tool_call_delta.index in passthrough_tool_call_indexes
and not is_unindexed_function_delta
):
if (
tool_call_delta.index is None
and tool_call_delta.id is None
and tool_call_delta.function is None
and None in buffered_calls
):
raise ModelBehaviorError(
"Chat Completions tool call delta could not be attributed "
"safely between buffered and passthrough calls."
)
if matches_buffered_id:
raise ModelBehaviorError(
"Chat Completions tool call index and ID identified different "
"tool call owners."
)
saw_passthrough_tool_call = True
remaining_tool_calls.append(tool_call_delta)
elif cls._should_buffer_tool_call_delta(tool_call_delta):
cls._accumulate_tool_call_delta(buffered_calls, tool_call_delta)
else:
if (
isinstance(tool_call_delta.index, int)
and tool_call_delta.index in buffered_calls
):
raise ModelBehaviorError(
"Chat Completions tool call index identified both a buffered "
"function call and a passthrough call."
)
passthrough_tool_call_indexes.add(tool_call_delta.index)
saw_passthrough_tool_call = True
remaining_tool_calls.append(tool_call_delta)
Expand Down Expand Up @@ -458,7 +563,11 @@ async def buffer_tool_call_stream(
if buffered_calls:
if last_chunk is None:
return
yield cls._buffered_tool_calls_chunk(last_chunk, buffered_calls)
yield cls._buffered_tool_calls_chunk(
last_chunk,
buffered_calls,
passthrough_tool_call_indexes,
)

@staticmethod
def _merged_provider_data(
Expand Down
Loading