Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion lib/crewai/src/crewai/tools/structured_tool.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return str(raw_result)

try:
Expand Down
30 changes: 28 additions & 2 deletions lib/crewai/tests/tools/test_base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
23 changes: 22 additions & 1 deletion lib/crewai/tests/tools/test_structured_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)


Expand Down
32 changes: 32 additions & 0 deletions lib/crewai/tests/tools/test_tool_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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] = []

Expand Down