From bfa652a7be8637562cc9b0833f75d927a64552d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Fri, 10 Jul 2026 20:33:57 -0300 Subject: [PATCH] fix(tools): stop rewriting the authored tool description at construction (#6508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tools): stop rewriting the authored tool description at construction 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 * fix(tools): harden composite-description handling after review - Anchor the pre-baked-composite check to the actual three-line block shape instead of a naive substring match, so authored prose that merely mentions "Tool Description:" is never truncated (CodeRabbit / Bugbot review finding). Shared as strip_composite_description_prefix() and reused by the function- calling schema builder, which had the same naive split. - Make render_text_description_and_args tolerate duck-typed tools without a real formatted_description string (fixes CI: step-executor tests pass Mock tools whose auto-created attribute is not a str). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> --- lib/crewai/src/crewai/tools/base_tool.py | 31 +++-- .../src/crewai/tools/structured_tool.py | 79 ++++++++++- lib/crewai/src/crewai/tools/tool_usage.py | 6 +- .../src/crewai/utilities/agent_utils.py | 22 ++- lib/crewai/tests/tools/test_base_tool.py | 129 +++++++++++++++--- 5 files changed, 231 insertions(+), 36 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..e868cd7da 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable import inspect import json +import re import textwrap from typing import TYPE_CHECKING, Annotated, Any, cast, get_type_hints import warnings @@ -21,7 +22,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 +112,70 @@ def build_schema_hint(args_schema: type[BaseModel]) -> str: return "" +# Matches a description that IS a pre-composed LLM block (as written by +# older versions into the field, and by adapters that still bake it in). +# Anchored to the full three-line shape so authored prose that merely +# mentions "Tool Description:" is never mistaken for a composite. Greedy +# ``.*`` keeps only the text after the LAST marker, matching the historical +# split behavior for nested pre-baked blocks. +_COMPOSITE_DESCRIPTION_RE = re.compile( + r"^Tool Name:.*\nTool Arguments:.*\nTool Description:\s*", + re.DOTALL, +) + + +def strip_composite_description_prefix(description: str) -> str: + """Return the authored text from a pre-composed LLM description block. + + Descriptions that don't start with the composite shape are returned + unchanged. + """ + match = _COMPOSITE_DESCRIPTION_RE.match(description) + if match: + return description[match.end() :] + return description + + +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 *is* 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 marker is used. The check is anchored to the composite + shape, so authored prose that merely mentions ``"Tool Description:"`` + passes through untouched. + + 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. + """ + description = strip_composite_description_prefix(description) + 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 +209,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..d2090b0a0 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -27,7 +27,10 @@ from crewai.agents.parser import ( from crewai.llms.base_llm import BaseLLM, call_stop_override from crewai.tools import BaseTool as CrewAITool from crewai.tools.base_tool import BaseTool -from crewai.tools.structured_tool import CrewStructuredTool +from crewai.tools.structured_tool import ( + CrewStructuredTool, + strip_composite_description_prefix, +) from crewai.tools.tool_types import ToolResult from crewai.utilities.errors import AgentRepositoryError from crewai.utilities.exceptions.context_window_exceeding_exception import ( @@ -147,7 +150,14 @@ def render_text_description_and_args( Returns: Plain text description of tools. """ - tool_strings = [tool.description for tool in tools] + # Fall back to the raw description for duck-typed tools (including test + # mocks) that don't provide a real formatted_description string. + tool_strings = [ + formatted + if isinstance((formatted := getattr(tool, "formatted_description", None)), str) + else tool.description + for tool in tools + ] return "\n".join(tool_strings) @@ -190,10 +200,10 @@ def convert_tools_to_openai_schema( except Exception: parameters = {} - # BaseTool formats description as "Tool Name: ...\nTool Arguments: ...\nTool Description: {original}" - description = tool.description - if "Tool Description:" in description: - description = description.split("Tool Description:")[-1].strip() + # Old checkpoints and some adapters bake the composed LLM block + # ("Tool Name: ...\nTool Arguments: ...\nTool Description: {authored}") + # into the description field; keep only the authored text here. + description = strip_composite_description_prefix(tool.description) sanitized_name = sanitize_tool_name(tool.name) diff --git a/lib/crewai/tests/tools/test_base_tool.py b/lib/crewai/tests/tools/test_base_tool.py index 52661fffc..b879fecf7 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,88 @@ 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_prose_mentioning_the_marker_is_not_truncated(self): + """Authored text that merely mentions "Tool Description:" must reach + the LLM untouched — only descriptions that ARE a pre-composed block + (anchored three-line shape) get stripped.""" + tool_instance = self._make_tool() + prose = ( + "Formats prompts. The output includes a line reading " + "'Tool Description:' followed by the tool's summary." + ) + tool_instance.description = prose + assert tool_instance.formatted_description.endswith( + f"Tool Description: {prose}" + ) + + 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