From afce744720cc5367b0fbbd9d4f8e11b989a2b658 Mon Sep 17 00:00:00 2001 From: Joao Moura Date: Fri, 10 Jul 2026 00:10:19 -0700 Subject: [PATCH] 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 --- .../src/crewai/tools/structured_tool.py | 38 ++++++++++++++++--- .../src/crewai/utilities/agent_utils.py | 22 ++++++++--- lib/crewai/tests/tools/test_base_tool.py | 14 +++++++ 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index c1b94fda3..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 @@ -111,6 +112,30 @@ 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, @@ -123,10 +148,12 @@ def format_description_for_llm( ``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. + 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). @@ -136,8 +163,7 @@ def format_description_for_llm( Returns: The composed, LLM-facing description block. """ - if "Tool Description:" in description: - description = description.split("Tool Description:")[-1].strip() + 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) diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 47885f198..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.formatted_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 14499bd1d..b879fecf7 100644 --- a/lib/crewai/tests/tools/test_base_tool.py +++ b/lib/crewai/tests/tools/test_base_tool.py @@ -752,6 +752,20 @@ class TestAuthoredDescriptionPreserved: "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