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
5 changes: 4 additions & 1 deletion python/semantic_kernel/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 10 additions & 7 deletions python/semantic_kernel/connectors/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 5 additions & 3 deletions python/semantic_kernel/contents/function_call_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
12 changes: 8 additions & 4 deletions python/semantic_kernel/functions/kernel_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)."""
Expand Down
12 changes: 12 additions & 0 deletions python/tests/unit/agents/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
31 changes: 31 additions & 0 deletions python/tests/unit/connectors/mcp/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
24 changes: 24 additions & 0 deletions python/tests/unit/contents/test_function_call_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
15 changes: 15 additions & 0 deletions python/tests/unit/functions/test_kernel_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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"},
Expand Down
28 changes: 28 additions & 0 deletions python/tests/unit/kernel/test_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading