From 46b2bc49ebbd34aaf79476245d6720d9c717d300 Mon Sep 17 00:00:00 2001 From: Dmitry Voropaev Date: Thu, 29 Oct 2026 20:12:07 +0300 Subject: [PATCH] Python: Treat a tool call argument named settings as a function argument FunctionCallContent.to_kernel_arguments built the arguments with KernelArguments(**args). KernelArguments.__init__ has a settings keyword for execution settings, so a kernel function parameter called settings was read as execution settings. A string value raised AttributeError ('str' object has no attribute 'service_id') out of auto function calling, and a dict value was moved into execution_settings so the function reported a missing argument. Add the parsed arguments with update(**args) instead, which keeps the TypeError for arguments that are not a JSON object, so those still get the malformed arguments reply. Do the same in KernelArguments.__or__ and __ror__ and in Agent._merge_arguments, where a settings key raised TypeError for multiple values, and in the MCP server call_tool and get_prompt handlers. --- python/semantic_kernel/agents/agent.py | 5 ++- python/semantic_kernel/connectors/mcp.py | 17 +++++----- .../contents/function_call_content.py | 8 +++-- .../functions/kernel_arguments.py | 12 ++++--- python/tests/unit/agents/test_agent.py | 12 +++++++ python/tests/unit/connectors/mcp/test_mcp.py | 31 +++++++++++++++++++ .../contents/test_function_call_content.py | 24 ++++++++++++++ .../unit/functions/test_kernel_arguments.py | 15 +++++++++ python/tests/unit/kernel/test_kernel.py | 28 +++++++++++++++++ 9 files changed, 137 insertions(+), 15 deletions(-) diff --git a/python/semantic_kernel/agents/agent.py b/python/semantic_kernel/agents/agent.py index a941f9897441..f11b70833d30 100644 --- a/python/semantic_kernel/agents/agent.py +++ b/python/semantic_kernel/agents/agent.py @@ -484,7 +484,10 @@ def _merge_arguments(self, override_args: KernelArguments | None) -> KernelArgum merged_params = dict(self.arguments) merged_params.update(override_args) - return KernelArguments(settings=merged_execution_settings, **merged_params) + # A `settings` key in the arguments stays an argument + merged_arguments = KernelArguments(settings=merged_execution_settings) + merged_arguments.update(**merged_params) + return merged_arguments # endregion diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index b2eaa84d19bc..594b408baac5 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -1230,11 +1230,11 @@ async def _get_prompt(name: str, arguments: dict[str, Any] | None) -> types.GetP if prompt is None: return types.GetPromptResult(description="Prompt not found", messages=[]) - # Call the prompt - rendered_prompt = await prompt.render( - kernel, - KernelArguments(**arguments) if arguments is not None else KernelArguments(), - ) + # Call the prompt, an argument named `settings` stays an argument + prompt_arguments = KernelArguments() + if arguments is not None: + prompt_arguments.update(arguments) + rendered_prompt = await prompt.render(kernel, prompt_arguments) # since the return type of a get_prompts is a list of messages, # we need to convert the rendered prompt to a list of messages # by using the ChatHistory class @@ -1271,7 +1271,10 @@ async def _set_logging_level(level: types.LoggingLevel) -> None: async def _call_kernel_function(function_name: str, arguments: Any) -> FunctionResult | None: function = functions_to_expose[function_name] - arguments["server"] = server - return await function.invoke(kernel=kernel, **arguments) + # Not function.invoke(**arguments), which would read an argument named `settings` as execution settings + kernel_arguments = KernelArguments() + kernel_arguments.update(arguments) + kernel_arguments["server"] = server + return await function.invoke(kernel=kernel, arguments=kernel_arguments) return server diff --git a/python/semantic_kernel/contents/function_call_content.py b/python/semantic_kernel/contents/function_call_content.py index e4ca2338111b..6d5a6737c621 100644 --- a/python/semantic_kernel/contents/function_call_content.py +++ b/python/semantic_kernel/contents/function_call_content.py @@ -173,9 +173,11 @@ def to_kernel_arguments(self) -> "KernelArguments": from semantic_kernel.functions.kernel_arguments import KernelArguments args = self.parse_arguments() - if not args: - return KernelArguments() - return KernelArguments(**args) + kernel_arguments = KernelArguments() + if args: + # Not KernelArguments(**args), which would read an argument named `settings` as execution settings. + kernel_arguments.update(**args) + return kernel_arguments @deprecated("The function_name and plugin_name properties should be used instead.") def split_name(self) -> list[str | None]: diff --git a/python/semantic_kernel/functions/kernel_arguments.py b/python/semantic_kernel/functions/kernel_arguments.py index a6048d14e631..9c8b52247388 100644 --- a/python/semantic_kernel/functions/kernel_arguments.py +++ b/python/semantic_kernel/functions/kernel_arguments.py @@ -71,8 +71,10 @@ def __or__(self, value: dict) -> "KernelArguments": new_execution_settings = (self.execution_settings or {}).copy() if isinstance(value, KernelArguments) and value.execution_settings: new_execution_settings |= value.execution_settings - # Create a new KernelArguments with merged dict values - return KernelArguments(settings=new_execution_settings, **(dict(self) | dict(value))) + # Create a new KernelArguments with merged dict values, a `settings` key stays an argument + result = KernelArguments(settings=new_execution_settings) + result.update(**(dict(self) | dict(value))) + return result def __ror__(self, value: dict) -> "KernelArguments": """Merges a dict with a KernelArguments. @@ -91,8 +93,10 @@ def __ror__(self, value: dict) -> "KernelArguments": if self.execution_settings: new_execution_settings |= self.execution_settings - # Create a new KernelArguments with merged dict values - return KernelArguments(settings=new_execution_settings, **(dict(value) | dict(self))) + # Create a new KernelArguments with merged dict values, a `settings` key stays an argument + result = KernelArguments(settings=new_execution_settings) + result.update(**(dict(value) | dict(self))) + return result def __ior__(self, value: "SupportsKeysAndGetItem[Any, Any] | Iterable[tuple[Any, Any]]") -> "KernelArguments": """Merges into this KernelArguments with another KernelArguments or dict (in-place).""" diff --git a/python/tests/unit/agents/test_agent.py b/python/tests/unit/agents/test_agent.py index e47d2c7b10f4..182baac40e2e 100644 --- a/python/tests/unit/agents/test_agent.py +++ b/python/tests/unit/agents/test_agent.py @@ -187,6 +187,18 @@ def test_merge_arguments_both_not_none(): assert merged["param2"] == "override_param", "Should include param from override" +def test_merge_arguments_with_settings_key(): + agent = MockAgent() + agent.arguments = KernelArguments(settings={"key1": "val1"}, tone="calm") + override = KernelArguments() + override["settings"] = "dark" + + merged = agent._merge_arguments(override) + + assert dict(merged) == {"tone": "calm", "settings": "dark"} + assert merged.execution_settings == {"key1": "val1"} + + # region Declarative Spec tests diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 8cf15275aaef..3255e58f58b5 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -19,6 +19,7 @@ ) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError from semantic_kernel.functions import KernelFunction, KernelPlugin, kernel_function +from semantic_kernel.prompt_template import InputVariable, KernelPromptTemplate, PromptTemplateConfig @pytest.fixture @@ -832,6 +833,36 @@ def second(count: int) -> str: assert calls == [registration[-1] for registration in retained] +async def test_mcp_server_get_prompt_with_settings_argument(kernel: "Kernel"): + prompt = KernelPromptTemplate( + prompt_template_config=PromptTemplateConfig( + name="theme", + template="Use the {{$settings}} theme.", + input_variables=[InputVariable(name="settings", is_required=True)], + ) + ) + server = create_mcp_server_from_kernel(kernel, prompts=[prompt]) + + async with create_connected_server_and_client_session(server) as client: + result = await client.get_prompt("theme", {"settings": "dark"}) + assert result.messages[0].content.text == "Use the dark theme." + + +async def test_mcp_server_call_tool_with_settings_argument(kernel: "Kernel"): + class ThemePlugin: + @kernel_function + def apply_theme(self, settings: str) -> str: + return f"applied {settings}" + + kernel.add_plugin(ThemePlugin(), plugin_name="theme") + server = create_mcp_server_from_kernel(kernel) + + async with create_connected_server_and_client_session(server) as client: + result = await client.call_tool("apply_theme", {"settings": "dark"}) + assert not result.isError + assert result.content == [types.TextContent(type="text", text="applied dark")] + + @pytest.mark.parametrize("tool_name_length", [128, 129]) @pytest.mark.parametrize("use_plugin_names", [False, True]) async def test_mcp_server_tool_name_length(kernel, caplog, tool_name_length, use_plugin_names): diff --git a/python/tests/unit/contents/test_function_call_content.py b/python/tests/unit/contents/test_function_call_content.py index 6cdfc4557e99..6fa48890dfcd 100644 --- a/python/tests/unit/contents/test_function_call_content.py +++ b/python/tests/unit/contents/test_function_call_content.py @@ -167,6 +167,30 @@ def test_to_kernel_arguments_none(): assert fc.to_kernel_arguments() == KernelArguments() +@pytest.mark.parametrize( + "arguments, expected", + [ + ('{"settings": "dark"}', "dark"), + ('{"settings": {"mode": "dark"}}', {"mode": "dark"}), + ({"settings": "dark"}, "dark"), + ], +) +def test_to_kernel_arguments_with_settings_argument(arguments, expected): + # An argument named settings is a function argument, not execution settings + fc = FunctionCallContent(id="test", name="Test-Function", arguments=arguments) + kernel_arguments = fc.to_kernel_arguments() + assert dict(kernel_arguments) == {"settings": expected} + assert kernel_arguments.execution_settings is None + + +@pytest.mark.parametrize("arguments", ['"{\\"settings\\": \\"dark\\"}"', '["ab"]', '[["settings", "dark"]]']) +def test_to_kernel_arguments_not_an_object(arguments): + # Arguments that are not a JSON object raise TypeError, which invoke_function_call reports as malformed + fc = FunctionCallContent(id="test", name="Test-Function", arguments=arguments) + with pytest.raises(TypeError): + fc.to_kernel_arguments() + + def test_split_name(function_call: FunctionCallContent): # Test splitting the name into plugin and function name assert function_call.split_name() == ["Test", "Function"] diff --git a/python/tests/unit/functions/test_kernel_arguments.py b/python/tests/unit/functions/test_kernel_arguments.py index 579ca999329d..67f2bfc050c6 100644 --- a/python/tests/unit/functions/test_kernel_arguments.py +++ b/python/tests/unit/functions/test_kernel_arguments.py @@ -59,6 +59,14 @@ def test_kernel_arguments_bool(): (KernelArguments(a=1), KernelArguments(a=99), {"a": 99}, None), # Merging with a plain dict (KernelArguments(a=1), {"b": 2}, {"a": 1, "b": 2}, None), + # A key named settings is a regular argument + (KernelArguments(a=1), {"settings": "x"}, {"a": 1, "settings": "x"}, None), + ( + KernelArguments(settings=PromptExecutionSettings(service_id="s1")), + {"settings": "x"}, + {"settings": "x"}, + ["s1"], + ), # Merging execution_settings together ( KernelArguments(settings=PromptExecutionSettings(service_id="s1")), @@ -133,6 +141,13 @@ def test_kernel_arguments_inplace_merge(lhs, rhs, expected_dict, expected_settin ({"a": 1}, KernelArguments(a=99), {"a": 99}, None), # Merging with a KernelArguments ({"b": 2}, KernelArguments(a=1), {"b": 2, "a": 1}, None), + # A key named settings is a regular argument + ( + {"settings": "x"}, + KernelArguments(a=1, settings=PromptExecutionSettings(service_id="s2")), + {"settings": "x", "a": 1}, + ["s2"], + ), # Merging execution_settings together ( {"test": "value"}, diff --git a/python/tests/unit/kernel/test_kernel.py b/python/tests/unit/kernel/test_kernel.py index fd10770de534..64550de6516b 100644 --- a/python/tests/unit/kernel/test_kernel.py +++ b/python/tests/unit/kernel/test_kernel.py @@ -354,6 +354,34 @@ def fail(self, value: str) -> str: assert "Error invoking function test-fail. Error type: ValueError." in messages +@pytest.mark.parametrize( + "arguments, expected_result", + [ + ('{"settings": "dark"}', "applied dark"), + ('{"settings": {"mode": "dark"}}', "applied {'mode': 'dark'}"), + ( + '"{\\"settings\\": \\"dark\\"}"', + "The tool call arguments are malformed. Arguments must be in JSON format. Please try again.", + ), + ], +) +async def test_invoke_function_call_with_settings_parameter(kernel: Kernel, arguments, expected_result): + class ThemePlugin: + @kernel_function + def apply_theme(self, settings: str | dict) -> str: + return f"applied {settings}" + + kernel.add_plugin(ThemePlugin(), plugin_name="theme") + chat_history = ChatHistory() + await kernel.invoke_function_call( + function_call=FunctionCallContent(name="theme-apply_theme", arguments=arguments), + chat_history=chat_history, + function_behavior=FunctionChoiceBehavior.Auto(), + ) + + assert chat_history.messages[-1].items[0].result == expected_result + + class LightsPlugin: lights = [ {"id": 1, "name": "Table Lamp", "is_on": False},