From 37087b7e1d98a92ffab8df81d5682ffbbf0545c3 Mon Sep 17 00:00:00 2001 From: theCyberTech <84775494+theCyberTech@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:12:20 +0800 Subject: [PATCH 1/6] fix(agent): recognize OpenAI Responses API tool-call shape in native tool loop is_tool_call_list() and extract_tool_call_info() only recognized Chat-Completions-style ({"function": {...}}), Anthropic-style ({"name", "input"}), and Gemini-style tool-call shapes. The Responses API's function_call output items are flat dicts shaped {"id", "name", "arguments"} with no nested "function" key and no "input" key, so they matched none of the checks. This caused is_tool_call_list() to misclassify a genuine tool call as a plain text answer, so the native tool loop returned the raw tool-call list as the agent's final output instead of executing the tool. Even after recognizing the shape, extract_tool_call_info() would have passed an empty arguments dict, since it only read "input" for the dict fallback. Verified against LLM(api="responses") with tools attached: the agent now correctly executes the tool with the parsed arguments instead of returning the unexecuted tool-call JSON as its answer. Co-Authored-By: Claude Sonnet 5 --- lib/crewai/src/crewai/utilities/agent_utils.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 91cfbb6db8..545305d235 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -1228,7 +1228,14 @@ def extract_tool_call_info( ) 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 {} + # OpenAI Responses API function_call items are flat dicts using + # "arguments" (not "input") with no nested "function" key. + 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 @@ -1260,6 +1267,13 @@ 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 (flat dict, no nested "function" key) + 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 From cb78402898284d05cc62a98b6dfe8890a71337b1 Mon Sep 17 00:00:00 2001 From: theCyberTech <84775494+theCyberTech@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:18:07 +0800 Subject: [PATCH 2/6] test(agent_utils): cover OpenAI Responses API tool-call shape Regression tests for is_tool_call_list() and extract_tool_call_info() against the Responses API's flat {"id", "name", "arguments"} dict shape, alongside existing Chat-Completions and Bedrock/Anthropic shapes to confirm no regression there. Confirmed these tests fail against the pre-fix version of agent_utils.py (3 failures matching exactly the Responses API cases) and pass against the fix in 37087b7e1. Co-Authored-By: Claude Sonnet 5 --- .../tests/utilities/test_agent_utils.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py index 9cf4a2d2a3..2e7ee18bc6 100644 --- a/lib/crewai/tests/utilities/test_agent_utils.py +++ b/lib/crewai/tests/utilities/test_agent_utils.py @@ -25,6 +25,8 @@ _split_messages_into_chunks, convert_tools_to_openai_schema, execute_single_native_tool_call, + extract_tool_call_info, + is_tool_call_list, NativeToolCallResult, parse_tool_call_args, summarize_messages, @@ -981,6 +983,88 @@ def test_parallel_summarize_preserves_files(self) -> None: assert "report.pdf" in summary_msg["files"] +class TestIsToolCallListResponsesApiShape: + """Regression tests: OpenAI Responses API tool-call dicts must be recognized. + + Responses API function_call output items are flat dicts shaped + {"id", "name", "arguments"} - no nested "function" key, and "arguments" + instead of Anthropic/Bedrock-style "input". + """ + + def test_responses_api_dict_is_recognized_as_tool_call(self) -> None: + response = [ + { + "id": "call_abc123", + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + } + ] + assert is_tool_call_list(response) is True + + def test_plain_text_answer_not_misclassified(self) -> None: + assert is_tool_call_list(["just a string, not a tool call"]) is False + + def test_empty_list_returns_false(self) -> None: + assert is_tool_call_list([]) is False + + def test_chat_completions_style_still_recognized(self) -> None: + response = [{"function": {"name": "fetch_page", "arguments": "{}"}}] + assert is_tool_call_list(response) is True + + def test_bedrock_anthropic_style_still_recognized(self) -> None: + response = [{"name": "fetch_page", "input": {"url": "https://example.com"}}] + assert is_tool_call_list(response) is True + + +class TestExtractToolCallInfoResponsesApiShape: + """Regression tests: extract_tool_call_info must parse Responses API dicts.""" + + def test_responses_api_dict_extracts_real_arguments(self) -> None: + tool_call = { + "id": "call_abc123", + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + } + result = extract_tool_call_info(tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "call_abc123" + assert func_name == "fetch_page" + assert func_args == '{"url": "https://example.com"}' + + def test_responses_api_dict_does_not_return_empty_args(self) -> None: + tool_call = { + "id": "call_xyz", + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + } + _, _, func_args = extract_tool_call_info(tool_call) + assert func_args != {} + + def test_bedrock_anthropic_style_still_uses_input(self) -> None: + tool_call = {"name": "fetch_page", "input": {"url": "https://example.com"}} + _, func_name, func_args = extract_tool_call_info(tool_call) + assert func_name == "fetch_page" + assert func_args == {"url": "https://example.com"} + + def test_chat_completions_style_still_uses_nested_function(self) -> None: + tool_call = { + "id": "call_1", + "function": {"name": "fetch_page", "arguments": "{}"}, + } + _, func_name, func_args = extract_tool_call_info(tool_call) + assert func_name == "fetch_page" + assert func_args == "{}" + + def test_non_dict_unrecognized_shape_returns_none(self) -> None: + assert extract_tool_call_info("just a string") is None + + def test_unrecognized_dict_shape_returns_empty_name_and_args(self) -> None: + call_id, func_name, func_args = extract_tool_call_info({"unrelated": "data"}) + assert func_name == "" + assert func_args == {} + + class TestParseToolCallArgs: """Unit tests for parse_tool_call_args.""" From 37a8267355c8c89269b57ba3a467b9efa341d134 Mon Sep 17 00:00:00 2001 From: theCyberTech <84775494+theCyberTech@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:34:42 +0800 Subject: [PATCH 3/6] fix(llms/openai): convert Chat-Completions tool messages to Responses API input items _prepare_responses_params() passed non-system messages straight through as the Responses API "input" array without converting them. That's fine for plain user/assistant text (matches the API's lenient "easy input message" shape), but Chat-Completions-style assistant messages carrying "tool_calls" and "tool"-role messages have no equivalent shape in the Responses API - it expects standalone "function_call" and "function_call_output" input items instead. Sending the raw Chat-Completions shapes gets rejected with a 400 (union-type validation failure against every Responses API input item variant). This broke every multi-turn tool-calling conversation over api="responses" that doesn't rely on auto_chain/previous_response_id (i.e. the common case: resending full history each turn instead of referencing server-side state). Added _convert_message_to_responses_input_items() to translate: - assistant + tool_calls -> one function_call item per call - tool role -> function_call_output item - everything else -> passed through unchanged Verified against a real multi-turn tool-calling run: the agent now completes the full conversation and returns the actual extracted answer instead of erroring on the second turn. Co-Authored-By: Claude Sonnet 5 --- .../llms/providers/openai/completion.py | 44 +++++++- lib/crewai/tests/llms/openai/test_openai.py | 103 ++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 77d2bbbdd2..4b872d1d28 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -643,6 +643,44 @@ async def _acall_responses( response_model=response_model, ) + def _convert_message_to_responses_input_items( + self, message: LLMMessage + ) -> list[dict[str, Any]]: + """Convert a Chat-Completions-style message into Responses API input items. + + The Responses API has no message shape for an assistant turn carrying + ``tool_calls`` or for a ``tool`` role reply - those become standalone + ``function_call`` / ``function_call_output`` input items instead. Plain + user/assistant text messages pass through unchanged (accepted as-is by + the Responses API's lenient "easy input message" shape). + """ + role = message.get("role") + + if role == "assistant" and message.get("tool_calls"): + items: list[dict[str, Any]] = [] + for tool_call in message["tool_calls"]: + function = tool_call.get("function", {}) + items.append( + { + "type": "function_call", + "call_id": tool_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": message.get("content") or "", + } + ] + + return [message] + def _prepare_responses_params( self, messages: list[LLMMessage], @@ -658,7 +696,7 @@ def _prepare_responses_params( - Internally-tagged tool format (flat structure) """ instructions: str | None = self.instructions - input_messages: list[LLMMessage] = [] + input_messages: list[Any] = [] for message in messages: if message.get("role") == "system": @@ -669,7 +707,9 @@ def _prepare_responses_params( else: instructions = content_str else: - input_messages.append(message) + input_messages.extend( + self._convert_message_to_responses_input_items(message) + ) # Prepend reasoning items for ZDR (zero-data-retention) chaining when configured final_input: list[Any] = [] diff --git a/lib/crewai/tests/llms/openai/test_openai.py b/lib/crewai/tests/llms/openai/test_openai.py index 836abe838d..fdb77504b7 100644 --- a/lib/crewai/tests/llms/openai/test_openai.py +++ b/lib/crewai/tests/llms/openai/test_openai.py @@ -812,6 +812,109 @@ def test_openai_responses_api_with_system_message_extraction(): assert result.isupper() or "HELLO" in result.upper() +def test_openai_responses_api_converts_assistant_tool_calls_message(): + """Regression: assistant messages carrying tool_calls (Chat-Completions + shape) must become standalone function_call input items, since the + Responses API has no message shape for an assistant tool-call turn. + """ + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + {"role": "user", "content": "Fetch https://example.com"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + }, + } + ], + }, + ] + + params = llm._prepare_responses_params(messages) + + assert params["input"][0] == {"role": "user", "content": "Fetch https://example.com"} + assert params["input"][1] == { + "type": "function_call", + "call_id": "call_abc123", + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + } + + +def test_openai_responses_api_converts_tool_result_message(): + """Regression: tool-role messages (Chat-Completions shape) must become + function_call_output input items for the Responses API. + """ + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + { + "role": "tool", + "tool_call_id": "call_abc123", + "name": "fetch_page", + "content": "page text", + }, + ] + + params = llm._prepare_responses_params(messages) + + assert params["input"] == [ + { + "type": "function_call_output", + "call_id": "call_abc123", + "output": "page text", + } + ] + + +def test_openai_responses_api_multi_turn_tool_conversation_shape(): + """Regression: a full multi-turn tool-calling conversation (user -> + assistant tool_calls -> tool result) must convert entirely into valid + Responses API input items, with no leftover Chat-Completions-only keys + ("tool_calls", "tool_call_id") that the Responses API would reject. + """ + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + {"role": "user", "content": "Fetch https://example.com"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "fetch_page", + "arguments": '{"url": "https://example.com"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "name": "fetch_page", + "content": "page text", + }, + ] + + params = llm._prepare_responses_params(messages) + + for item in params["input"]: + assert "tool_calls" not in item + assert "tool_call_id" not in item + assert params["input"][1]["type"] == "function_call" + assert params["input"][2]["type"] == "function_call_output" + + @pytest.mark.vcr() def test_openai_responses_api_streaming(): """Test Responses API with streaming enabled.""" From 6f62ed826dcbe490fef90901e9141e654209c88f Mon Sep 17 00:00:00 2001 From: theCyberTech <84775494+theCyberTech@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:30:51 +0800 Subject: [PATCH 4/6] fix(llms/openai): fix mypy list-item type error in message conversion _convert_message_to_responses_input_items() was annotated to return list[dict[str, Any]], but the passthrough branch returns the LLMMessage argument unchanged. Lists are invariant in mypy, so a bare LLMMessage (TypedDict) isn't assignable into a list[dict[str, Any]] return - this was flagged by CI's type-checker job across all Python versions. Widened the return type (and the local list built in the tool_calls branch) to list[dict[str, Any] | LLMMessage], matching what the function actually returns. Confirmed with a local mypy run and the full openai/agent_utils test suites (176 passed). Co-Authored-By: Claude Sonnet 5 --- lib/crewai/src/crewai/llms/providers/openai/completion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 4b872d1d28..7df42e4f1a 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -645,7 +645,7 @@ async def _acall_responses( def _convert_message_to_responses_input_items( self, message: LLMMessage - ) -> list[dict[str, Any]]: + ) -> list[dict[str, Any] | LLMMessage]: """Convert a Chat-Completions-style message into Responses API input items. The Responses API has no message shape for an assistant turn carrying @@ -657,7 +657,7 @@ def _convert_message_to_responses_input_items( role = message.get("role") if role == "assistant" and message.get("tool_calls"): - items: list[dict[str, Any]] = [] + items: list[dict[str, Any] | LLMMessage] = [] for tool_call in message["tool_calls"]: function = tool_call.get("function", {}) items.append( From f6640b1cd8ab1e46cf9b407ce31c67ed7262cc4c Mon Sep 17 00:00:00 2001 From: Rip&Tear <84775494+theCyberTech@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:28:38 +0800 Subject: [PATCH 5/6] fix: harden Responses API tool call conversion --- .../llms/providers/openai/completion.py | 7 +++-- .../src/crewai/utilities/agent_utils.py | 4 ++- lib/crewai/tests/llms/openai/test_openai.py | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 6110640b52..ead2675c8c 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -683,14 +683,17 @@ def _convert_message_to_responses_input_items( if role == "assistant" and message.get("tool_calls"): items: list[dict[str, Any] | LLMMessage] = [] + if message.get("content"): + items.append({"role": "assistant", "content": message["content"]}) for tool_call in message["tool_calls"]: function = tool_call.get("function", {}) + args = function.get("arguments", "") items.append( { "type": "function_call", - "call_id": tool_call.get("id", ""), + "call_id": tool_call.get("id") or f"call_{id(tool_call)}", "name": function.get("name", ""), - "arguments": function.get("arguments", ""), + "arguments": args if isinstance(args, str) else json.dumps(args), } ) return items diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index c6330bd056..7bbe3ed49c 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -1277,7 +1277,9 @@ 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 (flat dict, no nested "function" key) + # OpenAI Responses API-style (flat dict, no nested "function" key). This + # intentionally accepts the same broad shape as the Bedrock check above; + # only provider paths that return lists reach this classifier. if ( isinstance(first_item, dict) and "name" in first_item diff --git a/lib/crewai/tests/llms/openai/test_openai.py b/lib/crewai/tests/llms/openai/test_openai.py index 4bbe93a60d..c0a7728835 100644 --- a/lib/crewai/tests/llms/openai/test_openai.py +++ b/lib/crewai/tests/llms/openai/test_openai.py @@ -1006,6 +1006,37 @@ def test_openai_responses_api_converts_assistant_tool_calls_message(): } +def test_openai_responses_api_preserves_assistant_content_with_tool_calls(): + """Assistant text must be retained when it accompanies tool calls.""" + llm = OpenAICompletion(model="gpt-4o-mini", api="responses") + + messages = [ + { + "role": "assistant", + "content": "I'll fetch that page now.", + "tool_calls": [ + { + "type": "function", + "function": { + "name": "fetch_page", + "arguments": {"url": "https://example.com"}, + }, + } + ], + } + ] + + params = llm._prepare_responses_params(messages) + + assert params["input"][0] == { + "role": "assistant", + "content": "I'll fetch that page now.", + } + assert params["input"][1]["type"] == "function_call" + assert params["input"][1]["call_id"].startswith("call_") + assert params["input"][1]["arguments"] == '{"url": "https://example.com"}' + + def test_openai_responses_api_converts_tool_result_message(): """Regression: tool-role messages (Chat-Completions shape) must become function_call_output input items for the Responses API. From 9e06006f7270589a978e9bc712db9f608439ac42 Mon Sep 17 00:00:00 2001 From: Rip&Tear <84775494+theCyberTech@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:42:38 +0800 Subject: [PATCH 6/6] style: format Responses API conversion --- lib/crewai/src/crewai/llms/providers/openai/completion.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index ead2675c8c..ca9588885b 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -693,7 +693,9 @@ def _convert_message_to_responses_input_items( "type": "function_call", "call_id": tool_call.get("id") or f"call_{id(tool_call)}", "name": function.get("name", ""), - "arguments": args if isinstance(args, str) else json.dumps(args), + "arguments": args + if isinstance(args, str) + else json.dumps(args), } ) return items