diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 8fe52df320..b11b20f17e 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -145,6 +145,54 @@ def _suppress_griffe_logging(): logger.setLevel(previous_level) +# Aliases of the Google-style parameter section header ("Args:") — the only section kind +# that generate_func_documentation below consumes for parameter descriptions. A header only +# counts when the whole line is exactly ``Header:`` (griffe anchors these at column 0), so +# inline mentions such as "see Args: below" never match. +_GOOGLE_SECTION_HEADER_RE = re.compile( + r"^(args|arguments|params|parameters):\s*$", + re.IGNORECASE, +) + + +def _ensure_blank_line_before_google_sections(doc: str) -> str: + """Insert a blank line before a Google-style parameter section header (``Args:`` or an + alias) that directly follows non-indented text (for example a summary line). + + griffe's Google parser silently skips a section header when there is no blank line above + it and the following line is indented (it logs "Missing blank line above section"). That + drops every parameter description and leaks the raw ``Args:`` block into the description. + numpy/sphinx parsing already tolerates the missing blank line, so this normalizes the + Google case to match. Only the parameter section is normalized because + generate_func_documentation only consumes parameter sections (plus the first text block); + other griffe sections are intentionally left alone. The string is returned unchanged when + no insertion is needed, which keeps well-formed docstrings byte-identical. + """ + lines = doc.splitlines() + output: list[str] = [] + inserted = False + for index, line in enumerate(lines): + if ( + index > 0 + and _GOOGLE_SECTION_HEADER_RE.match(line) + # Preceding line is non-blank top-level text (summary), not an indented section body. + and output + and output[-1].strip() + and not output[-1].startswith((" ", "\t")) + # Following line is an indented block, matching griffe's "indented line below" gate. + and index + 1 < len(lines) + and lines[index + 1].startswith((" ", "\t")) + ): + output.append("") + inserted = True + output.append(line) + + if not inserted: + # Preserve the original object (splitlines/join would drop a trailing newline). + return doc + return "\n".join(output) + + def generate_func_documentation( func: Callable[..., Any], style: DocstringStyle | None = None ) -> FuncDocumentation: @@ -165,8 +213,13 @@ def generate_func_documentation( if not doc: return FuncDocumentation(name=name, description=None, param_descriptions=None) + # Resolve the style against the original docstring before any normalization. + resolved_style = style or _detect_docstring_style(doc) + if resolved_style == "google": + doc = _ensure_blank_line_before_google_sections(doc) + with _suppress_griffe_logging(): - docstring = Docstring(doc, lineno=1, parser=style or _detect_docstring_style(doc)) + docstring = Docstring(doc, lineno=1, parser=resolved_style) parsed = docstring.parse() description: str | None = next( diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 9771bda99d..143b6e70f4 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -6,9 +6,9 @@ from pydantic import BaseModel, Field, ValidationError from typing_extensions import TypedDict -from agents import RunContextWrapper +from agents import RunContextWrapper, function_tool from agents.exceptions import UserError -from agents.function_schema import function_schema +from agents.function_schema import function_schema, generate_func_documentation def no_args_function(): @@ -885,3 +885,76 @@ def func_with_annotated_multiple_field_constraints( with pytest.raises(ValidationError): # zero factor fs.params_pydantic_model(**{"score": 50, "factor": 0.0}) + + +def missing_blank_line_google_function(city: str, units: str): + """Get the weather for a city. + Args: + city: The city to get weather for. + units: Temperature units to use. + """ + return f"{city} {units}" + + +def blank_line_google_function(city: str, units: str): + """Get the weather for a city. + + Args: + city: The city to get weather for. + units: Temperature units to use. + """ + return f"{city} {units}" + + +def test_google_docstring_missing_blank_line_before_args(): + """A Google docstring whose summary is immediately followed by ``Args:`` (no blank line) + should still yield parameter descriptions and a clean function description.""" + fs = function_schema(missing_blank_line_google_function, strict_json_schema=False) + + properties = fs.params_json_schema.get("properties", {}) + assert properties["city"]["description"] == "The city to get weather for." + assert properties["units"]["description"] == "Temperature units to use." + + assert fs.description == "Get the weather for a city." + assert "Args:" not in (fs.description or "") + + +def test_google_docstring_missing_blank_line_matches_blank_line_form(): + """The missing-blank-line variant must produce the exact same schema as the well-formed + variant that includes the blank line.""" + fixed = function_schema(missing_blank_line_google_function, strict_json_schema=False) + control = function_schema(blank_line_google_function, strict_json_schema=False) + + assert fixed.description == control.description + assert fixed.params_json_schema["properties"] == control.params_json_schema["properties"] + + +def test_google_docstring_blank_line_form_is_unchanged(): + """Well-formed docstrings (with the blank line already present) must be parsed + byte-identically before and after the normalization: this guards against the helper + accidentally rewriting docstrings that do not need it.""" + doc = generate_func_documentation(blank_line_google_function) + assert doc.description == "Get the weather for a city." + assert doc.param_descriptions == { + "city": "The city to get weather for.", + "units": "Temperature units to use.", + } + + +def test_google_docstring_missing_blank_line_function_tool(): + """End-to-end: a @function_tool-decorated function with the missing-blank-line docstring + must expose parameter descriptions and a clean tool description.""" + + @function_tool + def weather(city: str, units: str) -> str: + """Get the weather for a city. + Args: + city: The city to get weather for. + units: Temperature units to use. + """ + return f"{city} {units}" + + properties = weather.params_json_schema.get("properties", {}) + assert properties["city"]["description"] == "The city to get weather for." + assert properties["units"]["description"] == "Temperature units to use." + assert "Args:" not in (weather.description or "")