From 0f76744751412fc7bdbd3089b556bd1e8f54205f Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Wed, 3 Jun 2026 22:06:21 +0200 Subject: [PATCH 1/4] fix(function_schema): support reserved param names --- src/agents/function_schema.py | 73 ++++++++++++++++++++++++++++------- tests/test_function_schema.py | 19 +++++++++ 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 8fe52df320..c2fa7f1c87 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 @@ -40,6 +40,8 @@ class FuncSchema: strict_json_schema: bool = True """Whether the JSON schema is in strict mode. We **strongly** recommend setting this to True, as it increases the likelihood of correct JSON input.""" + pydantic_field_name_map: dict[str, str] | None = None + """Maps function parameter names to the internal Pydantic field names used for validation.""" def to_call_args(self, data: BaseModel) -> tuple[list[Any], dict[str, Any]]: """ @@ -56,7 +58,8 @@ 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) + pydantic_field_name = (self.pydantic_field_name_map or {}).get(name, name) + value = getattr(data, pydantic_field_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 []) @@ -221,6 +224,28 @@ def _extract_field_info_from_metadata(metadata: tuple[Any, ...]) -> FieldInfo | return None +_PYDANTIC_PROTECTED_FIELD_PREFIXES = ("model_dump", "model_validate") + + +def _requires_pydantic_alias(name: str) -> bool: + """Returns True when a parameter name cannot safely be used as a Pydantic field name.""" + + return name == "model_config" or any( + name.startswith(prefix) for prefix in _PYDANTIC_PROTECTED_FIELD_PREFIXES + ) + + +def _make_safe_pydantic_field_name(name: str, used_names: set[str]) -> str: + """Generates a unique internal Pydantic field name for aliased parameters.""" + + candidate = f"func_arg_{name}" + suffix = 1 + while candidate in used_names: + suffix += 1 + candidate = f"func_arg_{name}_{suffix}" + return candidate + + def function_schema( func: Callable[..., Any], docstring_style: DocstringStyle | None = None, @@ -317,8 +342,18 @@ def function_schema( # We will collect field definitions for create_model as a dict: # field_name -> (type_annotation, default_value_or_Field(...)) fields: dict[str, Any] = {} + pydantic_field_name_map: dict[str, str] = {} + used_pydantic_field_names: set[str] = set() for name, param in filtered_params: + pydantic_field_name = ( + _make_safe_pydantic_field_name(name, used_pydantic_field_names) + if _requires_pydantic_alias(name) + else name + ) + pydantic_field_name_map[name] = pydantic_field_name + used_pydantic_field_names.add(pydantic_field_name) + ann = type_hints.get(name, param.annotation) default = param.default @@ -344,9 +379,9 @@ def function_schema( ann = list[ann] # type: ignore # Default factory to empty list - fields[name] = ( + fields[pydantic_field_name] = ( ann, - Field(default_factory=list, description=field_description), + Field(default_factory=list, description=field_description, alias=name), ) elif param.kind == param.VAR_KEYWORD: @@ -362,9 +397,9 @@ def function_schema( # e.g. def foo(**kwargs: int) -> Dict[str, int] ann = dict[str, ann] # type: ignore - fields[name] = ( + fields[pydantic_field_name] = ( ann, - Field(default_factory=dict, description=field_description), + Field(default_factory=dict, description=field_description, alias=name), ) else: @@ -381,30 +416,39 @@ def function_schema( merged = FieldInfo.merge_field_infos(merged, default=default) elif isinstance(default, FieldInfo): merged = FieldInfo.merge_field_infos(merged, default) - fields[name] = (ann, merged) + if pydantic_field_name != name: + merged = FieldInfo.merge_field_infos(merged, alias=name) + fields[pydantic_field_name] = (ann, merged) elif default == inspect._empty: # Required field - fields[name] = ( + fields[pydantic_field_name] = ( ann, - Field(..., description=field_description), + Field(..., description=field_description, alias=name), ) elif isinstance(default, FieldInfo): # Parameter with a default value that is a Field(...) - fields[name] = ( + fields[pydantic_field_name] = ( ann, FieldInfo.merge_field_infos( - default, description=field_description or default.description + default, + description=field_description or default.description, + alias=name, ), ) else: # Parameter with a default value - fields[name] = ( + fields[pydantic_field_name] = ( ann, - Field(default=default, description=field_description), + Field(default=default, description=field_description, alias=name), ) # 3. Dynamically build a Pydantic model - dynamic_model = create_model(f"{func_name}_args", __base__=BaseModel, **fields) + dynamic_model = create_model( + f"{func_name}_args", + __base__=BaseModel, + __config__=ConfigDict(populate_by_name=True), + **fields, + ) # 4. Build JSON schema from that model json_schema = dynamic_model.model_json_schema() @@ -421,4 +465,5 @@ def function_schema( signature=sig, takes_context=takes_context, strict_json_schema=strict_json_schema, + pydantic_field_name_map=pydantic_field_name_map, ) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 9771bda99d..6157183c6e 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -134,6 +134,25 @@ def test_varargs_function(): assert result2 == (7, (9.9,), False, {"some_key": "some_value"}) +@pytest.mark.parametrize("param_name", ["model_config", "model_dump", "model_validate"]) +def test_function_schema_supports_pydantic_reserved_param_names(param_name: str) -> None: + namespace: dict[str, Any] = {} + exec( + f"def reserved_name_tool({param_name}: str) -> str:\n" + f" return {param_name}\n", + namespace, + ) + func = namespace["reserved_name_tool"] + + func_schema = function_schema(func) + + assert func_schema.params_json_schema["properties"][param_name]["type"] == "string" + parsed = func_schema.params_pydantic_model(**{param_name: "value"}) + args, kwargs_dict = func_schema.to_call_args(parsed) + + assert func(*args, **kwargs_dict) == "value" + + class Foo(TypedDict): a: int b: str From a7548518957b7b5c57b7055485f277c7d39c0dd4 Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Wed, 3 Jun 2026 22:47:42 +0200 Subject: [PATCH 2/4] fix(function_schema): handle alias edge cases --- src/agents/function_schema.py | 39 +++++++++++++++++++++-------------- tests/test_function_schema.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index c2fa7f1c87..4d6c97ba7d 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -246,6 +246,12 @@ def _make_safe_pydantic_field_name(name: str, used_names: set[str]) -> str: return candidate +def _with_field_alias(field_info: FieldInfo, alias: str) -> FieldInfo: + """Returns a copy of a field definition with an explicit validation/serialization alias.""" + + return FieldInfo.merge_field_infos(field_info, Field(alias=alias)) + + def function_schema( func: Callable[..., Any], docstring_style: DocstringStyle | None = None, @@ -343,7 +349,7 @@ def function_schema( # field_name -> (type_annotation, default_value_or_Field(...)) fields: dict[str, Any] = {} pydantic_field_name_map: dict[str, str] = {} - used_pydantic_field_names: set[str] = set() + used_pydantic_field_names = {name for name, _ in filtered_params} for name, param in filtered_params: pydantic_field_name = ( @@ -417,30 +423,33 @@ def function_schema( elif isinstance(default, FieldInfo): merged = FieldInfo.merge_field_infos(merged, default) if pydantic_field_name != name: - merged = FieldInfo.merge_field_infos(merged, alias=name) + merged = _with_field_alias(merged, name) fields[pydantic_field_name] = (ann, merged) elif default == inspect._empty: # Required field - fields[pydantic_field_name] = ( - ann, - Field(..., description=field_description, alias=name), + field = ( + Field(..., description=field_description, alias=name) + if pydantic_field_name != name + else Field(..., description=field_description) ) + fields[pydantic_field_name] = (ann, field) elif isinstance(default, FieldInfo): # Parameter with a default value that is a Field(...) - fields[pydantic_field_name] = ( - ann, - FieldInfo.merge_field_infos( - default, - description=field_description or default.description, - alias=name, - ), + field = FieldInfo.merge_field_infos( + default, + description=field_description or default.description, ) + if pydantic_field_name != name: + field = _with_field_alias(field, name) + fields[pydantic_field_name] = (ann, field) else: # Parameter with a default value - fields[pydantic_field_name] = ( - ann, - Field(default=default, description=field_description, alias=name), + field = ( + Field(default=default, description=field_description, alias=name) + if pydantic_field_name != name + else Field(default=default, description=field_description) ) + fields[pydantic_field_name] = (ann, field) # 3. Dynamically build a Pydantic model dynamic_model = create_model( diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 6157183c6e..74c7c8e7ec 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -153,6 +153,40 @@ def test_function_schema_supports_pydantic_reserved_param_names(param_name: str) assert func(*args, **kwargs_dict) == "value" +def test_function_schema_avoids_reserved_name_alias_collisions() -> None: + def collision_tool(model_dump: str, func_arg_model_dump: int) -> tuple[str, int]: + return model_dump, func_arg_model_dump + + func_schema = function_schema(collision_tool) + + properties = func_schema.params_json_schema["properties"] + assert set(properties) == {"model_dump", "func_arg_model_dump"} + + parsed = func_schema.params_pydantic_model( + model_dump="value", + func_arg_model_dump=3, + ) + args, kwargs_dict = func_schema.to_call_args(parsed) + + assert collision_tool(*args, **kwargs_dict) == ("value", 3) + + +def test_function_schema_preserves_field_alias_defaults() -> None: + def aliased_tool(city: str = Field(alias="location")) -> str: + return city + + func_schema = function_schema(aliased_tool) + + properties = func_schema.params_json_schema["properties"] + assert "location" in properties + assert "city" not in properties + + parsed = func_schema.params_pydantic_model(location="Paris") + args, kwargs_dict = func_schema.to_call_args(parsed) + + assert aliased_tool(*args, **kwargs_dict) == "Paris" + + class Foo(TypedDict): a: int b: str From 7e1a3df92228f8481e9b5802d959b8f035dd6f80 Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Wed, 3 Jun 2026 23:46:48 +0200 Subject: [PATCH 3/4] fix(function_schema): avoid create_model base config mix --- src/agents/function_schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agents/function_schema.py b/src/agents/function_schema.py index 4d6c97ba7d..d4eab71ef4 100644 --- a/src/agents/function_schema.py +++ b/src/agents/function_schema.py @@ -454,7 +454,6 @@ def function_schema( # 3. Dynamically build a Pydantic model dynamic_model = create_model( f"{func_name}_args", - __base__=BaseModel, __config__=ConfigDict(populate_by_name=True), **fields, ) From abbd54de53c07c1a063fc813fa70b5506d175287 Mon Sep 17 00:00:00 2001 From: Jianke LIN Date: Thu, 4 Jun 2026 09:06:42 +0200 Subject: [PATCH 4/4] Format reserved parameter schema test --- tests/test_function_schema.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_function_schema.py b/tests/test_function_schema.py index 74c7c8e7ec..326daad3a9 100644 --- a/tests/test_function_schema.py +++ b/tests/test_function_schema.py @@ -138,8 +138,7 @@ def test_varargs_function(): def test_function_schema_supports_pydantic_reserved_param_names(param_name: str) -> None: namespace: dict[str, Any] = {} exec( - f"def reserved_name_tool({param_name}: str) -> str:\n" - f" return {param_name}\n", + f"def reserved_name_tool({param_name}: str) -> str:\n return {param_name}\n", namespace, ) func = namespace["reserved_name_tool"]