diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index ac1cccbeb..8242328ee 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -847,7 +847,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin): func_name = sanitize_tool_name( func_info.get("name", "") or tool_call.get("name", "") ) - func_args = func_info.get("arguments", "{}") or tool_call.get("input", {}) + func_args = func_info.get("arguments") or tool_call.get("input") or {} return call_id, func_name, func_args return None diff --git a/lib/crewai/tests/agents/test_async_agent_executor.py b/lib/crewai/tests/agents/test_async_agent_executor.py index 01297bdcc..208bb5474 100644 --- a/lib/crewai/tests/agents/test_async_agent_executor.py +++ b/lib/crewai/tests/agents/test_async_agent_executor.py @@ -331,6 +331,97 @@ class TestInvokeStepCallback: executor._invoke_step_callback(answer) +class TestParseNativeToolCall: + """Tests for _parse_native_tool_call covering multiple provider formats. + + Regression tests for issue #4748: Bedrock tool calls with 'input' field + were returning empty arguments because the old code used + ``func_info.get("arguments", "{}")`` which always returns a truthy + default, preventing the ``or`` fallback to ``tool_call.get("input")``. + """ + + def test_openai_dict_format(self, executor: CrewAgentExecutor) -> None: + """Test OpenAI dict format with function.arguments.""" + tool_call = { + "id": "call_abc123", + "function": { + "name": "search_tool", + "arguments": '{"query": "test"}', + }, + } + result = executor._parse_native_tool_call(tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "call_abc123" + assert func_name == "search_tool" + assert func_args == '{"query": "test"}' + + def test_bedrock_dict_format_extracts_input(self, executor: CrewAgentExecutor) -> None: + """Test AWS Bedrock dict format extracts 'input' field for arguments.""" + tool_call = { + "toolUseId": "tooluse_xyz789", + "name": "search_tool", + "input": {"query": "AWS Bedrock features"}, + } + result = executor._parse_native_tool_call(tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "tooluse_xyz789" + assert func_name == "search_tool" + assert func_args == {"query": "AWS Bedrock features"} + + def test_bedrock_dict_format_not_empty_regression(self, executor: CrewAgentExecutor) -> None: + """Regression test for #4748: Bedrock args must NOT be empty. + + Before the fix, ``func_info.get("arguments", "{}")`` returned the + truthy string ``"{}"`` which short-circuited the ``or`` operator, + so ``tool_call.get("input", {})`` was never evaluated. + """ + tool_call = { + "toolUseId": "tooluse_regression", + "name": "search_tool", + "input": {"query": "important query", "limit": 5}, + } + result = executor._parse_native_tool_call(tool_call) + assert result is not None + _, _, func_args = result + assert func_args == {"query": "important query", "limit": 5} + assert func_args != {} + assert func_args != "{}" + + def test_dict_without_function_or_input_returns_empty(self, executor: CrewAgentExecutor) -> None: + """Test dict format with neither function.arguments nor input.""" + tool_call = { + "id": "call_noop", + "name": "no_args_tool", + } + result = executor._parse_native_tool_call(tool_call) + assert result is not None + _, func_name, func_args = result + assert func_name == "no_args_tool" + assert func_args == {} + + def test_openai_arguments_preferred_over_input(self, executor: CrewAgentExecutor) -> None: + """Test that function.arguments takes precedence over input.""" + tool_call = { + "id": "call_both", + "function": { + "name": "dual_tool", + "arguments": '{"from": "openai"}', + }, + "input": {"from": "bedrock"}, + } + result = executor._parse_native_tool_call(tool_call) + assert result is not None + _, _, func_args = result + assert func_args == '{"from": "openai"}' + + def test_returns_none_for_unrecognized(self, executor: CrewAgentExecutor) -> None: + """Test that None is returned for unrecognized tool call formats.""" + result = executor._parse_native_tool_call(12345) + assert result is None + + class TestAsyncLLMResponseHelper: """Tests for aget_llm_response helper function.""" @@ -385,4 +476,4 @@ class TestAsyncLLMResponseHelper: messages=[{"role": "user", "content": "test"}], callbacks=[], printer=Printer(), - ) \ No newline at end of file + ) diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py index 3d249906a..3999d218b 100644 --- a/lib/crewai/tests/utilities/test_agent_utils.py +++ b/lib/crewai/tests/utilities/test_agent_utils.py @@ -17,6 +17,7 @@ from crewai.utilities.agent_utils import ( _format_messages_for_summary, _split_messages_into_chunks, convert_tools_to_openai_schema, + extract_tool_call_info, parse_tool_call_args, summarize_messages, ) @@ -1049,3 +1050,132 @@ class TestParseToolCallArgs: _, error = parse_tool_call_args("{bad json}", "tool", "call_7") assert error is not None assert set(error.keys()) == {"call_id", "func_name", "result", "from_cache", "original_tool"} + + +class TestExtractToolCallInfo: + """Tests for extract_tool_call_info covering multiple provider formats.""" + + def test_openai_dict_format(self) -> None: + """Test OpenAI dict format with function.arguments.""" + tool_call = { + "id": "call_abc123", + "function": { + "name": "search_tool", + "arguments": '{"query": "test"}', + }, + } + 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 == "search_tool" + assert func_args == '{"query": "test"}' + + def test_bedrock_dict_format_with_input(self) -> None: + """Test AWS Bedrock dict format uses 'input' field for arguments.""" + tool_call = { + "toolUseId": "tooluse_xyz789", + "name": "search_tool", + "input": {"query": "AWS Bedrock features"}, + } + result = extract_tool_call_info(tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "tooluse_xyz789" + assert func_name == "search_tool" + assert func_args == {"query": "AWS Bedrock features"} + + def test_bedrock_dict_format_does_not_return_empty_args(self) -> None: + """Test that Bedrock format does not silently return empty args. + + This is the core regression test for issue #4748: when the dict has no + 'function' key but does have 'input', the arguments must come from + 'input' rather than defaulting to an empty dict/string. + """ + tool_call = { + "toolUseId": "tooluse_abc", + "name": "search_tool", + "input": {"query": "important query", "limit": 5}, + } + result = extract_tool_call_info(tool_call) + assert result is not None + _, _, func_args = result + # Must NOT be empty — the bug was that func_args came back as "{}" + assert func_args == {"query": "important query", "limit": 5} + + def test_dict_format_without_function_or_input_returns_empty(self) -> None: + """Test dict format with neither function.arguments nor input.""" + tool_call = { + "id": "call_noop", + "name": "no_args_tool", + } + result = extract_tool_call_info(tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "call_noop" + assert func_name == "no_args_tool" + assert func_args == {} + + def test_openai_dict_arguments_preferred_over_input(self) -> None: + """Test that function.arguments takes precedence over input when both exist.""" + tool_call = { + "id": "call_both", + "function": { + "name": "dual_tool", + "arguments": '{"from": "openai"}', + }, + "input": {"from": "bedrock"}, + } + result = extract_tool_call_info(tool_call) + assert result is not None + _, _, func_args = result + assert func_args == '{"from": "openai"}' + + def test_dict_format_generates_id_when_missing(self) -> None: + """Test that a call ID is generated when neither id nor toolUseId exist.""" + tool_call = { + "name": "some_tool", + "input": {"key": "value"}, + } + result = extract_tool_call_info(tool_call) + assert result is not None + call_id, _, _ = result + assert call_id.startswith("call_") + + def test_returns_none_for_unrecognized_format(self) -> None: + """Test that None is returned for unrecognized tool call formats.""" + result = extract_tool_call_info(12345) + assert result is None + + def test_openai_object_format(self) -> None: + """Test OpenAI object format with .function attribute.""" + mock_function = MagicMock() + mock_function.name = "calculator" + mock_function.arguments = '{"expression": "2+2"}' + + mock_tool_call = MagicMock() + mock_tool_call.function = mock_function + mock_tool_call.id = "call_obj_123" + # Ensure it doesn't have function_call attribute + del mock_tool_call.function_call + + result = extract_tool_call_info(mock_tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "call_obj_123" + assert func_name == "calculator" + assert func_args == '{"expression": "2+2"}' + + def test_anthropic_object_format(self) -> None: + """Test Anthropic ToolUseBlock format with .name and .input attributes.""" + mock_tool_call = MagicMock(spec=["name", "input", "id"]) + mock_tool_call.name = "search" + mock_tool_call.input = {"query": "hello"} + mock_tool_call.id = "toolu_abc" + + result = extract_tool_call_info(mock_tool_call) + assert result is not None + call_id, func_name, func_args = result + assert call_id == "toolu_abc" + assert func_name == "search" + assert func_args == {"query": "hello"}