Skip to content
Open
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
59 changes: 57 additions & 2 deletions src/agents/function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
import inspect
import logging
import re
from collections.abc import Callable
import warnings
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Annotated, Any, Literal, get_args, get_origin, get_type_hints

Expand Down Expand Up @@ -325,6 +326,36 @@ def _extract_field_info_from_metadata(metadata: tuple[Any, ...]) -> FieldInfo |
return None


# Parameter names that build a generated arguments model without an error, survive as fields,
# and still cannot behave as ordinary fields. A field named ``model_post_init`` becomes the
# model's post-init hook, so Pydantic calls the argument value after validation and every tool
# invocation fails with an unrelated ``TypeError``. Names that are silently swallowed by
# ``create_model`` instead are caught after construction, by checking ``model_fields``.
_PYDANTIC_RESERVED_PARAM_NAMES = frozenset({"model_post_init"})


def _pydantic_rejects_param_name(name: str) -> bool:
"""Return whether Pydantic refuses a field with this name on a generated model."""
probe_fields: dict[str, Any] = {name: (str, Field())}
with warnings.catch_warnings():
warnings.simplefilter("ignore")
try:
create_model("_agents_param_name_probe", __base__=BaseModel, **probe_fields)
except Exception:
return True
return False


def _unsupported_pydantic_param_names_error(func_name: str, names: Sequence[str]) -> UserError:
formatted = ", ".join(f"`{name}`" for name in names)
noun = "a parameter name" if len(names) == 1 else "parameter names"
return UserError(
f"Function {func_name} has {noun} that Pydantic reserves on the generated "
f"arguments model: {formatted}. Rename the parameter, or wrap the function "
"and forward the value from a differently named parameter."
)


def function_schema(
func: Callable[..., Any],
docstring_style: DocstringStyle | None = None,
Expand Down Expand Up @@ -511,7 +542,31 @@ def function_schema(
)

# 3. Dynamically build a Pydantic model
dynamic_model = create_model(f"{func_name}_args", __base__=BaseModel, **fields)
reserved_params = sorted(_PYDANTIC_RESERVED_PARAM_NAMES.intersection(fields))
if reserved_params:
raise _unsupported_pydantic_param_names_error(func_name, reserved_params)

try:
dynamic_model = create_model(f"{func_name}_args", __base__=BaseModel, **fields)
except Exception as exc:
# Pydantic reserves some parameter names on generated models (``model_config`` is
# consumed by ``create_model`` as model configuration, and protected-namespace names
# such as ``model_dump`` or ``model_validate`` collide with BaseModel members). Those
# failures surface as opaque errors deep inside Pydantic, so identify the offending
# parameter names and raise an actionable error instead. Failures unrelated to a
# parameter name propagate unchanged.
rejected_params = sorted(name for name in fields if _pydantic_rejects_param_name(name))
if not rejected_params:
raise
raise _unsupported_pydantic_param_names_error(func_name, rejected_params) from exc

# ``create_model`` consumes some names as its own keyword arguments (``__doc__`` and
# ``__module__`` among them), so construction succeeds but the parameter silently never
# becomes a field. Verify every requested field survived rather than enumerating the
# control keywords, so this keeps holding if Pydantic adds more.
swallowed_params = sorted(name for name in fields if name not in dynamic_model.model_fields)
if swallowed_params:
raise _unsupported_pydantic_param_names_error(func_name, swallowed_params)

# 4. Build JSON schema from that model
json_schema = dynamic_model.model_json_schema()
Expand Down
78 changes: 78 additions & 0 deletions tests/test_function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,63 @@ def test_to_call_args_does_not_shadow_pydantic_model_fields_set():
assert result == "hello:42"


def test_param_named_model_config_raises_user_error():
"""``model_config`` is consumed by ``create_model`` as model configuration, so the raw
failure is an opaque ``TypeError: 'FieldInfo' object is not iterable`` inside Pydantic."""

def func(model_config: str) -> str:
return model_config

with pytest.raises(UserError, match=r"`model_config`"):
function_schema(func, use_docstring_info=False)


def test_param_named_model_dump_raises_user_error():
"""Protected-namespace collisions such as ``model_dump`` must raise an actionable
UserError instead of Pydantic's raw ``ValueError``."""

def func(model_dump: str, query: str) -> str:
return f"{model_dump}:{query}"

with pytest.raises(UserError, match=r"`model_dump`"):
function_schema(func, use_docstring_info=False)


def test_param_named_model_post_init_raises_user_error():
"""A field named ``model_post_init`` becomes the model's post-init hook, so without the
guard the schema builds but every argument validation fails with an unrelated TypeError."""

def func(model_post_init: str) -> str:
return model_post_init

with pytest.raises(UserError, match=r"`model_post_init`"):
function_schema(func, use_docstring_info=False)


def test_multiple_reserved_param_names_are_all_reported():
def func(model_dump: str, model_validate: str, query: str) -> str:
return query

with pytest.raises(UserError, match=r"`model_dump`, `model_validate`"):
function_schema(func, use_docstring_info=False)


def test_non_reserved_pydantic_member_param_names_still_work():
"""Names Pydantic accepts as fields (with a shadow warning) must keep working."""

def func(model_copy: str, model_json_schema: str) -> str:
return f"{model_copy}:{model_json_schema}"

with pytest.warns(UserWarning):
func_schema = function_schema(func, use_docstring_info=False)
parsed = func_schema.params_pydantic_model.model_validate(
{"model_copy": "a", "model_json_schema": "b"}
)

args, kwargs_dict = func_schema.to_call_args(parsed)
assert func(*args, **kwargs_dict) == "a:b"


def varargs_function(x: int, *numbers: float, flag: bool = False, **kwargs: Any):
return x, numbers, flag, kwargs

Expand Down Expand Up @@ -1350,3 +1407,24 @@ def test_to_call_args_allows_kwargs_key_matching_var_positional_param() -> None:
args, kwargs_dict = fs.to_call_args(parsed)

assert _kwargs_var_positional_name(*args, **kwargs_dict) == ((1,), {"rest": 5})


def test_param_named_dunder_doc_raises_user_error():
"""``create_model`` consumes ``__doc__`` as its own keyword, so the parameter never
becomes a field. That has to surface as a UserError rather than a malformed model."""

def func(__doc__: str) -> str:
return __doc__

with pytest.raises(UserError) as exc_info:
function_schema(func, use_docstring_info=False)
assert "__doc__" in str(exc_info.value)


def test_param_named_dunder_module_raises_user_error():
def func(__module__: str) -> str:
return __module__

with pytest.raises(UserError) as exc_info:
function_schema(func, use_docstring_info=False)
assert "__module__" in str(exc_info.value)