diff --git a/lib/crewai/src/crewai/llms/providers/openai/completion.py b/lib/crewai/src/crewai/llms/providers/openai/completion.py index 78e8d23e8f..ca9588885b 100644 --- a/lib/crewai/src/crewai/llms/providers/openai/completion.py +++ b/lib/crewai/src/crewai/llms/providers/openai/completion.py @@ -668,6 +668,49 @@ async def _acall_responses( response_model=response_model, ) + def _convert_message_to_responses_input_items( + self, message: LLMMessage + ) -> 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 + ``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] | 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") or f"call_{id(tool_call)}", + "name": function.get("name", ""), + "arguments": args + if isinstance(args, str) + else json.dumps(args), + } + ) + 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], @@ -683,7 +726,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": @@ -694,7 +737,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/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index d2090b0a09..7bbe3ed49c 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -1238,7 +1238,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 @@ -1270,6 +1277,15 @@ 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). 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 + 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_openai.py b/lib/crewai/tests/llms/openai/test_openai.py index d5bc797d84..c0a7728835 100644 --- a/lib/crewai/tests/llms/openai/test_openai.py +++ b/lib/crewai/tests/llms/openai/test_openai.py @@ -970,6 +970,140 @@ 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_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. + """ + 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.""" 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."""