| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent b5e635e commit 0d29250
2 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -655,8 +655,14 @@ async def invoke( | |||
| 655 | 655 | if isinstance(arguments, Mapping): | |
| 656 | 656 | parsed_arguments = dict(arguments) | |
| 657 | 657 | if self.input_model is not None and not self._schema_supplied: | |
| 658 | + # exclude_unset (not exclude_none): keep arguments the model | ||
| 659 | + # explicitly provided even when their value is null, and drop | ||
| 660 | + # only the ones it left out, so the function's own defaults | ||
| 661 | + # apply. Excluding null instead would strip a required nullable | ||
| 662 | + # parameter the model deliberately set to null, failing the | ||
| 663 | + # invocation on the missing argument (#5934). | ||
| 658 | 664 | parsed_arguments = self.input_model.model_validate(parsed_arguments).model_dump( | |
| 659 | - exclude_none=True | ||
| 665 | + exclude_unset=True | ||
| 660 | 666 | ) | |
| 661 | 667 | elif isinstance(arguments, BaseModel): | |
| 662 | 668 | if ( | |
@@ -665,7 +671,7 @@ async def invoke( | |||
| 665 | 671 | and not isinstance(arguments, self.input_model) | |
| 666 | 672 | ): | |
| 667 | 673 | raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") | |
| 668 | - parsed_arguments = arguments.model_dump(exclude_none=True) | ||
| 674 | + parsed_arguments = arguments.model_dump(exclude_unset=True) | ||
| 669 | 675 | else: | |
| 670 | 676 | raise TypeError( | |
| 671 | 677 | f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}" | |
@@ -1480,7 +1486,10 @@ async def _auto_invoke_function( | |||
| 1480 | 1486 | runtime_kwargs["session"] = invocation_session | |
| 1481 | 1487 | try: | |
| 1482 | 1488 | if not cast(bool, getattr(tool, "_schema_supplied", False)) and tool.input_model is not None: | |
| 1483 | - args = tool.input_model.model_validate(parsed_args).model_dump(exclude_none=True) | ||
| 1489 | + # exclude_unset (not exclude_none) so an argument the model explicitly set | ||
| 1490 | + # to null still reaches the function; see FunctionTool.invoke for the full | ||
| 1491 | + # rationale. This is the auto-calling path #5934 actually hits. | ||
| 1492 | + args = tool.input_model.model_validate(parsed_args).model_dump(exclude_unset=True) | ||
| 1484 | 1493 | else: | |
| 1485 | 1494 | args = dict(parsed_args) | |
| 1486 | 1495 | args = _validate_arguments_against_schema( | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -17,8 +17,10 @@ | |||
| 17 | 17 | ) | |
| 18 | 18 | from agent_framework._middleware import FunctionInvocationContext | |
| 19 | 19 | from agent_framework._tools import ( | |
| 20 | + _auto_invoke_function, | ||
| 20 | 21 | _parse_annotation, | |
| 21 | 22 | _parse_inputs, | |
| 23 | + normalize_function_invocation_configuration, | ||
| 22 | 24 | ) | |
| 23 | 25 | from agent_framework.observability import OtelAttr | |
| 24 | 26 | ||
@@ -151,6 +153,59 @@ def search(query: str) -> str: | |||
| 151 | 153 | await search.invoke(arguments={}) | |
| 152 | 154 | ||
| 153 | 155 | ||
| 156 | + async def test_invoke_preserves_explicit_null_argument(): | ||
| 157 | + """A required nullable argument the model sets to null must reach the function. | ||
| 158 | + | ||
| 159 | + Regression for #5934: exclude_none dropped the explicit null, so the required | ||
| 160 | + ``unit`` went missing and the invocation failed. | ||
| 161 | + """ | ||
| 162 | + | ||
| 163 | + @tool | ||
| 164 | + def get_weather(location: str, unit: Literal["C", "F"] | None) -> str: | ||
| 165 | + return f"{location}:{unit}" | ||
| 166 | + | ||
| 167 | + result = await get_weather.invoke(arguments={"location": "Seattle", "unit": None}) | ||
| 168 | + assert isinstance(result, list) | ||
| 169 | + assert result[0].text == "Seattle:None" | ||
| 170 | + | ||
| 171 | + | ||
| 172 | + async def test_invoke_omitted_optional_uses_function_default(): | ||
| 173 | + """An omitted optional argument still falls back to the function's own default.""" | ||
| 174 | + | ||
| 175 | + @tool | ||
| 176 | + def get_weather(location: str, unit: str = "C") -> str: | ||
| 177 | + return f"{location}:{unit}" | ||
| 178 | + | ||
| 179 | + result = await get_weather.invoke(arguments={"location": "Seattle"}) | ||
| 180 | + assert result[0].text == "Seattle:C" | ||
| 181 | + | ||
| 182 | + | ||
| 183 | + async def test_auto_invoke_preserves_explicit_null_argument(): | ||
| 184 | + """The auto function-calling path must preserve an explicit null argument too. | ||
| 185 | + | ||
| 186 | + Regression for #5934: ``FunctionTool.invoke`` was fixed, but ``_auto_invoke_function`` | ||
| 187 | + (the path a model's ``function_call`` actually takes) still ran ``exclude_none`` and | ||
| 188 | + dropped the required ``unit``, so the invocation failed with a missing argument. | ||
| 189 | + """ | ||
| 190 | + | ||
| 191 | + @tool | ||
| 192 | + def get_weather(location: str, unit: Literal["C", "F"] | None) -> str: | ||
| 193 | + return f"{location}:{unit}" | ||
| 194 | + | ||
| 195 | + function_call = Content.from_function_call( | ||
| 196 | + call_id="call-1", | ||
| 197 | + name=get_weather.name, | ||
| 198 | + arguments='{"location": "Seattle", "unit": null}', | ||
| 199 | + ) | ||
| 200 | + result = await _auto_invoke_function( | ||
| 201 | + function_call, | ||
| 202 | + config=normalize_function_invocation_configuration(None), | ||
| 203 | + tool_map={get_weather.name: get_weather}, | ||
| 204 | + ) | ||
| 205 | + assert result.type == "function_result" | ||
| 206 | + assert result.result == "Seattle:None" | ||
| 207 | + | ||
| 208 | + | ||
| 154 | 209 | async def test_tool_decorator_with_json_schema_invoke_invalid_type(): | |
| 155 | 210 | """Test schema type checks run for mapping arguments.""" | |
| 156 | 211 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments