From 4362a45deaff3ded6aeff5870c94c0ce1bbbd7d6 Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Thu, 9 Jul 2026 23:26:44 -0700 Subject: [PATCH] fix(tools): stop rewriting the authored tool description at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseTool.model_post_init silently replaced the public description field with the LLM-facing composite ("Tool Name: ...\nTool Arguments: ...\n Tool Description: "), breaking equality assertions on authored text and hiding the extra prompt tokens from token-careful authors. The authored description now survives construction as written. The composite is composed on demand via a new formatted_description property on BaseTool and CrewStructuredTool (shared format_description_for_llm helper), and every prompt path that relied on the baked-in composite — render_text_description_and_args, ToolUsage._render, and tool-usage error messages — now renders through it, so the text the LLM sees is unchanged. The helper strips any pre-existing composite block before composing, so tools deserialized from old checkpoints and adapters that still bake the composite into the field (e.g. the crewai-tools MCP adapter) don't get double-wrapped. BaseTool._generate_description remains as a no-op hook because subclasses override it and model_post_init still calls it. Fixes EPD-179. Co-Authored-By: Claude Fable 5 --- lib/crewai/src/crewai/tools/base_tool.py | 31 +++-- .../src/crewai/tools/structured_tool.py | 53 +++++++- lib/crewai/src/crewai/tools/tool_usage.py | 6 +- .../src/crewai/utilities/agent_utils.py | 2 +- lib/crewai/tests/tools/test_base_tool.py | 115 +++++++++++++++--- 5 files changed, 176 insertions(+), 31 deletions(-) diff --git a/lib/crewai/src/crewai/tools/base_tool.py b/lib/crewai/src/crewai/tools/base_tool.py index c6c3dba15..eb941bc26 100644 --- a/lib/crewai/src/crewai/tools/base_tool.py +++ b/lib/crewai/src/crewai/tools/base_tool.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Awaitable, Callable import importlib from inspect import Parameter, signature -import json import threading from typing import ( Any, @@ -37,9 +36,9 @@ from crewai.tools.structured_tool import ( _infer_result_schema_from_callable, _serialize_schema, build_schema_hint, + format_description_for_llm, ) from crewai.types.callback import SerializableCallable, _resolve_dotted_path -from crewai.utilities.pydantic_schema_utils import generate_model_description from crewai.utilities.string_utils import sanitize_tool_name @@ -479,15 +478,27 @@ class BaseTool(BaseModel, ABC): f"{self.__class__.__name__}Schema", **fields ) + @property + def formatted_description(self) -> str: + """LLM-facing composite of name, argument schema, and description. + + Use this when rendering the tool into a prompt; ``description`` + holds only the authored text. + """ + return format_description_for_llm(self.name, self.args_schema, self.description) + def _generate_description(self) -> None: - """Generate the tool description with a JSON schema for arguments.""" - schema = generate_model_description(self.args_schema) - args_json = json.dumps(schema["json_schema"]["schema"], indent=2) - self.description = ( - f"Tool Name: {sanitize_tool_name(self.name)}\n" - f"Tool Arguments: {args_json}\n" - f"Tool Description: {self.description}" - ) + """Deprecated hook kept for backward compatibility; does nothing. + + Historically this rewrote the public ``description`` field at + construction time into the LLM-facing composite (``Tool Name: …\\n + Tool Arguments: …\\nTool Description: ``). The authored + ``description`` is now preserved as written and the composite is + exposed separately via :attr:`formatted_description`. + + ``model_post_init`` still calls this so subclasses that override it + (e.g. adapters that customize the composite) keep working. + """ _BASE_TOOL_CLS = BaseTool diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index 8ecba8549..c1b94fda3 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -21,7 +21,10 @@ from pydantic import ( from typing_extensions import Self from crewai.utilities.logger import Logger -from crewai.utilities.pydantic_schema_utils import create_model_from_schema +from crewai.utilities.pydantic_schema_utils import ( + create_model_from_schema, + generate_model_description, +) from crewai.utilities.string_utils import sanitize_tool_name @@ -108,6 +111,45 @@ def build_schema_hint(args_schema: type[BaseModel]) -> str: return "" +def format_description_for_llm( + name: str, + args_schema: type[BaseModel] | None, + description: str, +) -> str: + """Compose the LLM-facing tool description. + + Combines the tool name, its argument JSON schema, and the authored + description into the prompt block agents see. The authored + ``description`` field itself is never mutated — prompt rendering calls + this on demand. + + Idempotent: if ``description`` already contains a composed block (e.g. + a tool deserialized from a checkpoint written by an older version, or + an adapter that bakes the composite into the field), only the authored + text after the last ``"Tool Description:"`` marker is used. + + Args: + name: The tool name (sanitized for the prompt). + args_schema: The tool's argument schema, if any. + description: The authored tool description. + + Returns: + The composed, LLM-facing description block. + """ + if "Tool Description:" in description: + description = description.split("Tool Description:")[-1].strip() + if args_schema is not None: + schema = generate_model_description(args_schema) + args_json = json.dumps(schema["json_schema"]["schema"], indent=2) + else: + args_json = "{}" + return ( + f"Tool Name: {sanitize_tool_name(name)}\n" + f"Tool Arguments: {args_json}\n" + f"Tool Description: {description}" + ) + + class ToolUsageLimitExceededError(Exception): """Exception raised when a tool has reached its maximum usage limit.""" @@ -141,6 +183,15 @@ class CrewStructuredTool(BaseModel): _logger: Logger = PrivateAttr(default_factory=Logger) _original_tool: Any = PrivateAttr(default=None) + @property + def formatted_description(self) -> str: + """LLM-facing composite of name, argument schema, and description. + + Use this when rendering the tool into a prompt; ``description`` + holds only the authored text. + """ + return format_description_for_llm(self.name, self.args_schema, self.description) + @model_validator(mode="after") def _validate_func(self) -> Self: if self.func is not None: diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index e92ba03ee..4a973add9 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -430,7 +430,7 @@ class ToolUsage: ).format( error=e, tool=sanitize_tool_name(tool.name), - tool_inputs=tool.description, + tool_inputs=tool.formatted_description, ) result = ToolUsageError( f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}" @@ -670,7 +670,7 @@ class ToolUsage: ).format( error=e, tool=sanitize_tool_name(tool.name), - tool_inputs=tool.description, + tool_inputs=tool.formatted_description, ) result = ToolUsageError( f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}" @@ -803,7 +803,7 @@ class ToolUsage: def _render(self) -> str: """Render the tool name and description in plain text.""" - descriptions = [tool.description for tool in self.tools] + descriptions = [tool.formatted_description for tool in self.tools] return "\n--\n".join(descriptions) def _function_calling( diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 91cfbb6db..47885f198 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -147,7 +147,7 @@ def render_text_description_and_args( Returns: Plain text description of tools. """ - tool_strings = [tool.description for tool in tools] + tool_strings = [tool.formatted_description for tool in tools] return "\n".join(tool_strings) diff --git a/lib/crewai/tests/tools/test_base_tool.py b/lib/crewai/tests/tools/test_base_tool.py index 52661fffc..14499bd1d 100644 --- a/lib/crewai/tests/tools/test_base_tool.py +++ b/lib/crewai/tests/tools/test_base_tool.py @@ -18,11 +18,16 @@ def test_creating_a_tool_using_annotation(): return question assert my_tool.name == "Name of my tool" - assert "Tool Name: name_of_my_tool" in my_tool.description - assert "Tool Arguments:" in my_tool.description - assert '"question"' in my_tool.description - assert '"type": "string"' in my_tool.description - assert "Tool Description: Clear description for what this tool is useful for" in my_tool.description + # The authored description is preserved as written; the LLM-facing + # composite lives at formatted_description. + assert my_tool.description == ( + "Clear description for what this tool is useful for, your agent will need this information to use it." + ) + assert "Tool Name: name_of_my_tool" in my_tool.formatted_description + assert "Tool Arguments:" in my_tool.formatted_description + assert '"question"' in my_tool.formatted_description + assert '"type": "string"' in my_tool.formatted_description + assert "Tool Description: Clear description for what this tool is useful for" in my_tool.formatted_description assert my_tool.args_schema.model_json_schema()["properties"] == { "question": {"title": "Question", "type": "string"} } @@ -33,9 +38,10 @@ def test_creating_a_tool_using_annotation(): converted_tool = my_tool.to_structured_tool() assert converted_tool.name == "Name of my tool" - assert "Tool Name: name_of_my_tool" in converted_tool.description - assert "Tool Arguments:" in converted_tool.description - assert '"question"' in converted_tool.description + assert converted_tool.description == my_tool.description + assert "Tool Name: name_of_my_tool" in converted_tool.formatted_description + assert "Tool Arguments:" in converted_tool.formatted_description + assert '"question"' in converted_tool.formatted_description assert converted_tool.args_schema.model_json_schema()["properties"] == { "question": {"title": "Question", "type": "string"} } @@ -56,11 +62,16 @@ def test_creating_a_tool_using_baseclass(): my_tool = MyCustomTool() assert my_tool.name == "Name of my tool" - assert "Tool Name: name_of_my_tool" in my_tool.description - assert "Tool Arguments:" in my_tool.description - assert '"question"' in my_tool.description - assert '"type": "string"' in my_tool.description - assert "Tool Description: Clear description for what this tool is useful for" in my_tool.description + # The authored description is preserved as written; the LLM-facing + # composite lives at formatted_description. + assert my_tool.description == ( + "Clear description for what this tool is useful for, your agent will need this information to use it." + ) + assert "Tool Name: name_of_my_tool" in my_tool.formatted_description + assert "Tool Arguments:" in my_tool.formatted_description + assert '"question"' in my_tool.formatted_description + assert '"type": "string"' in my_tool.formatted_description + assert "Tool Description: Clear description for what this tool is useful for" in my_tool.formatted_description assert my_tool.args_schema.model_json_schema()["properties"] == { "question": {"title": "Question", "type": "string"} } @@ -69,9 +80,10 @@ def test_creating_a_tool_using_baseclass(): converted_tool = my_tool.to_structured_tool() assert converted_tool.name == "Name of my tool" - assert "Tool Name: name_of_my_tool" in converted_tool.description - assert "Tool Arguments:" in converted_tool.description - assert '"question"' in converted_tool.description + assert converted_tool.description == my_tool.description + assert "Tool Name: name_of_my_tool" in converted_tool.formatted_description + assert "Tool Arguments:" in converted_tool.formatted_description + assert '"question"' in converted_tool.formatted_description assert converted_tool.args_schema.model_json_schema()["properties"] == { "question": {"title": "Question", "type": "string"} } @@ -695,3 +707,74 @@ class TestToolDecoratorArunValidation: with pytest.raises(ValueError, match="validation failed"): await async_execute.arun(wrong_arg="value") + + +class TestAuthoredDescriptionPreserved: + """Regression tests for EPD-179: BaseTool.model_post_init silently + rewrote the authored ``description`` into the LLM-facing composite + (``Tool Name: …\\nTool Arguments: …\\nTool Description: ``). + The authored field must survive construction as written, with the + composite exposed separately at ``formatted_description``. + """ + + AUTHORED = "Returns the current temperature for a city." + + def _make_tool(self) -> BaseTool: + class TempArgs(BaseModel): + city: str = Field(description="City name to look up.") + + class TempTool(BaseTool): + name: str = "get_temperature" + description: str = TestAuthoredDescriptionPreserved.AUTHORED + args_schema: type[BaseModel] = TempArgs + + def _run(self, city: str) -> str: + return f"22C in {city}" + + return TempTool() + + def test_description_equals_authored_text(self): + tool_instance = self._make_tool() + assert tool_instance.description == self.AUTHORED + + def test_formatted_description_contains_composite(self): + tool_instance = self._make_tool() + formatted = tool_instance.formatted_description + assert "Tool Name: get_temperature" in formatted + assert "Tool Arguments:" in formatted + assert '"city"' in formatted + assert formatted.endswith(f"Tool Description: {self.AUTHORED}") + + def test_formatted_description_tracks_later_description_edits(self): + tool_instance = self._make_tool() + tool_instance.description = "Edited description." + assert tool_instance.formatted_description.endswith( + "Tool Description: Edited description." + ) + + def test_composite_is_not_reapplied_to_prebaked_descriptions(self): + """A description that already contains a composed block (old + checkpoints, adapters that bake the composite into the field) must + not be double-wrapped.""" + tool_instance = self._make_tool() + tool_instance.description = ( + "Tool Name: get_temperature\n" + 'Tool Arguments: {"city": "str"}\n' + f"Tool Description: {self.AUTHORED}" + ) + formatted = tool_instance.formatted_description + assert formatted.count("Tool Description:") == 1 + assert formatted.endswith(f"Tool Description: {self.AUTHORED}") + + def test_prompt_rendering_still_uses_composite(self): + from crewai.utilities.agent_utils import render_text_description_and_args + + tool_instance = self._make_tool() + structured = tool_instance.to_structured_tool() + assert structured.description == self.AUTHORED + + for candidate in (tool_instance, structured): + rendered = render_text_description_and_args([candidate]) + assert "Tool Name: get_temperature" in rendered + assert "Tool Arguments:" in rendered + assert f"Tool Description: {self.AUTHORED}" in rendered