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
31 changes: 21 additions & 10 deletions lib/crewai/src/crewai/tools/base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from collections.abc import Awaitable, Callable
import importlib
from inspect import Parameter, signature
import json
import threading
from typing import (
Any,
Expand Down Expand Up @@ -37,9 +36,9 @@
_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


Expand Down Expand Up @@ -479,15 +478,27 @@ def _set_args_schema(self) -> None:
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
Expand Down
79 changes: 78 additions & 1 deletion lib/crewai/src/crewai/tools/structured_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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
Expand All @@ -21,7 +22,10 @@
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


Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions lib/crewai/src/crewai/tools/tool_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ async def _ause(
).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)}"
Expand Down Expand Up @@ -670,7 +670,7 @@ def _use(
).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)}"
Expand Down Expand Up @@ -803,7 +803,7 @@ def _select_tool(self, tool_name: str) -> Any:

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(
Expand Down
22 changes: 16 additions & 6 deletions lib/crewai/src/crewai/utilities/agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@
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 (
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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)

Expand Down
129 changes: 113 additions & 16 deletions lib/crewai/tests/tools/test_base_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ def my_tool(question: str) -> str:
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"}
}
Expand All @@ -33,9 +38,10 @@ def my_tool(question: str) -> str:
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"}
}
Expand All @@ -56,11 +62,16 @@ def _run(self, question: str) -> str:
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"}
}
Expand All @@ -69,9 +80,10 @@ def _run(self, question: str) -> str:
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"}
}
Expand Down Expand Up @@ -695,3 +707,88 @@ async def async_execute(code: str) -> str:

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
Loading