From af66e3981656c24da8814602225f4e297eb39fb3 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Wed, 5 Nov 2025 14:53:16 -0500 Subject: [PATCH 1/2] fix: Strip argument sections out of inputSpec descriptions Per #1067 including the args in the description is redundant as it's already included in the parameter docs which can increase the token counts. Strip args from the description strings for inputSpecs --- src/strands/tools/decorator.py | 60 ++++++++- tests/strands/agent/test_agent.py | 4 +- tests/strands/tools/test_decorator.py | 177 ++++++++++++++++++++++++-- 3 files changed, 224 insertions(+), 17 deletions(-) diff --git a/src/strands/tools/decorator.py b/src/strands/tools/decorator.py index 5c49f4b58..0ea328a39 100644 --- a/src/strands/tools/decorator.py +++ b/src/strands/tools/decorator.py @@ -164,6 +164,56 @@ def _create_input_model(self) -> Type[BaseModel]: # Handle case with no parameters return create_model(model_name) + def _extract_description_from_docstring(self) -> str: + """Extract the docstring excluding only the Args section. + + This method uses the parsed docstring to extract everything except + the Args/Arguments/Parameters section, preserving Returns, Raises, + Examples, and other sections. + + Returns: + The description text, or the function name if no description is available. + """ + func_name = self.func.__name__ + + # Fallback: try to extract manually from raw docstring + raw_docstring = inspect.getdoc(self.func) + if raw_docstring: + lines = raw_docstring.strip().split("\n") + result_lines = [] + skip_args_section = False + + for line in lines: + stripped_line = line.strip() + + # Check if we're starting the Args section + if stripped_line.lower().startswith(("args:", "arguments:", "parameters:", "param:", "params:")): + skip_args_section = True + continue + + # Check if we're starting a new section (not Args) + elif ( + stripped_line.lower().startswith(("returns:", "return:", "yields:", "yield:")) + or stripped_line.lower().startswith(("raises:", "raise:", "except:", "exceptions:")) + or stripped_line.lower().startswith(("examples:", "example:", "note:", "notes:")) + or stripped_line.lower().startswith(("see also:", "seealso:", "references:", "ref:")) + ): + skip_args_section = False + result_lines.append(line) + continue + + # If we're not in the Args section, include the line + if not skip_args_section: + result_lines.append(line) + + # Join and clean up the description + description = "\n".join(result_lines).strip() + if description: + return description + + # Final fallback: use function name + return func_name + def extract_metadata(self) -> ToolSpec: """Extract metadata from the function to create a tool specification. @@ -173,7 +223,7 @@ def extract_metadata(self) -> ToolSpec: The specification includes: - name: The function name (or custom override) - - description: The function's docstring + - description: The function's docstring description (excluding Args) - inputSchema: A JSON schema describing the expected parameters Returns: @@ -181,12 +231,8 @@ def extract_metadata(self) -> ToolSpec: """ func_name = self.func.__name__ - # Extract function description from docstring, preserving paragraph breaks - description = inspect.getdoc(self.func) - if description: - description = description.strip() - else: - description = func_name + # Extract function description from parsed docstring, excluding Args section and beyond + description = self._extract_description_from_docstring() # Get schema directly from the Pydantic model input_schema = self.input_model.model_json_schema() diff --git a/tests/strands/agent/test_agent.py b/tests/strands/agent/test_agent.py index 3a0bc2dfb..550422cfe 100644 --- a/tests/strands/agent/test_agent.py +++ b/tests/strands/agent/test_agent.py @@ -2240,8 +2240,8 @@ def test_agent_backwards_compatibility_single_text_block(): # Should extract text for backwards compatibility assert agent.system_prompt == text - - + + @pytest.mark.parametrize( "content, expected", [ diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index 25f9bc39e..cb08faa28 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -221,14 +221,7 @@ def test_tool(param1: str, param2: int) -> str: # Check basic spec properties assert spec["name"] == "test_tool" - assert ( - spec["description"] - == """Test tool function. - -Args: - param1: First parameter - param2: Second parameter""" - ) + assert spec["description"] == "Test tool function." # Check input schema schema = spec["inputSchema"]["json"] @@ -310,6 +303,174 @@ def test_tool(required: str, optional: Optional[int] = None) -> str: exp_events = [ ToolResultEvent({"toolUseId": "test-id", "status": "success", "content": [{"text": "Result: hello 42"}]}) ] + assert tru_events == exp_events + + +@pytest.mark.asyncio +async def test_docstring_description_extraction(): + """Test that docstring descriptions are extracted correctly, excluding Args section.""" + + @strands.tool + def tool_with_full_docstring(param1: str, param2: int) -> str: + """This is the main description. + + This is more description text. + + Args: + param1: First parameter + param2: Second parameter + + Returns: + A string result + + Raises: + ValueError: If something goes wrong + """ + return f"{param1} {param2}" + + spec = tool_with_full_docstring.tool_spec + assert ( + spec["description"] + == """This is the main description. + +This is more description text. + +Returns: + A string result + +Raises: + ValueError: If something goes wrong""" + ) + + +def test_docstring_args_variations(): + """Test that various Args section formats are properly excluded.""" + + @strands.tool + def tool_with_args(param: str) -> str: + """Main description. + + Args: + param: Parameter description + """ + return param + + @strands.tool + def tool_with_arguments(param: str) -> str: + """Main description. + + Arguments: + param: Parameter description + """ + return param + + @strands.tool + def tool_with_parameters(param: str) -> str: + """Main description. + + Parameters: + param: Parameter description + """ + return param + + @strands.tool + def tool_with_params(param: str) -> str: + """Main description. + + Params: + param: Parameter description + """ + return param + + for tool in [tool_with_args, tool_with_arguments, tool_with_parameters, tool_with_params]: + spec = tool.tool_spec + assert spec["description"] == "Main description." + + +def test_docstring_no_args_section(): + """Test docstring extraction when there's no Args section.""" + + @strands.tool + def tool_no_args(param: str) -> str: + """This is the complete description. + + Returns: + A string result + """ + return param + + spec = tool_no_args.tool_spec + expected_desc = """This is the complete description. + +Returns: + A string result""" + assert spec["description"] == expected_desc + + +def test_docstring_only_args_section(): + """Test docstring extraction when there's only an Args section.""" + + @strands.tool + def tool_only_args(param: str) -> str: + """Args: + param: Parameter description + """ + return param + + spec = tool_only_args.tool_spec + # Should fall back to function name when no description remains + assert spec["description"] == "tool_only_args" + + +def test_docstring_empty(): + """Test docstring extraction when docstring is empty.""" + + @strands.tool + def tool_empty_docstring(param: str) -> str: + return param + + spec = tool_empty_docstring.tool_spec + # Should fall back to function name + assert spec["description"] == "tool_empty_docstring" + + +def test_docstring_preserves_other_sections(): + """Test that non-Args sections are preserved in the description.""" + + @strands.tool + def tool_multiple_sections(param: str) -> str: + """Main description here. + + Args: + param: This should be excluded + + Returns: + This should be included + + Raises: + ValueError: This should be included + + Examples: + This should be included + + Note: + This should be included + """ + return param + + spec = tool_multiple_sections.tool_spec + description = spec["description"] + + # Should include main description and other sections + assert "Main description here." in description + assert "Returns:" in description + assert "This should be included" in description + assert "Raises:" in description + assert "Examples:" in description + assert "Note:" in description + + # Should exclude Args section + assert "This should be excluded" not in description @pytest.mark.asyncio From b4f093c1d54ca51947e51b584ed8e559a0ff2de1 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Wed, 5 Nov 2025 15:10:01 -0500 Subject: [PATCH 2/2] fix: Failing test --- tests/strands/tools/test_decorator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/strands/tools/test_decorator.py b/tests/strands/tools/test_decorator.py index cb08faa28..f89f1c945 100644 --- a/tests/strands/tools/test_decorator.py +++ b/tests/strands/tools/test_decorator.py @@ -337,7 +337,7 @@ def tool_with_full_docstring(param1: str, param2: int) -> str: Returns: A string result - + Raises: ValueError: If something goes wrong""" )