From c52d0d9530dbf9119efb5dfa6ede98959e4ac364 Mon Sep 17 00:00:00 2001 From: alex-clawd Date: Sun, 26 Jul 2026 02:18:20 -0700 Subject: [PATCH] fix(openai): make tool calling work on the Responses API path (#6657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(openai): make tool calling work on the Responses API path An agent with tools on api="responses" never produced an answer. It returned the raw tool-call list instead: [{'id': 'call_...', 'name': 'multiply', 'arguments': '{"a":17,"b":23}'}] Three defects in the chain, all on the Responses side only: 1. `is_tool_call_list()` knew the OpenAI-nested, Anthropic, Bedrock and Gemini shapes but not the Responses one ({"id", "name", "arguments"} -- no nested "function", no "input"). The list wasn't recognized as tool calls, so the executor handed it back verbatim as the final answer. 2. `extract_tool_call_info()` read "arguments" only from a nested "function" object, falling back to "input". For the Responses shape both missed and the arguments silently became {}, so the tool would have run with no input. 3. With those fixed the tool ran, then the follow-up request 400'd: Invalid type for 'input[1].content': expected one of an array of objects or string, but got null instead. Tool calling is expressed differently by the two APIs. Chat Completions uses an assistant message carrying `tool_calls` with content: None, then role: "tool" results. The Responses API uses flat function_call / function_call_output items keyed by call_id. Those messages were passed through untranslated. `_to_responses_input()` now converts them. Messages without tool calls pass through unchanged, so nothing else moves. Verified end to end against the live API: api="responses" + tools -> 391 (was raw tool-call JSON) chained multi-step tool calls -> 400 (17*23, then +9) completions path (control) -> 391 (unchanged) The generated `input` payload was also posted to /v1/responses directly and accepted, and the pre-fix chat-shaped payload confirmed as a 400. This is why api="responses" never worked for agents: the provider side has had a full Responses implementation since #4258/c4c9208, but the executor never learned the shape it emits. Fixing it also unblocks routing gpt-5.4+ tool calls to the Responses API instead of dropping reasoning_effort. Tests: 12 cases covering recognition, extraction (including that the Chat Completions and Bedrock shapes are unaffected), translation of assistant/tool messages, parallel calls, assistant text alongside tool calls, non-string tool output, and the full prepared `input` list. * fix(openai): prefer Responses "call_id" over the item's own "id" Per CodeRabbit review. A raw Responses function_call item carries both keys with different values, confirmed against the live API: keys ['arguments', 'call_id', 'id', 'name', 'status', 'type'] id fc_0adeb715c5d740c7006a65ccb72b948199872ad8b5a5c53108 call_id call_dEoHFrYnOgWYvk17FymdcDZ5 function_call_output must reference call_id. Reading the item's own "id" would produce a tool result the model can't correlate back to its invocation. Our own _extract_function_calls_from_response already maps item.call_id into "id", so the normal path was correct and the existing tests passed. But extract_tool_call_info is a shared helper reached from every provider's tool loop, and a raw Responses item is a plausible thing to hand it -- silently picking the wrong identifier is a bad trap to leave in place for one line of guard. Tests: raw item extraction asserting call_id is chosen over id, and a round-trip check that the id extracted from a call is the one sent back with its result. 997 passed across tests/llms, test_agent_utils and tests/agents (1 pre-existing unrelated failure from a local OLLAMA_API_KEY env leak). Real two-agent chained-tool run still returns the correct answer. --------- Co-authored-by: João Moura --- .../llms/providers/openai/completion.py | 48 +++- .../src/crewai/utilities/agent_utils.py | 31 ++- .../llms/openai/test_responses_tool_loop.py | 226 ++++++++++++++++++ 3 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 lib/crewai/tests/llms/openai/test_responses_tool_loop.py diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 2cd07e863..338e58d87 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -719,6 +719,52 @@ class OpenAICompletion(BaseLLM): response_model=response_model, ) + @staticmethod + def _to_responses_input(message: LLMMessage) -> list[Any]: + """Translate a chat-format message into Responses ``input`` items. + + Tool calling is expressed differently by the two APIs. Chat Completions + uses an assistant message carrying ``tool_calls`` (with ``content: None``) + followed by ``role: "tool"`` results; the Responses API uses flat + ``function_call`` / ``function_call_output`` items keyed by ``call_id``. + + Passing the chat shape straight through is rejected: + + Invalid type for 'input[1].content': expected one of an array of + objects or string, but got null instead. + + Anything without tool calls is already valid and passes through unchanged. + """ + role = message.get("role") + + if role == "assistant" and message.get("tool_calls"): + items: list[Any] = [] + content = message.get("content") + if content: + items.append({"role": "assistant", "content": content}) + for call in message["tool_calls"]: + function = call.get("function", {}) + items.append( + { + "type": "function_call", + "call_id": call.get("id", ""), + "name": function.get("name", ""), + "arguments": function.get("arguments", "{}"), + } + ) + return items + + if role == "tool": + return [ + { + "type": "function_call_output", + "call_id": message.get("tool_call_id", ""), + "output": str(message.get("content", "")), + } + ] + + return [message] + def _prepare_responses_params( self, messages: list[LLMMessage], @@ -745,7 +791,7 @@ class OpenAICompletion(BaseLLM): else: instructions = content_str else: - input_messages.append(message) + input_messages.extend(self._to_responses_input(message)) # Prepend reasoning items for ZDR (zero-data-retention) chaining when configured final_input: list[Any] = [] diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index e72281e51..48e0fa7c9 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -1246,13 +1246,28 @@ def extract_tool_call_info( call_id = getattr(tool_call, "id", f"call_{id(tool_call)}") return call_id, sanitize_tool_name(tool_call.name), tool_call.input if isinstance(tool_call, dict): - # Support OpenAI "id", Bedrock "toolUseId", or generate one + # Prefer the Responses API "call_id", then OpenAI "id", then Bedrock + # "toolUseId", else generate one. A raw Responses function_call item carries + # both "id" (fc_...) and "call_id" (call_...) with different values, and the + # matching function_call_output must reference "call_id" -- reading "id" + # would produce a tool result that can't be correlated to its invocation. call_id = ( - tool_call.get("id") or tool_call.get("toolUseId") or f"call_{id(tool_call)}" + tool_call.get("call_id") + or tool_call.get("id") + or tool_call.get("toolUseId") + or f"call_{id(tool_call)}" ) func_info = tool_call.get("function", {}) func_name = func_info.get("name", "") or tool_call.get("name", "") - func_args = func_info.get("arguments") or tool_call.get("input") or {} + # "arguments" is also read from the top level for the OpenAI Responses API, + # which emits {"id", "name", "arguments"} with no nested "function" object. + # Without it the args silently resolved to {} and the tool ran with no input. + func_args = ( + func_info.get("arguments") + or tool_call.get("arguments") + or tool_call.get("input") + or {} + ) return call_id, sanitize_tool_name(func_name), func_args return None @@ -1284,6 +1299,16 @@ def is_tool_call_list(response: list[Any]) -> bool: # Bedrock-style if isinstance(first_item, dict) and "name" in first_item and "input" in first_item: return True + # OpenAI Responses API style: {"id", "name", "arguments"}, with no nested + # "function" object and no "input". Without this the list isn't recognized as + # tool calls, so the executor hands it back verbatim and the agent returns raw + # tool-call JSON instead of running the tool and producing a final answer. + if ( + isinstance(first_item, dict) + and "name" in first_item + and "arguments" in first_item + ): + return True # Gemini-style if hasattr(first_item, "function_call") and first_item.function_call: return True diff --git a/lib/crewai/tests/llms/openai/test_responses_tool_loop.py b/lib/crewai/tests/llms/openai/test_responses_tool_loop.py new file mode 100644 index 000000000..839e44c97 --- /dev/null +++ b/lib/crewai/tests/llms/openai/test_responses_tool_loop.py @@ -0,0 +1,226 @@ +"""Tests for tool calling on the OpenAI Responses API path. + +The two APIs express tool calling differently: + + Chat Completions assistant message with `tool_calls` (content: None), + then {"role": "tool", "tool_call_id": ...} + Responses flat {"type": "function_call", "call_id", ...} and + {"type": "function_call_output", "call_id", "output"} items + +Sending the chat shape to /v1/responses is rejected outright: + + Invalid type for 'input[1].content': expected one of an array of objects or + string, but got null instead. + +Verified against the live endpoint: the chat shape 400s, the native items complete. +""" + +import pytest + +from crewai.llms.providers.openai.completion import OpenAICompletion +from crewai.utilities.agent_utils import extract_tool_call_info, is_tool_call_list + + +# The shape OpenAICompletion._extract_function_calls_from_response builds from a +# Responses payload: no nested "function" object, no "input". +RESPONSES_TOOL_CALL = { + "id": "call_abc", + "name": "multiply", + "arguments": '{"a": 17, "b": 23}', +} + +# A raw Responses `function_call` output item, as returned by the API. Note that +# "id" and "call_id" are different values -- the matching function_call_output must +# reference "call_id". +RAW_RESPONSES_ITEM = { + "type": "function_call", + "id": "fc_0adeb715c5d740c7006a65ccb7", + "call_id": "call_dEoHFrYnOgWYvk17FymdcDZ5", + "name": "multiply", + "arguments": '{"a": 17, "b": 23}', + "status": "completed", +} + + +def build(model: str = "gpt-5.5", **kwargs) -> OpenAICompletion: + return OpenAICompletion(model=model, api_key="sk-test", api="responses", **kwargs) + + +class TestToolCallRecognition: + """The executor must recognize Responses-shaped tool calls.""" + + def test_recognizes_responses_shape(self): + assert is_tool_call_list([RESPONSES_TOOL_CALL]) + + def test_extracts_arguments_from_top_level(self): + """Previously fell through to `input` and silently yielded {}.""" + call_id, name, args = extract_tool_call_info(RESPONSES_TOOL_CALL) + + assert call_id == "call_abc" + assert name == "multiply" + assert args == '{"a": 17, "b": 23}' + + @pytest.mark.parametrize( + ("tool_call", "expected_args"), + [ + ( + {"id": "c", "function": {"name": "f", "arguments": '{"x":1}'}}, + '{"x":1}', + ), + ({"toolUseId": "c", "name": "f", "input": {"x": 1}}, {"x": 1}), + ], + ) + def test_other_provider_shapes_still_work(self, tool_call, expected_args): + """Chat Completions and Bedrock shapes must be unaffected.""" + assert is_tool_call_list([tool_call]) + assert extract_tool_call_info(tool_call)[2] == expected_args + + def test_raw_responses_item_uses_call_id_not_item_id(self): + """A raw function_call item carries both; only call_id can be correlated. + + function_call_output must reference call_id, so picking up the item's own + "id" (fc_...) would produce a tool result the model can't match to its + invocation. + """ + call_id, name, args = extract_tool_call_info(RAW_RESPONSES_ITEM) + + assert call_id == "call_dEoHFrYnOgWYvk17FymdcDZ5" + assert call_id != RAW_RESPONSES_ITEM["id"] + assert name == "multiply" + assert args == '{"a": 17, "b": 23}' + + +class TestResponsesInputTranslation: + """Chat-format tool messages must become native Responses items.""" + + def test_assistant_tool_calls_become_function_call_items(self): + message = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "multiply", "arguments": '{"a":17,"b":23}'}, + } + ], + } + + assert OpenAICompletion._to_responses_input(message) == [ + { + "type": "function_call", + "call_id": "call_1", + "name": "multiply", + "arguments": '{"a":17,"b":23}', + } + ] + + def test_tool_result_becomes_function_call_output(self): + message = {"role": "tool", "tool_call_id": "call_1", "content": "391"} + + assert OpenAICompletion._to_responses_input(message) == [ + {"type": "function_call_output", "call_id": "call_1", "output": "391"} + ] + + def test_assistant_text_alongside_tool_calls_is_preserved(self): + message = { + "role": "assistant", + "content": "Let me calculate.", + "tool_calls": [ + {"id": "c1", "function": {"name": "multiply", "arguments": "{}"}} + ], + } + + items = OpenAICompletion._to_responses_input(message) + + assert items[0] == {"role": "assistant", "content": "Let me calculate."} + assert items[1]["type"] == "function_call" + + def test_parallel_tool_calls_become_separate_items(self): + message = { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "c1", "function": {"name": "multiply", "arguments": "{}"}}, + {"id": "c2", "function": {"name": "add", "arguments": "{}"}}, + ], + } + + items = OpenAICompletion._to_responses_input(message) + + assert [i["call_id"] for i in items] == ["c1", "c2"] + + @pytest.mark.parametrize( + "message", + [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + ) + def test_messages_without_tool_calls_pass_through(self, message): + assert OpenAICompletion._to_responses_input(message) == [message] + + def test_non_string_tool_output_is_coerced(self): + """Tool results arrive as ints, dicts, etc. The API requires a string.""" + message = {"role": "tool", "tool_call_id": "c1", "content": 391} + + assert OpenAICompletion._to_responses_input(message)[0]["output"] == "391" + + def test_call_and_output_ids_round_trip(self): + """The id extracted from a call must be the one sent back with its result. + + This is the correlation the API relies on: a function_call_output whose + call_id doesn't match an emitted function_call is rejected or ignored. + """ + call_id, name, args = extract_tool_call_info(RAW_RESPONSES_ITEM) + + assistant = OpenAICompletion._to_responses_input( + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": call_id, "function": {"name": name, "arguments": args}} + ], + } + ) + result = OpenAICompletion._to_responses_input( + {"role": "tool", "tool_call_id": call_id, "content": "391"} + ) + + assert assistant[0]["call_id"] == result[0]["call_id"] == call_id + + +class TestPreparedParams: + """End-to-end shape of the `input` list handed to the Responses API.""" + + def test_tool_conversation_produces_valid_input(self): + llm = build() + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "multiply 17 and 23"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "function": {"name": "multiply", "arguments": '{"a":17,"b":23}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "391"}, + ] + + params = llm._prepare_responses_params(messages) + + assert params["instructions"] == "You are helpful." + assert [item.get("type") or item["role"] for item in params["input"]] == [ + "user", + "function_call", + "function_call_output", + ] + # The rejected shape was an item carrying an explicit content: None. + # function_call items have no content key at all, which is valid. + assert not any( + "content" in item and item["content"] is None for item in params["input"] + )