fix(tools): stop rewriting the authored tool description at construction (#6508)

* 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: <authored>"), 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
This commit is contained in:
João Moura
2026-07-10 20:33:57 -03:00
committed by GitHub
parent b65c8487d2
commit bfa652a7be
5 changed files with 231 additions and 36 deletions

View File

@@ -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: <authored>``). 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

View File

@@ -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:

View File

@@ -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(

View File

@@ -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)

View File

@@ -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: <authored>``).
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