Skip to content

Non-buffered Chat Completions tool-call streaming finalizes incomplete tool calls with empty name/call_id #3861

Description

@baiyuxi930826

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:

  1. On first tool-call delta for an index, it seeds ResponseFunctionToolCall(name="", call_id="", ...).
  2. Live streaming only starts once both name and call_id are present.
  3. 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.pybuffer_streamed_tool_calls: bool = False

Suggested direction

  1. Align non-buffered finalization with buffered validation (raise ModelBehaviorError when name or call_id is still empty at stream end), or
  2. 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.
  3. Add a regression test that feeds incomplete tool-call deltas through both paths and asserts matching failure (or explicit drop) behavior.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions