From d86077e677bd086323bfc5e0445b5d8ed030e143 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Mon, 24 Aug 2026 13:36:56 -0500 Subject: [PATCH] fix(function_schema): read tool args without Pydantic property shadowing Tool parameters named model_extra or model_fields_set validated correctly but were invoked with the BaseModel property values instead of the parsed field, so function tools silently received None or a set of field names. --- src/agents/function_schema.py | 6 +++++- tests/test_function_schema.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 378715dcb4..791eb3b661 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -51,6 +51,10 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: positional_args: list[Any] = [] keyword_args: dict[str, Any] = {} seen_var_positional = False + # Read instance storage first so Pydantic properties such as ``model_extra`` + # and ``model_fields_set`` do not shadow tool parameters of the same name. + # ``model_dump()`` is unsuitable here because it converts nested models to dicts. + instance_values = object.__getattribute__(data, "__dict__") # Use enumerate() so we can skip the first parameter if it's context. for idx, (name, param) in enumerate(self.signature.parameters.items()): @@ -58,7 +62,7 @@ def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: if self.takes_context and idx == 0: continue - value = getattr(data, name, None) + value = instance_values[name] if name in instance_values else getattr(data, name, None) if param.kind == param.VAR_POSITIONAL: # e.g. *args: extend positional args and mark that *args is now seen positional_args.extend(value or []) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 1b261ce9b5..1cdd08049c 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -93,6 +93,44 @@ def test_simple_function(): func_schema.params_pydantic_model(**{"a": "not an integer"}) +def function_with_model_extra_param(query: str, model_extra: str) -> str: + return f"{query}:{model_extra}" + + +def function_with_model_fields_set_param(query: str, model_fields_set: int) -> str: + return f"{query}:{model_fields_set}" + + +def test_to_call_args_does_not_shadow_pydantic_model_extra(): + """A parameter named ``model_extra`` must not be replaced by BaseModel.model_extra.""" + + with pytest.warns(UserWarning, match="model_extra"): + func_schema = function_schema(function_with_model_extra_param, use_docstring_info=False) + parsed = func_schema.params_pydantic_model.model_validate( + {"query": "hello", "model_extra": "gpt-4.1"} + ) + + args, kwargs_dict = func_schema.to_call_args(parsed) + result = function_with_model_extra_param(*args, **kwargs_dict) + assert result == "hello:gpt-4.1" + + +def test_to_call_args_does_not_shadow_pydantic_model_fields_set(): + """A parameter named ``model_fields_set`` must not be replaced by BaseModel.model_fields_set.""" + + with pytest.warns(UserWarning, match="model_fields_set"): + func_schema = function_schema( + function_with_model_fields_set_param, use_docstring_info=False + ) + parsed = func_schema.params_pydantic_model.model_validate( + {"query": "hello", "model_fields_set": 42} + ) + + args, kwargs_dict = func_schema.to_call_args(parsed) + result = function_with_model_fields_set_param(*args, **kwargs_dict) + assert result == "hello:42" + + def varargs_function(x: int, *numbers: float, flag: bool = False, **kwargs: Any): return x, numbers, flag, kwargs