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
- Configure
LLM(model="gpt-5.5", api="responses", stream=True) on an agent that has at least one tool.
- 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.
- The model returns a
function_call item.
_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 9393a47)
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:
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.
Description
With
api="responses"andstream=True,OpenAICompletion._handle_streaming_responsesaccumulates function calls fromresponse.output_item.doneevents but only ever acts on them underif function_calls and available_functions:. There is no branch for theavailable_functions is Nonecase, which is exactly the contractCrewAgentExecutoruses (it passesavailable_functions=Noneand executes the tools itself). The accumulated calls are discarded, the method falls through to theLLMCallCompletedEvent(LLM_CALL)path and returns the streamed text — an empty string for a pure tool-call turn.The non-streaming sibling
_handle_responseshandles 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
LLM(model="gpt-5.5", api="responses", stream=True)on an agent that has at least one tool.CrewAgentExecutorinvoke the LLM. It calls withtools=openai_toolsandavailable_functions=None(lib/crewai/src/crewai/agents/crew_agent_executor.py:542-543, and the same at:1340-1341), then requires a list back.function_callitem._call_responsesdispatches onself._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):
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:
completion.py:1113:if function_calls and not available_functions:→ emitLLMCallCompletedEvent(call_type=LLMCallType.TOOL_CALL)andreturn function_calls. The async twin at:1260is identical.completion.py:2061-2062: comment "Without available_functions, return tool_calls so the caller (executor) handles execution", thenif message.tool_calls and not available_functions: ... return list(message.tool_calls); same block at:2499-2500in_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."lib/crewai/src/crewai/agents/crew_agent_executor.py:551-561: it passesavailable_functions=None, then requiresisinstance(answer, list) and self._is_tool_call_list(answer)to run_handle_native_tool_calls.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) useif function_calls and available_functions:with no falsy branch, so this path is the odd one out.Screenshots/Code snippets
Verbatim output, verifier 1:
Verbatim output, verifier 2:
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 9393a47)
crewAI Tools Version
Not installed / not involved in the reproduction (1.15.21 available in the same environment). The defect is in
crewaicore; nocrewai-toolscode 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:
The
_handle_responses/_handle_streaming_responsespair differs only in the missingnot available_functionsbranch. Applying the mirror branch to both_handle_streaming_responsesand_ahandle_streaming_responsesmakes the streaming case return the same list, with the existing openai provider tests unchanged in outcome.Related reports found while checking for duplicates:
available_functions is None; patches_handle_streaming_completion/_ahandle_streaming_completiononly. It does not touch_handle_streaming_responsesor_ahandle_streaming_responses, so the Responses path remains uncovered.Possible Solution
Mirror the non-streaming sibling before the
if function_calls and available_functions:block in both_handle_streaming_responsesand_ahandle_streaming_responses: whenfunction_callsis non-empty andavailable_functionsis falsy, emitLLMCallCompletedEvent(call_type=LLMCallType.TOOL_CALL, response=function_calls, ...)andreturn function_calls— the same shape ascompletion.py:1113and:2500. Plus a regression test covering the streaming Responses handler withavailable_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.mdit carries thellm-generatedlabel; I tried to apply it, but adding labels requires write access this account does not have (AddLabelsToLabelablepermission error), so a maintainer will need to add it.