diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index 8ecba85496..9e22eb19b1 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Mapping, Sequence import inspect import json import textwrap @@ -58,6 +58,14 @@ def _format_tool_output_for_agent(tool: Any, raw_result: Any) -> str: result_schema = getattr(tool, "result_schema", None) if not (isinstance(result_schema, type) and issubclass(result_schema, BaseModel)): + if isinstance(raw_result, Mapping) or ( + isinstance(raw_result, Sequence) + and not isinstance(raw_result, str | bytes | bytearray) + ): + try: + return json.dumps(raw_result) + except (TypeError, ValueError): + pass return str(raw_result) try: diff --git a/lib/crewai/tests/tools/test_base_tool.py b/lib/crewai/tests/tools/test_base_tool.py index 52661fffc0..4302fed554 100644 --- a/lib/crewai/tests/tools/test_base_tool.py +++ b/lib/crewai/tests/tools/test_base_tool.py @@ -460,12 +460,22 @@ def test_base_tools_return_raw_result_and_json_agent_text( expected_agent_payload ) - def test_base_tool_does_not_infer_non_pydantic_return_annotation(self) -> None: + def test_base_tool_serializes_non_pydantic_mapping_output_as_json(self) -> None: t = DictAnnotatedSearchTool() raw_result = t.run(query="crew") assert raw_result == {"query": "crew", "score": 0.5} + assert json.loads(t.format_output_for_agent(raw_result)) == { + "query": "crew", + "score": 0.5, + } + + def test_base_tool_falls_back_for_unserializable_mapping_output(self) -> None: + t = DictAnnotatedSearchTool() + raw_result: dict[str, object] = {} + raw_result["self"] = raw_result + assert t.format_output_for_agent(raw_result) == str(raw_result) @pytest.mark.parametrize( @@ -506,7 +516,7 @@ def test_decorator_tools_return_raw_result_and_json_agent_text( expected_agent_payload ) - def test_decorator_tool_does_not_infer_non_pydantic_return_annotation( + def test_decorator_tool_serializes_non_pydantic_mapping_output_as_json( self, ) -> None: @tool("search") @@ -517,6 +527,22 @@ def search(query: str) -> dict[str, object]: raw_result = search.run(query="crew") assert raw_result == {"query": "crew", "score": 0.5} + assert json.loads(search.format_output_for_agent(raw_result)) == { + "query": "crew", + "score": 0.5, + } + + def test_decorator_tool_falls_back_for_unserializable_mapping_output( + self, + ) -> None: + @tool("search") + def search(query: str) -> dict[str, object]: + """Search for a query.""" + return {"query": query, "score": 0.5} + + raw_result: dict[str, object] = {} + raw_result["self"] = raw_result + assert search.format_output_for_agent(raw_result) == str(raw_result) def test_explicit_result_schema_wins_over_return_annotation(self) -> None: diff --git a/lib/crewai/tests/tools/test_structured_tool.py b/lib/crewai/tests/tools/test_structured_tool.py index 0241abbcff..7c3a24c53f 100644 --- a/lib/crewai/tests/tools/test_structured_tool.py +++ b/lib/crewai/tests/tools/test_structured_tool.py @@ -164,7 +164,7 @@ def test_from_function_returns_raw_result_and_json_agent_text( ) -def test_from_function_does_not_infer_non_pydantic_result_schema(): +def test_from_function_serializes_non_pydantic_mapping_output_as_json(): tool = CrewStructuredTool.from_function( func=_build_plain_structured_value, name="build_value", @@ -173,6 +173,27 @@ def test_from_function_does_not_infer_non_pydantic_result_schema(): raw_result = tool.invoke({"value": "crew"}) assert raw_result == {"value": "crew", "count": 1} + assert json.loads(tool.format_output_for_agent(raw_result)) == { + "value": "crew", + "count": 1, + } + + +def test_from_function_falls_back_for_circular_mapping_output(): + def build_value(value: str) -> dict[str, object]: + """Build a value.""" + result: dict[str, object] = {"value": value} + result["self"] = result + return result + + tool = CrewStructuredTool.from_function( + func=build_value, + name="build_value", + ) + + raw_result = tool.invoke({"value": "crew"}) + + assert raw_result["self"] is raw_result assert tool.format_output_for_agent(raw_result) == str(raw_result) diff --git a/lib/crewai/tests/tools/test_tool_usage.py b/lib/crewai/tests/tools/test_tool_usage.py index 9d61c93a9b..1c71a7bbe9 100644 --- a/lib/crewai/tests/tools/test_tool_usage.py +++ b/lib/crewai/tests/tools/test_tool_usage.py @@ -24,6 +24,7 @@ register_after_tool_call_hook, ) from crewai.tools import BaseTool +from crewai.tools.base_tool import Tool from crewai.tools.tool_calling import ToolCalling from crewai.tools.tool_usage import ToolUsage from crewai.utilities.tool_utils import execute_tool_and_check_finality @@ -62,6 +63,15 @@ def _run(self, query: str) -> SearchOutput: return SearchOutput(query=query, score=0.7) +class NestedDictLangChainTool: + name = "mock_api_tool" + description = "Fetches data from a mock API." + + @staticmethod + def func(query: str) -> dict[str, object]: + return {"status": "success", "data": {"items": [{"id": 1, "value": query}]}} + + # Example agent and task example_agent = Agent( role="Number Generator", @@ -163,6 +173,28 @@ def test_tool_usage_returns_json_agent_text_for_typed_output(): assert json.loads(result) == {"query": "crew", "score": 0.7} +def test_tool_usage_serializes_plain_nested_dict_output_as_json(): + structured_tool = Tool.from_langchain( + NestedDictLangChainTool() + ).to_structured_tool() + action = AgentAction( + thought="", + tool="mock_api_tool", + tool_input='{"query": "crew"}', + text='Action: mock_api_tool\nAction Input: {"query": "crew"}', + ) + + result = execute_tool_and_check_finality( + agent_action=action, + tools=[structured_tool], + ) + + assert json.loads(result.result) == { + "status": "success", + "data": {"items": [{"id": 1, "value": "crew"}]}, + } + + def test_tool_usage_cache_callback_receives_raw_typed_output(): raw_results: list[object] = []