From 84ce3bfd84bcde253af4a5b266351bf9f42df73f Mon Sep 17 00:00:00 2001 From: Sohum Trivedi Date: Mon, 13 Jul 2026 23:55:10 -0700 Subject: [PATCH 1/2] fix: parse Google docstrings that omit the blank line before a section generate_func_documentation feeds inspect.getdoc(func) to griffe's Google parser. When a summary line is immediately followed by an Args:/Returns:/... section with no blank line in between, griffe skips the section ("Missing blank line above section"), so every parameter description is dropped and the raw Args: block leaks into the function description. This is a Google-only gap; the numpy and sphinx parsers already tolerate the missing blank line. Normalize the docstring before parsing: when the resolved style is google, insert a single blank line before a recognized section header that directly follows non-indented text and is itself followed by an indented block. The anchor mirrors griffe's own recognition (column-0 header, case-insensitive section keys, indented line below), and the helper returns the original string unchanged when no insertion is needed, so well-formed docstrings stay byte-identical. --- src/agents/function_schema.py | 54 ++++++++++++++++++- tests/test_function_schema.py | 97 ++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 8fe52df320..dad0f67378 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -145,6 +145,53 @@ def _suppress_griffe_logging(): logger.setLevel(previous_level) +# Recognized Google-style section headers, mirroring griffe's case-insensitive section keys. +# 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|keyword args|keyword arguments|other args" + r"|other arguments|other params|other parameters|raises|exceptions|returns|yields" + r"|receives|examples|attributes|warns|warnings):\s*$", + re.IGNORECASE, +) + + +def _ensure_blank_line_before_google_sections(doc: str) -> str: + """Insert a blank line before a Google-style section header 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. 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 +212,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..3b9836ad8b 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,96 @@ 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_multi_section(): + """Multiple sections stacked with no blank lines anywhere should all be recognized.""" + + def multi_section(city: str) -> str: + """Get the weather for a city. + Args: + city: The city to get weather for. + Returns: + A human readable weather string. + """ + return city + + fs = function_schema(multi_section, strict_json_schema=False) + properties = fs.params_json_schema.get("properties", {}) + assert properties["city"]["description"] == "The city to get weather for." + assert fs.description == "Get the weather for a city." + assert "Args:" not in (fs.description or "") + assert "Returns:" not in (fs.description or "") + + +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 "") From 9654627600893a7f44c719d6f678ad7a2a16d259 Mon Sep 17 00:00:00 2001 From: Sohum Trivedi Date: Tue, 14 Jul 2026 09:28:03 -0700 Subject: [PATCH 2/2] fix: limit docstring normalization to Google parameter section headers Per review feedback, I narrowed the blank-line normalization to the four parameter-section aliases that generate_func_documentation actually consumes (Args, Arguments, Params, Parameters) instead of mirroring the full griffe section registry, and rescoped the comments and helper docstring accordingly. I also removed the multi-section test: its Returns: assertion passed vacuously because the helper never fires after an indented line and later text sections are ignored anyway. The base-failing regression test, the equivalence test, the well-formed control test, and the end-to-end function_tool test are retained unchanged. --- src/agents/function_schema.py | 21 +++++++++++---------- tests/test_function_schema.py | 20 -------------------- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index dad0f67378..b11b20f17e 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -145,27 +145,28 @@ def _suppress_griffe_logging(): logger.setLevel(previous_level) -# Recognized Google-style section headers, mirroring griffe's case-insensitive section keys. -# 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. +# 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|keyword args|keyword arguments|other args" - r"|other arguments|other params|other parameters|raises|exceptions|returns|yields" - r"|receives|examples|attributes|warns|warnings):\s*$", + 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 section header that directly follows - non-indented text (for example a summary line). + """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. The string is returned unchanged when no insertion is needed, which - keeps well-formed docstrings byte-identical. + 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] = [] diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 3b9836ad8b..143b6e70f4 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -941,26 +941,6 @@ def test_google_docstring_blank_line_form_is_unchanged(): } -def test_google_docstring_missing_blank_line_multi_section(): - """Multiple sections stacked with no blank lines anywhere should all be recognized.""" - - def multi_section(city: str) -> str: - """Get the weather for a city. - Args: - city: The city to get weather for. - Returns: - A human readable weather string. - """ - return city - - fs = function_schema(multi_section, strict_json_schema=False) - properties = fs.params_json_schema.get("properties", {}) - assert properties["city"]["description"] == "The city to get weather for." - assert fs.description == "Get the weather for a city." - assert "Args:" not in (fs.description or "") - assert "Returns:" not in (fs.description or "") - - 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."""