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
15 changes: 12 additions & 3 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +658 to +662

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and you're right that this was the path that mattered. I've pushed a follow-up that switches _auto_invoke_function to exclude_unset=True as well, so an explicit null in a model-emitted function_call now reaches the tool instead of being dropped by exclude_none. The three sites (invoke's mapping and BaseModel branches plus the auto-calling path) are consistent now.

# 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 (
Expand All @@ -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__}"
Expand Down Expand Up @@ -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(
Expand Down
55 changes: 55 additions & 0 deletions python/packages/core/tests/core/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Comment on lines +156 to +160

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in the follow-up: test_auto_invoke_preserves_explicit_null_argument drives _auto_invoke_function directly with {"unit": null} and asserts the tool sees None. It fails on the old exclude_none (the required argument goes missing and parsing errors) and passes with the fix, so it guards the auto-calling path specifically.

"""

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

Expand Down
Loading