diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 8860c15180..5055debcf5 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -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 @@ -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, @@ -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() diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 1d8325d1a0..2cffd6e859 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -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 @@ -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)