crewAIInc / crewAIInc/crewAI

[BUG] Responses API streaming drops tool calls when available_functions is None, returning "" instead of the function-call list

Open
#7,497 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
58.8k
Forks
8.5k
Avg merge
1d 15h
Merged PRs (30d)
109

Description

Description

With api="responses" and stream=True, OpenAICompletion._handle_streaming_responses accumulates function calls from response.output_item.done events but only ever acts on them under if function_calls and available_functions:. There is no branch for the available_functions is None case, which is exactly the contract CrewAgentExecutor uses (it passes available_functions=None and executes the tools itself). The accumulated calls are discarded, the method falls through to the LLMCallCompletedEvent(LLM_CALL) path and returns the streamed text — an empty string for a pure tool-call turn.

The non-streaming sibling _handle_responses handles this correctly at the same call site (if function_calls and not available_functions: ... return function_calls), as does the chat-completions streaming path. Only the Responses streaming handlers are missing the branch.

Root cause: lib/crewai/src/crewai/llms/providers/openai/completion.py:1436 (sync) and :1573 (async twin _ahandle_streaming_responses).

Steps to Reproduce
  1. Configure LLM(model="gpt-5.5", api="responses", stream=True) on an agent that has at least one tool.
  2. Let CrewAgentExecutor invoke the LLM. It calls with tools=openai_tools and available_functions=None (lib/crewai/src/crewai/agents/crew_agent_executor.py:542-543, and the same at :1340-1341), then requires a list back.
  3. The model returns a function_call item.
  4. _call_responses dispatches on self._effective_stream() into _handle_streaming_responses, which reaches the guard at line 1436 and drops the call.

Minimal handler-level reproduction (no network; the OpenAI client is faked):

from types import SimpleNamespace
from crewai.llms.providers.openai.completion import OpenAICompletion

FUNCTION_CALL_ITEM = SimpleNamespace(
    type="function_call", id="fc_1", call_id="call_abc",
    name="multiply", arguments='{"a": 17, "b": 23}', status="completed",
)

RESPONSE = SimpleNamespace(
    id="resp_1", status="completed",
    output=[FUNCTION_CALL_ITEM], output_text="",
    usage=SimpleNamespace(input_tokens=10, output_tokens=5, total_tokens=15,
                          input_tokens_details=None, output_tokens_details=None),
)

def stream_events():
    yield SimpleNamespace(type="response.created", response=RESPONSE)
    yield SimpleNamespace(type="response.output_item.done", item=FUNCTION_CALL_ITEM)
    yield SimpleNamespace(type="response.completed", response=RESPONSE)

class FakeResponses:
    def __init__(self, streaming): self.streaming = streaming
    def create(self, **kwargs):
        return stream_events() if self.streaming else RESPONSE

class FakeClient:
    def __init__(self, streaming): self.responses = FakeResponses(streaming)

def build(**kw):
    return OpenAICompletion(model="gpt-5.5", api_key="sk-test", api="responses", **kw)

llm = build(stream=False)
llm._get_sync_client = lambda: FakeClient(False)
nonstream = llm._handle_responses(
    params={"input": [{"role": "user", "content": "multiply"}]}, available_functions=None)
print("non-streaming ->", type(nonstream).__name__, nonstream)

llm = build(stream=True)
llm._get_sync_client = lambda: FakeClient(True)
streamed = llm._handle_streaming_responses(
    params={"input": [{"role": "user", "content": "multiply"}]}, available_functions=None)
print("streaming     ->", type(streamed).__name__, repr(streamed))

Full script kept at /tmp/repro_openai_responses_stream_tools.py.

Expected behavior

For a pure tool-call turn, streaming should return the same value the non-streaming path returns: the list of function calls, so the caller (the executor) can run them.

Concrete basis, all in the same class:

  • Non-streaming Responses sibling, completion.py:1113: if function_calls and not available_functions: → emit LLMCallCompletedEvent(call_type=LLMCallType.TOOL_CALL) and return function_calls. The async twin at :1260 is identical.
  • Chat-completions streaming, completion.py:2061-2062: comment "Without available_functions, return tool_calls so the caller (executor) handles execution", then if message.tool_calls and not available_functions: ... return list(message.tool_calls); same block at :2499-2500 in _finalize_streaming_response, whose docstring states: "Returns: Tool calls list when tools were invoked without available_functions, tool execution result when available_functions is provided, or the text response string."
  • The executor contract, lib/crewai/src/crewai/agents/crew_agent_executor.py:551-561: it passes available_functions=None, then requires isinstance(answer, list) and self._is_tool_call_list(answer) to run _handle_native_tool_calls.
  • The error surfaces from lib/crewai/src/crewai/utilities/agent_utils.py ("Invalid response from LLM call - None or empty."), or the agent silently returns a blank answer.

