Skip to content

Commit 25bd1bb

Browse files
Python: Reject tool call arguments for parameters with include_in_function_choices=False (#14774)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Parameters declared with `{"include_in_function_choices": False}` are left out of the tool schema that is sent to the model, and the docs describe them as values the host supplies through `KernelArguments`. `Kernel.invoke_function_call` still accepted those names in a model tool call, so a value from the model took precedence over the invocation-level value. This change makes argument handling match the schema: a tool call can only set the parameters that were advertised to the model. It is the counterpart of #14625, which stopped advertising these parameters in the Anthropic, Bedrock, Google AI and Vertex AI tool formats. There is no related open issue. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> - `Kernel.invoke_function_call` builds the set of accepted argument names from the parameters that have `include_in_function_choices=True`. A tool call that includes a hidden parameter gets the existing "Received unexpected argument(s)" result and the function is not invoked, so the model can retry without it. - Hidden parameters still get their value from the invocation-level `KernelArguments` or from their default. The required-parameter check is unchanged. - The `kernel_function` docstring now states that a tool call from the model cannot set such a parameter. - Tests in `tests/unit/kernel/test_kernel.py` cover a hidden name in dict and JSON-string arguments (the function must not run and the result must be the unexpected-argument message) and a host-supplied hidden value that still reaches the function. The two rejection tests fail without the change. Compatibility: Bedrock agents create their function schema server-side when the action group is created, so action groups created before #14625 (released in python 1.46.0) still list hidden parameters. Calls from such agents that include a hidden name now get the unexpected-argument result until the action group is re-created. Validation, run on Python 3.13 (macOS) in an environment built like the unit-test workflow (`uv pip install --editable ".[<all extras>]" --group dev`), from `python/` unless noted: - `uv run --no-sync python -u -X faulthandler -m pytest -p no:cacheprovider -n auto ./tests/unit --ignore=tests/unit/processes/dapr_runtime`: 4983 passed, 1 skipped, 5 xfailed. The dapr suite was not run locally. - `uv run --no-sync pytest -p no:cacheprovider tests/unit/kernel/test_kernel.py -k hidden_parameter`: 3 passed. - `uv run --no-sync pre-commit run --config python/.pre-commit-config.yaml --files <the three changed files>` (from the repo root): all hooks passed or had no files to check. - `uv run --no-sync mypy -p semantic_kernel --config-file mypy.ini`: 42 errors in 7 files, none in the changed files. Unmodified `main` reports the same total (42 errors in 7 files) in this environment. ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
1 parent b714e97 commit 25bd1bb

3 files changed

Lines changed: 64 additions & 2 deletions

File tree

‎python/semantic_kernel/functions/kernel_function_decorator.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def kernel_function(
3535
if you set `{"include_in_function_choices": False}` in the annotation, that parameter will not be included in
3636
the representation of the function towards LLM's or MCP Servers. If you do set this and the parameter is required
3737
but you do not set it in a invoke level arguments, the function will raise an error.
38+
During auto function calling, a tool call from the model that sets such a parameter is rejected as an
39+
unexpected argument, so the value can only come from invoke level arguments or the parameter default.
3840
3941
After the annotations, it checks recursively until it reaches the lowest level, and it combines
4042
the types into a single comma-separated string, a forwardRef is also supported.

‎python/semantic_kernel/kernel.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,14 @@ async def invoke_function_call(
376376
required_param_names = {
377377
param.name for param in function_to_call.parameters if param.name is not None and param.is_required
378378
}
379+
# Parameters hidden from the model (include_in_function_choices=False) can only be set by the host.
380+
model_param_names = {
381+
param.name for param in function_to_call.parameters if param.include_in_function_choices
382+
}
379383
received_param_names = set(parsed_args or {})
380384

381385
missing_params = required_param_names - received_param_names
382-
unexpected_params = received_param_names - {param.name for param in function_to_call.parameters}
386+
unexpected_params = received_param_names - model_param_names
383387

384388
if missing_params or unexpected_params:
385389
msg_parts = []

‎python/tests/unit/kernel/test_kernel.py‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from copy import deepcopy
88
from dataclasses import dataclass
99
from pathlib import Path
10-
from typing import Union
10+
from typing import Annotated, Union
1111
from unittest.mock import AsyncMock, MagicMock, patch
1212

1313
import pytest
@@ -682,6 +682,62 @@ async def test_invoke_function_call_with_missing_or_unexpected_args(kernel: Kern
682682
), "Expected fallback message not found in chat history."
683683

684684

685+
@pytest.mark.parametrize(
686+
"model_arguments",
687+
[{"value": "visible", "scope": "model-value"}, '{"value": "visible", "scope": "model-value"}'],
688+
ids=["dict", "json"],
689+
)
690+
async def test_invoke_function_call_rejects_model_supplied_hidden_parameter(kernel: Kernel, model_arguments):
691+
calls: list[str] = []
692+
693+
class TestPlugin:
694+
@kernel_function
695+
def echo(
696+
self,
697+
value: str,
698+
scope: Annotated[str, "Host supplied", {"include_in_function_choices": False}] = "default",
699+
) -> str:
700+
calls.append(scope)
701+
return f"{value}:{scope}"
702+
703+
kernel.add_plugin(TestPlugin(), plugin_name="test")
704+
chat_history = ChatHistory()
705+
706+
result = await kernel.invoke_function_call(
707+
function_call=FunctionCallContent(name="test-echo", arguments=model_arguments),
708+
chat_history=chat_history,
709+
arguments=KernelArguments(scope="host-value"),
710+
function_behavior=FunctionChoiceBehavior.Auto(),
711+
)
712+
713+
assert result is None
714+
assert calls == []
715+
assert "Received unexpected argument(s): ['scope']" in chat_history.messages[-1].items[0].result
716+
717+
718+
async def test_invoke_function_call_keeps_host_supplied_hidden_parameter(kernel: Kernel):
719+
class TestPlugin:
720+
@kernel_function
721+
def echo(
722+
self,
723+
value: str,
724+
scope: Annotated[str, "Host supplied", {"include_in_function_choices": False}] = "default",
725+
) -> str:
726+
return f"{value}:{scope}"
727+
728+
kernel.add_plugin(TestPlugin(), plugin_name="test")
729+
chat_history = ChatHistory()
730+
731+
await kernel.invoke_function_call(
732+
function_call=FunctionCallContent(name="test-echo", arguments={"value": "visible"}),
733+
chat_history=chat_history,
734+
arguments=KernelArguments(scope="host-value"),
735+
function_behavior=FunctionChoiceBehavior.Auto(),
736+
)
737+
738+
assert chat_history.messages[-1].items[0].result == "visible:host-value"
739+
740+
685741
async def test_invoke_function_call_with_filters_blocks_unallowed_function(kernel: Kernel):
686742
"""Verify that when function_behavior has filters, an unallowed function is blocked."""
687743
tool_call_mock = MagicMock(spec=FunctionCallContent)

0 commit comments

Comments
 (0)