diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index dd97ed2b30..e2b4039e22 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -10,7 +10,7 @@ # griffelib exposes the `griffe` package at runtime but currently does not ship typing markers. from griffe import Docstring, DocstringSectionKind # type: ignore[import-untyped] -from pydantic import BaseModel, Field, create_model +from pydantic import BaseModel, ConfigDict, Field, create_model from pydantic.fields import FieldInfo from .exceptions import UserError @@ -19,6 +19,19 @@ from .tool_context import ToolContext +class _ToolArgsBaseModel(BaseModel): + """Base for dynamically generated tool-argument models. + + ``protected_namespaces=()`` disables Pydantic's ``model_`` guard so a tool may declare + parameters whose names collide with ``BaseModel`` members (for example ``model_dump`` or + ``model_validate``) without raising at tool-definition time. The runtime call layer reads + argument values from instance ``__dict__`` rather than by attribute access, so a field that + shadows a model member is still resolved correctly. + """ + + model_config = ConfigDict(protected_namespaces=()) + + @dataclass class FuncSchema: """ @@ -470,7 +483,18 @@ def function_schema( ) # 3. Dynamically build a Pydantic model - dynamic_model = create_model(f"{func_name}_args", __base__=BaseModel, **fields) + # + # ``model_config`` cannot be a field name: ``create_model`` interprets a ``model_config`` + # keyword as the model's configuration, not a field, which otherwise fails with an opaque + # ``TypeError`` deep inside Pydantic. Reject it with an actionable error. Other names that + # collide with ``BaseModel`` members (``model_dump``, ``model_validate``, ...) are handled by + # ``_ToolArgsBaseModel`` disabling the protected-namespace guard. + if "model_config" in fields: + raise UserError( + "Tool parameter 'model_config' is not supported because Pydantic reserves that name " + "for model configuration. Rename the parameter (for example to 'config')." + ) + dynamic_model = create_model(f"{func_name}_args", __base__=_ToolArgsBaseModel, **fields) # 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 0b7550fcf0..c18794a535 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -131,6 +131,37 @@ def test_to_call_args_does_not_shadow_pydantic_model_fields_set(): assert result == "hello:42" +@pytest.mark.parametrize( + "param_name", + ["model_dump", "model_dump_json", "model_validate", "model_validate_json", "model_json_schema"], +) +def test_parameter_named_like_a_basemodel_method_is_supported(param_name: str) -> None: + """A tool parameter that shadows a ``BaseModel`` method must not crash at definition time.""" + namespace: dict[str, Any] = {} + exec( + f"def tool({param_name}: int, query: str) -> str:\n" + f" return f'{{{param_name}}}:{{query}}'", + namespace, + ) + tool = namespace["tool"] + + func_schema = function_schema(tool, use_docstring_info=False, strict_json_schema=False) + parsed = func_schema.params_pydantic_model.model_validate({param_name: 7, "query": "q"}) + + args, kwargs_dict = func_schema.to_call_args(parsed) + assert tool(*args, **kwargs_dict) == "7:q" + + +def test_parameter_named_model_config_raises_actionable_error() -> None: + """``model_config`` is reserved by ``create_model``; surface a clear error, not a TypeError.""" + + def tool(model_config: str) -> str: + return model_config + + with pytest.raises(UserError, match="model_config"): + function_schema(tool, use_docstring_info=False, strict_json_schema=False) + + def varargs_function(x: int, *numbers: float, flag: bool = False, **kwargs: Any): return x, numbers, flag, kwargs