The streaming Responses handlers (:1436, :1573) use if function_calls and available_functions: with no falsy branch, so this path is the odd one out.

Screenshots/Code snippets

Verbatim output, verifier 1:

stream=False -> list  [{'id': 'call_abc', 'name': 'multiply', 'arguments': '{"a": 17, "b": 23}'}] (create calls=1, tools sent=True)
stream=True  -> str   '' (create calls=1, tools sent=True)
stream=False executor gets: [{'id': 'call_abc', 'name': 'multiply', 'arguments': '{"a": 17, "b": 23}'}]
stream=True  executor RAISES: ValueError: Invalid response from LLM call - None or empty.
(patched) stream=True -> list [{'id': 'call_abc', 'name': 'multiply', 'arguments': '{"a": 17, "b": 23}'}]
(patched) pytest tests/llms/openai tests/llms/test_tool_call_streaming.py: 204 passed, 4 failed, 1 skipped

Verbatim output, verifier 2:

================ stream=False ================
RESULT -> 'The answer is 391.'
create() calls: 2 | multiply() ran: [(17, 23)]

================ stream=True ================
RAISED -> ValueError Invalid response from LLM call - None or empty.
create() calls: 3 | multiply() ran: []
Operating System

Other (specify in additional context) — macOS 26.6.2 (Darwin 25.6.0)

Python Version

3.13

crewAI Version

1.15.21 (commit 9393a47f313a0544db15693db9bcc48585f30ef5)

crewAI Tools Version

Not installed / not involved in the reproduction (1.15.21 available in the same environment). The defect is in crewai core; no crewai-tools code is on the path.

Virtual Environment

Venv

Evidence

Environment: crewAI 1.15.21 @ 9393a47f313a0544db15693db9bcc48585f30ef5, openai 2.41.0, Python 3.13.15, macOS 26.6.2, uv venv.

An independent run on a clean checkout of the commit above gives:

non-streaming -> list [{'id': 'call_abc', 'name': 'multiply', 'arguments': '{"a": 17, "b": 23}'}]
streaming     -> str ''

The _handle_responses / _handle_streaming_responses pair differs only in the missing not available_functions branch. Applying the mirror branch to both _handle_streaming_responses and _ahandle_streaming_responses makes the streaming case return the same list, with the existing openai provider tests unchanged in outcome.

Related reports found while checking for duplicates:

  • Issue #7438 + PR #7439 — Bedrock streaming drops native tool calls (same defect class, different provider).
  • PR #4444 — chat-completions streaming returns tool calls when available_functions is None; patches _handle_streaming_completion / _ahandle_streaming_completion only. It does not touch _handle_streaming_responses or _ahandle_streaming_responses, so the Responses path remains uncovered.
  • Closed #7486 / #7243 / #4442. No existing issue or PR covers the OpenAI Responses streaming path.
Possible Solution

Mirror the non-streaming sibling before the if function_calls and available_functions: block in both _handle_streaming_responses and _ahandle_streaming_responses: when function_calls is non-empty and available_functions is falsy, emit LLMCallCompletedEvent(call_type=LLMCallType.TOOL_CALL, response=function_calls, ...) and return function_calls — the same shape as completion.py:1113 and :2500. Plus a regression test covering the streaming Responses handler with available_functions=None.

Happy to open a PR with this approach.

Additional context

AI disclosure: this issue was authored by an AI agent. Per .github/CONTRIBUTING.md it carries the llm-generated label; I tried to apply it, but adding labels requires write access this account does not have (AddLabelsToLabelable permission error), so a maintainer will need to add it.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in lib/crewai/src/crewai/llms/providers/openai/completion.py at _handle_streaming_responses and _ahandle_streaming_responses, comparing them with _handle_responses and the chat-completions streaming path. Reproduce the pure tool-call case with available_functions=None, then add coverage for both streaming handlers and verify they return the function-call list and emit the existing tool-call completion event.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend-api-design, testing
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
88/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.