Please read this first
- Have you read the docs? Agents SDK docs
- Have you searched for related issues? Searched for Chat Completions streaming /
buffer_streamed_tool_calls / incomplete tool-call handling; did not find an existing report of this default-vs-buffered asymmetry.
Describe the bug
For OpenAI Chat Completions streaming tool calls, the default path (buffer_streamed_tool_calls=False) and the buffered path (buffer_streamed_tool_calls=True) disagree on incomplete tool-call streams:
| Path |
Incomplete tool call (missing id and/or function name) |
| Default / non-buffered |
Still finalizes a ResponseFunctionToolCall with empty name and/or call_id via the end-of-stream fallback |
| Buffered |
Raises ModelBehaviorError (without a tool call id / without a function name) |
The same incomplete upstream stream can either hard-fail or silently produce an unusable (or partially usable) tool call, depending only on a flag that defaults to False. LiteLLM/AnyLLM integrations always use the non-buffered path today.
This appears to come from ChatCmplStreamHandler.handle_stream:
- On first tool-call delta for an index, it seeds
ResponseFunctionToolCall(name="", call_id="", ...).
- Live streaming only starts once both
name and call_id are present.
- At stream end, if streaming never started, the "fallback to old behavior" branch still emits
response.output_item.added / arguments delta / response.output_item.done with those empty fields.
The buffered path validates completeness in _buffered_tool_call_delta and raises instead (added in PR #3506).
Debug information
- Agents SDK version:
0.18.3 (checkout 965335aba6f6c71500e0b8cdb4e9e495f5801d4d)
- Python version: Python 3.12.10
- OS: Windows 11
- API surface: Chat Completions streaming (
OpenAIChatCompletionsModel / OpenAIProvider(use_responses=False))
- Flag:
buffer_streamed_tool_calls default False vs True
- Reproduction count: 2/2 stable (unit-level synthetic stream; no live API key)
Repro steps
Minimal unit-style repro against the stream handler (no live API key required):
import asyncio
from typing import AsyncIterator
from openai.types.chat.chat_completion_chunk import (
ChatCompletionChunk,
Choice,
ChoiceDelta,
ChoiceDeltaToolCall,
ChoiceDeltaToolCallFunction,
)
from openai.types.responses import Response
from agents.models.chatcmpl_stream_handler import ChatCmplStreamHandler
from agents.exceptions import ModelBehaviorError
def _chunk(tool_calls=None, finish_reason=None) -> ChatCompletionChunk:
return ChatCompletionChunk(
id="chunk-id",
created=1,
model="fake",
object="chat.completion.chunk",
choices=[
Choice(
index=0,
delta=ChoiceDelta(tool_calls=tool_calls),
finish_reason=finish_reason,
)
],
)
async def incomplete_stream() -> AsyncIterator[ChatCompletionChunk]:
# Incomplete: index + partial args, but no tool call id and no function name.
yield _chunk(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
function=ChoiceDeltaToolCallFunction(arguments='{"a":'),
)
]
)
yield _chunk(finish_reason="tool_calls")
async def main() -> None:
response = Response(
id="resp",
created_at=1,
model="fake",
object="response",
output=[],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
)
# Default / non-buffered
events = [
event
async for event in ChatCmplStreamHandler.handle_stream(
response, incomplete_stream()
)
]
done_items = [
e.item
for e in events
if getattr(e, "type", None) == "response.output_item.done"
]
print("non-buffered done items:", done_items)
# Observed: function_call with name="" and call_id=""
# Buffered
try:
async for _ in ChatCmplStreamHandler.handle_stream(
response,
ChatCmplStreamHandler.buffer_tool_call_stream(incomplete_stream()),
):
pass
print("buffered: no error")
except ModelBehaviorError as exc:
print("buffered raised:", type(exc).__name__, str(exc))
# Observed: ModelBehaviorError about missing tool call id / function name
asyncio.run(main())
Related existing coverage asserts the buffered hard-fail only:
tests/models/test_openai_chatcompletions_stream.py::test_buffered_tool_call_delta_requires_id_and_name
tests/models/test_openai_chatcompletions_stream.py::test_stream_response_buffered_tool_calls_raise_for_missing_tool_call_delta
I did not find a matching assertion that the default path rejects the same incomplete stream.
Expected behavior
Buffered and non-buffered Chat Completions streaming paths should treat incomplete tool-call streams consistently.
If a tool call never receives a name and/or call_id, the default non-buffered path should fail closed the same way as the buffered path (ModelBehaviorError), rather than emitting a finalized function call with empty identifiers.
(If empty finalization is intentionally kept for backward compatibility, that should be documented, and ideally warned/gated rather than silent.)
Actual behavior
- Non-buffered (default): end-of-stream fallback finalizes incomplete tool calls with empty
name / call_id.
- Buffered: raises
ModelBehaviorError when id or name is missing.
Additional observation from runner path analysis: empty name tends to fail later as "tool not found"; a present name with empty call_id can still reach tool execution with an empty call_id, which may break correlation/history pairing.
Possible cause
This may be legacy fallback behavior in ChatCmplStreamHandler.handle_stream (comment about "function name never arrived"), while buffer_tool_call_stream later added strict completeness checks in PR #3506. The safer checks are behind a non-default flag, so default and LiteLLM remain on the permissive finalizer.
Relevant spots:
src/agents/models/chatcmpl_stream_handler.py — empty seed; stream gate; end-of-stream fallback
src/agents/models/chatcmpl_stream_handler.py — _buffered_tool_call_delta validation
src/agents/models/openai_chatcompletions.py — buffer_streamed_tool_calls: bool = False
Suggested direction
- Align non-buffered finalization with buffered validation (raise
ModelBehaviorError when name or call_id is still empty at stream end), or
- If empty finalization must remain for compatibility, document it and consider a warning / opt-in strict mode; prefer fail-closed by default for incomplete identifiers.
- Add a regression test that feeds incomplete tool-call deltas through both paths and asserts matching failure (or explicit drop) behavior.
- Optional defense-in-depth: reject empty
call_id before tool execution in the runner.
I'd be happy to prepare a PR with a regression test if this behavior is confirmed.
Additional context
Please read this first
buffer_streamed_tool_calls/ incomplete tool-call handling; did not find an existing report of this default-vs-buffered asymmetry.Describe the bug
For OpenAI Chat Completions streaming tool calls, the default path (
buffer_streamed_tool_calls=False) and the buffered path (buffer_streamed_tool_calls=True) disagree on incomplete tool-call streams:idand/or functionname)ResponseFunctionToolCallwith emptynameand/orcall_idvia the end-of-stream fallbackModelBehaviorError(without a tool call id/without a function name)The same incomplete upstream stream can either hard-fail or silently produce an unusable (or partially usable) tool call, depending only on a flag that defaults to
False. LiteLLM/AnyLLM integrations always use the non-buffered path today.This appears to come from
ChatCmplStreamHandler.handle_stream:ResponseFunctionToolCall(name="", call_id="", ...).nameandcall_idare present.response.output_item.added/ arguments delta /response.output_item.donewith those empty fields.The buffered path validates completeness in
_buffered_tool_call_deltaand raises instead (added in PR #3506).Debug information
0.18.3(checkout965335aba6f6c71500e0b8cdb4e9e495f5801d4d)OpenAIChatCompletionsModel/OpenAIProvider(use_responses=False))buffer_streamed_tool_callsdefaultFalsevsTrueRepro steps
Minimal unit-style repro against the stream handler (no live API key required):
Related existing coverage asserts the buffered hard-fail only:
tests/models/test_openai_chatcompletions_stream.py::test_buffered_tool_call_delta_requires_id_and_nametests/models/test_openai_chatcompletions_stream.py::test_stream_response_buffered_tool_calls_raise_for_missing_tool_call_deltaI did not find a matching assertion that the default path rejects the same incomplete stream.
Expected behavior
Buffered and non-buffered Chat Completions streaming paths should treat incomplete tool-call streams consistently.
If a tool call never receives a
nameand/orcall_id, the default non-buffered path should fail closed the same way as the buffered path (ModelBehaviorError), rather than emitting a finalized function call with empty identifiers.(If empty finalization is intentionally kept for backward compatibility, that should be documented, and ideally warned/gated rather than silent.)
Actual behavior
name/call_id.ModelBehaviorErrorwhen id or name is missing.Additional observation from runner path analysis: empty
nametends to fail later as "tool not found"; a presentnamewith emptycall_idcan still reach tool execution with an emptycall_id, which may break correlation/history pairing.Possible cause
This may be legacy fallback behavior in
ChatCmplStreamHandler.handle_stream(comment about "function name never arrived"), whilebuffer_tool_call_streamlater added strict completeness checks in PR #3506. The safer checks are behind a non-default flag, so default and LiteLLM remain on the permissive finalizer.Relevant spots:
src/agents/models/chatcmpl_stream_handler.py— empty seed; stream gate; end-of-stream fallbacksrc/agents/models/chatcmpl_stream_handler.py—_buffered_tool_call_deltavalidationsrc/agents/models/openai_chatcompletions.py—buffer_streamed_tool_calls: bool = FalseSuggested direction
ModelBehaviorErrorwhennameorcall_idis still empty at stream end), orcall_idbefore tool execution in the runner.I'd be happy to prepare a PR with a regression test if this behavior is confirmed.
Additional context