diff --git a/docs/edge/en/concepts/tools.mdx b/docs/edge/en/concepts/tools.mdx index f3506fc32..b84ad0f28 100644 --- a/docs/edge/en/concepts/tools.mdx +++ b/docs/edge/en/concepts/tools.mdx @@ -211,6 +211,23 @@ raw_result = tool.run(query="CrewAI") print(raw_result.score) ``` +To send a custom representation to the agent, such as Markdown, override `format_output_for_agent` on your `BaseTool` subclass. This does not change direct execution: `tool.run(...)` still returns the raw Python value. + +```python Code +class SearchTool(BaseTool): + name: str = "Search" + description: str = "Searches for a query and returns the top match score." + + def _run(self, query: str) -> SearchResult: + return SearchResult(query=query, score=0.97) + + def format_output_for_agent(self, raw_result: object) -> str: + result = SearchResult.model_validate(raw_result) + return f"### Search result\n\n- Query: `{result.query}`\n- Score: {result.score}" +``` + +If you do not override `format_output_for_agent`, CrewAI uses the default typed-output behavior: Pydantic outputs become JSON for the agent, and untyped outputs use `str(raw_result)`. + If validation or serialization fails during agent execution, CrewAI emits a runtime warning and falls back to `str(raw_result)` for the agent-facing text. Direct tool calls still receive the raw result. ## Asynchronous Tool Support diff --git a/docs/edge/en/guides/tools/publish-custom-tools.mdx b/docs/edge/en/guides/tools/publish-custom-tools.mdx index de7109f88..2418c4fa5 100644 --- a/docs/edge/en/guides/tools/publish-custom-tools.mdx +++ b/docs/edge/en/guides/tools/publish-custom-tools.mdx @@ -142,6 +142,27 @@ class GeolocateTool(BaseTool): return {"latitude": 40.7128, "longitude": -74.0060} ``` +If agents should receive a custom text format instead of JSON, override `format_output_for_agent` on your `BaseTool` subclass. This is useful when the best agent-facing representation is Markdown, a terse summary, or another format derived from the same raw result. + +```python +class GeolocateTool(BaseTool): + name: str = "Geolocate" + description: str = "Converts a street address into latitude/longitude coordinates." + + def _run(self, address: str) -> GeolocateResult: + return GeolocateResult(latitude=40.7128, longitude=-74.0060) + + def format_output_for_agent(self, raw_result: object) -> str: + result = GeolocateResult.model_validate(raw_result) + return ( + f"### Coordinates\n\n" + f"- Latitude: `{result.latitude}`\n" + f"- Longitude: `{result.longitude}`" + ) +``` + +The override only controls the text sent to the agent. Direct users of your package still receive the raw value from `tool.run(...)`. + ### Optional: Environment Variables If your tool requires API keys or other configuration, declare them with `env_vars` so users know what to set: diff --git a/docs/edge/en/learn/create-custom-tools.mdx b/docs/edge/en/learn/create-custom-tools.mdx index 14d1b7588..d8ed98a1f 100644 --- a/docs/edge/en/learn/create-custom-tools.mdx +++ b/docs/edge/en/learn/create-custom-tools.mdx @@ -147,6 +147,50 @@ def product_lookup(sku: str) -> dict[str, object]: } ``` +#### Customize the Text Sent to the Agent + +By default, typed tool outputs are sent to the agent as JSON. If your agent should receive Markdown, XML, or a compact human-readable summary instead, subclass `BaseTool` and override `format_output_for_agent`. + +This only changes the agent-facing text. Direct calls to `tool.run(...)` still return the raw Python value from `_run`. + +```python Code +from crewai.tools import BaseTool +from pydantic import BaseModel, Field + +class ProductLookupResult(BaseModel): + sku: str = Field(description="The product SKU.") + name: str = Field(description="The product name.") + in_stock: bool = Field(description="Whether the product is available.") + +class ProductLookupTool(BaseTool): + name: str = "Product Lookup" + description: str = "Look up product availability by SKU." + + def _run(self, sku: str) -> ProductLookupResult: + return ProductLookupResult( + sku=sku, + name="CrewAI Enterprise License", + in_stock=True, + ) + + def format_output_for_agent(self, raw_result: object) -> str: + result = ProductLookupResult.model_validate(raw_result) + status = "in stock" if result.in_stock else "out of stock" + return ( + f"### {result.name}\n\n" + f"- SKU: `{result.sku}`\n" + f"- Status: **{status}**" + ) + +tool = ProductLookupTool() +result = tool.run(sku="CREW-ENT") + +# Direct Python calls receive the raw Pydantic object. +print(result.name) +``` + +When an agent calls `ProductLookupTool`, it receives the Markdown returned by `format_output_for_agent`. When you do not override this method, CrewAI uses the default behavior: validate typed outputs and serialize them to JSON, or use `str(raw_result)` for untyped outputs. + Use typed outputs for tool results that have stable fields, nested data, lists, IDs, status values, scores, or any structure the agent should interpret precisely. Plain strings are still fine for simple prose results. ### Defining a Cache Function for the Tool diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index 1151a749a..33b1c5f69 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -52,6 +52,10 @@ def _infer_output_schema_from_callable( def _format_tool_output_for_agent(tool: Any, raw_result: Any) -> str: + original_tool = getattr(tool, "_original_tool", None) + if original_tool is not None: + return cast(str, original_tool.format_output_for_agent(raw_result)) + output_schema = getattr(tool, "output_schema", None) if output_schema is None: return str(raw_result) diff --git a/lib/crewai/tests/tools/test_base_tool.py b/lib/crewai/tests/tools/test_base_tool.py index d34d83828..3616132f7 100644 --- a/lib/crewai/tests/tools/test_base_tool.py +++ b/lib/crewai/tests/tools/test_base_tool.py @@ -584,6 +584,30 @@ class TestToolOutputSchema: "score": 0.8, } + def test_custom_agent_output_formatter_carries_over_to_structured_tool( + self, + ) -> None: + class MarkdownSearchTool(BaseTool): + name: str = "markdown_search" + description: str = "Search for information" + output_schema: type[BaseModel] = SearchOutput + + def _run(self, query: str) -> SearchOutput: + return SearchOutput(query=query, score=0.8) + + def format_output_for_agent(self, raw_result: object) -> str: + result = self.output_schema.model_validate(raw_result) + return f"### Search result\n\n- Query: `{result.query}`\n- Score: {result.score}" + + structured = MarkdownSearchTool().to_structured_tool() + + raw_result = structured.invoke({"query": "crew"}) + + assert raw_result == SearchOutput(query="crew", score=0.8) + assert structured.format_output_for_agent(raw_result) == ( + "### Search result\n\n- Query: `crew`\n- Score: 0.8" + ) + # Async arun() Schema Validation Tests diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py index e5d2855b5..7866d0e01 100644 --- a/lib/crewai/tests/utilities/test_agent_utils.py +++ b/lib/crewai/tests/utilities/test_agent_utils.py @@ -1079,6 +1079,51 @@ class TestExecuteSingleNativeToolCall: "score": 0.9, } + def test_custom_agent_output_formatter_is_used_from_structured_tool( + self, + ) -> None: + clear_before_tool_call_hooks() + clear_after_tool_call_hooks() + + class SearchOutput(BaseModel): + query: str + score: float + + class MarkdownSearchTool(BaseTool): + name: str = "markdown_search" + description: str = "Search for a query" + output_schema: type[BaseModel] = SearchOutput + + def _run(self, query: str) -> SearchOutput: + return SearchOutput(query=query, score=0.9) + + def format_output_for_agent(self, raw_result: Any) -> str: + result = self.output_schema.model_validate(raw_result) + return f"### {result.query}\n\nScore: **{result.score}**" + + tool = MarkdownSearchTool() + tool_call = MagicMock() + tool_call.id = "call_1" + tool_call.function.name = "markdown_search" + tool_call.function.arguments = '{"query": "crew"}' + + result = execute_single_native_tool_call( + tool_call, + available_functions={"markdown_search": tool._run}, + original_tools=[], + structured_tools=[tool.to_structured_tool()], + tools_handler=None, + agent=None, + task=None, + crew=None, + event_source=MagicMock(), + printer=None, + verbose=False, + ) + + assert result.result == "### crew\n\nScore: **0.9**" + assert result.tool_message["content"] == "### crew\n\nScore: **0.9**" + def test_after_hook_includes_raw_tool_result_for_typed_output(self) -> None: clear_after_tool_call_hooks()