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
11 changes: 8 additions & 3 deletions src/agents/function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,11 +436,15 @@ def function_schema(
# Handle different parameter kinds
if param.kind == param.VAR_POSITIONAL:
# e.g. *args: extend positional args
# Keep Annotated metadata on each collected value. The stripped annotation above is
# still the source of truth for tuple-shape classification, while Pydantic applies
# Field constraints from the evaluated annotation to every positional argument.
variadic_value_ann = type_hints_with_extras.get(name, ann)
if get_origin(ann) is tuple:
# Preserve a homogeneous tuple as the type of each positional argument.
args_of_tuple = get_args(ann)
if len(args_of_tuple) == 2 and args_of_tuple[1] is Ellipsis:
ann = list[ann] # type: ignore
ann = list[variadic_value_ann] # type: ignore
# tuple[()] parameterizes an empty tuple and reports no args, while a bare
# typing.Tuple is unparameterized and carries no element type to reject.
elif hasattr(ann, "__args__"):
Expand All @@ -453,7 +457,7 @@ def function_schema(
ann = list[Any]
else:
# If user wrote *args: int, treat as List[int]
ann = list[ann] # type: ignore
ann = list[variadic_value_ann] # type: ignore

# Default factory to empty list
fields[name] = (
Expand All @@ -467,7 +471,8 @@ def function_schema(
# annotation as the value type -- mirroring the variadic-positional handling above,
# where ``*args: X`` becomes ``list[X]`` (see #4655). A bare ``**kwargs`` has ``ann``
# set to ``Any`` above, yielding ``dict[str, Any]``.
ann = dict[str, ann] # type: ignore
variadic_value_ann = type_hints_with_extras.get(name, ann)
ann = dict[str, variadic_value_ann] # type: ignore

fields[name] = (
ann,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,40 @@ def test_var_positional_supported_annotations_still_build(func: Any):
assert fs.params_json_schema["properties"]["args"]["type"] == "array"


def test_var_positional_preserves_annotated_field_constraints():
def func(*args: Annotated[int, Field(gt=0)]) -> tuple[int, ...]:
return args

fs = function_schema(func, use_docstring_info=False)

args_schema = fs.params_json_schema["properties"]["args"]
assert args_schema["items"]["exclusiveMinimum"] == 0

parsed = fs.params_pydantic_model.model_validate({"args": [1, 2]})
args, kwargs = fs.to_call_args(parsed)
assert func(*args, **kwargs) == (1, 2)

with pytest.raises(ValidationError):
fs.params_pydantic_model.model_validate({"args": [0]})


def test_var_keyword_preserves_annotated_field_constraints():
def func(**kwargs: Annotated[str, Field(min_length=2)]) -> dict[str, str]:
return kwargs

fs = function_schema(func, use_docstring_info=False, strict_json_schema=False)

kwargs_schema = fs.params_json_schema["properties"]["kwargs"]
assert kwargs_schema["additionalProperties"]["minLength"] == 2

parsed = fs.params_pydantic_model.model_validate({"kwargs": {"first": "ok"}})
args, kwargs = fs.to_call_args(parsed)
assert func(*args, **kwargs) == {"first": "ok"}

with pytest.raises(ValidationError):
fs.params_pydantic_model.model_validate({"kwargs": {"first": "x"}})


def test_var_keyword_dict_annotation():
# Case 3:
# A ``**kwargs: X`` annotation applies to each keyword *value* (PEP 484), so a
Expand Down