From 4ce22b93bb00f67094e5b4737fa7f7dadbbded0b Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:04:29 +0800 Subject: [PATCH 1/2] Python: preserve explicit null arguments in auto function calling FunctionTool.invoke dumped validated arguments with model_dump(exclude_none=True), which strips any argument the model set to null. A required nullable parameter (e.g. unit: Literal["C","F"] | None) that the model deliberately sets to null was therefore dropped, and the function failed to invoke on the missing argument. Use exclude_unset instead: keep the arguments the model actually provided (null included) and omit only the ones it left out, so the function's own defaults still apply. Because the input model is generated from the function signature, its field defaults match the signature defaults, so omitted optionals are unchanged. Fixes #5934 Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> --- .../packages/core/agent_framework/_tools.py | 10 +++++-- python/packages/core/tests/core/test_tools.py | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 6a6354419be..07bfcb8bf73 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__}" diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index da632b60741..6fae8e2408d 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -151,6 +151,33 @@ 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_tool_decorator_with_json_schema_invoke_invalid_type(): """Test schema type checks run for mapping arguments.""" From ba58b6ffd55acb70329656d7a6c3c3625517690e Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:59:53 +0800 Subject: [PATCH 2/2] Python: extend null-arg fix to the auto function-calling path The earlier change fixed FunctionTool.invoke, but _auto_invoke_function (the path a model-emitted function_call actually takes) still ran model_dump(exclude_none=True), so an explicit null for a required nullable argument was still dropped and the call failed with a missing argument. Switch it to exclude_unset to match invoke, and add a regression test that drives _auto_invoke_function with an explicit null. --- .../packages/core/agent_framework/_tools.py | 5 +++- python/packages/core/tests/core/test_tools.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 07bfcb8bf73..588ae93d5c0 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1486,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 6fae8e2408d..a24e20cacd4 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 @@ -178,6 +180,32 @@ def get_weather(location: str, unit: str = "C") -> str: 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."""