Skip to content
Merged
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
55 changes: 54 additions & 1 deletion src/agents/function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down
77 changes: 75 additions & 2 deletions tests/test_function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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 "")