diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 6a6354419b..588ae93d5c 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -655,8 +655,14 @@ async def invoke( if isinstance(arguments, Mapping): parsed_arguments = dict(arguments) if self.input_model is not None and not self._schema_supplied: + # exclude_unset (not exclude_none): keep arguments the model + # explicitly provided even when their value is null, and drop + # only the ones it left out, so the function's own defaults + # apply. Excluding null instead would strip a required nullable + # parameter the model deliberately set to null, failing the + # invocation on the missing argument (#5934). parsed_arguments = self.input_model.model_validate(parsed_arguments).model_dump( - exclude_none=True + exclude_unset=True ) elif isinstance(arguments, BaseModel): if ( @@ -665,7 +671,7 @@ async def invoke( and not isinstance(arguments, self.input_model) ): raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") - parsed_arguments = arguments.model_dump(exclude_none=True) + parsed_arguments = arguments.model_dump(exclude_unset=True) else: raise TypeError( f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}" @@ -1480,7 +1486,10 @@ async def _auto_invoke_function( runtime_kwargs["session"] = invocation_session try: if not cast(bool, getattr(tool, "_schema_supplied", False)) and tool.input_model is not None: - args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True) + # exclude_unset (not exclude_none) so an argument the model explicitly set + # to null still reaches the function; see FunctionTool.invoke for the full + # rationale. This is the auto-calling path #5934 actually hits. + args = tool.input_model.model_validate(parsed_args).model_dump(exclude_unset=True) else: args = dict(parsed_args) args = _validate_arguments_against_schema( diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index da632b6074..a24e20cacd 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -17,8 +17,10 @@ ) from agent_framework._middleware import FunctionInvocationContext from agent_framework._tools import ( + _auto_invoke_function, _parse_annotation, _parse_inputs, + normalize_function_invocation_configuration, ) from agent_framework.observability import OtelAttr @@ -151,6 +153,59 @@ def search(query: str) -> str: await search.invoke(arguments={}) +async def test_invoke_preserves_explicit_null_argument(): + """A required nullable argument the model sets to null must reach the function. + + Regression for #5934: exclude_none dropped the explicit null, so the required + ``unit`` went missing and the invocation failed. + """ + + @tool + def get_weather(location: str, unit: Literal["C", "F"] | None) -> str: + return f"{location}:{unit}" + + result = await get_weather.invoke(arguments={"location": "Seattle", "unit": None}) + assert isinstance(result, list) + assert result[0].text == "Seattle:None" + + +async def test_invoke_omitted_optional_uses_function_default(): + """An omitted optional argument still falls back to the function's own default.""" + + @tool + def get_weather(location: str, unit: str = "C") -> str: + return f"{location}:{unit}" + + result = await get_weather.invoke(arguments={"location": "Seattle"}) + assert result[0].text == "Seattle:C" + + +async def test_auto_invoke_preserves_explicit_null_argument(): + """The auto function-calling path must preserve an explicit null argument too. + + Regression for #5934: ``FunctionTool.invoke`` was fixed, but ``_auto_invoke_function`` + (the path a model's ``function_call`` actually takes) still ran ``exclude_none`` and + dropped the required ``unit``, so the invocation failed with a missing argument. + """ + + @tool + def get_weather(location: str, unit: Literal["C", "F"] | None) -> str: + return f"{location}:{unit}" + + function_call = Content.from_function_call( + call_id="call-1", + name=get_weather.name, + arguments='{"location": "Seattle", "unit": null}', + ) + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={get_weather.name: get_weather}, + ) + assert result.type == "function_result" + assert result.result == "Seattle:None" + + async def test_tool_decorator_with_json_schema_invoke_invalid_type(): """Test schema type checks run for mapping arguments."""