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] 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."""