diff --git a/python/semantic_kernel/prompt_template/utils/template_function_helpers.py b/python/semantic_kernel/prompt_template/utils/template_function_helpers.py index 3e7b7d3675ce..a7236b0b7de3 100644 --- a/python/semantic_kernel/prompt_template/utils/template_function_helpers.py +++ b/python/semantic_kernel/prompt_template/utils/template_function_helpers.py @@ -8,6 +8,7 @@ import nest_asyncio +from semantic_kernel.exceptions import FunctionExecutionException from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.prompt_template.const import ( HANDLEBARS_TEMPLATE_FORMAT_NAME, @@ -22,6 +23,26 @@ logger: logging.Logger = logging.getLogger(__name__) +# Parameters that the kernel injects when invoking a function. They cannot be supplied +# positionally from a template because they are not part of the template-facing signature. +_INJECTED_FUNCTION_PARAMETER_NAMES = frozenset({"kernel", "service", "execution_settings", "arguments"}) + + +def _map_positional_arguments(function: "KernelFunction", positional_args: tuple[Any, ...]) -> dict[str, Any]: + """Map positional template arguments to the function's template-facing parameters.""" + parameter_names = [ + parameter.name + for parameter in function.parameters + if parameter.name is not None + and (function.metadata.is_prompt or parameter.name not in _INJECTED_FUNCTION_PARAMETER_NAMES) + ] + if len(positional_args) > len(parameter_names): + raise FunctionExecutionException( + f"Function '{function.fully_qualified_name}' accepts at most {len(parameter_names)} positional " + f"argument(s), but {len(positional_args)} were provided." + ) + return dict(zip(parameter_names, positional_args)) + def create_template_helper_from_function( function: "KernelFunction", @@ -86,7 +107,6 @@ def func(*args, **kwargs): if base_arguments and base_arguments.execution_settings: arguments.execution_settings = base_arguments.execution_settings # pragma: no cover arguments.update(base_arguments) - arguments.update(kwargs) if len(args) > 0 and template_format == HANDLEBARS_TEMPLATE_FORMAT_NAME: this = args[0] @@ -100,6 +120,9 @@ def func(*args, **kwargs): else: logger.debug("Jinja2 context or Handlebars context without `this`") + arguments.update(_map_positional_arguments(function, actual_args)) + arguments.update(kwargs) + logger.debug( "Invoking function %s with positional argument count %d and keyword argument count %d.", function.metadata.fully_qualified_name, @@ -131,6 +154,7 @@ async def func(*args, **kwargs): if base_arguments and base_arguments.execution_settings: arguments.execution_settings = base_arguments.execution_settings # pragma: no cover arguments.update(base_arguments) + arguments.update(_map_positional_arguments(function, args)) arguments.update(kwargs) logger.debug( "Invoking function %s with positional argument count %d and keyword argument count %d.", diff --git a/python/tests/unit/prompt_template/test_template_helper.py b/python/tests/unit/prompt_template/test_template_helper.py index 723ac2f6008a..01e87fc8dbea 100644 --- a/python/tests/unit/prompt_template/test_template_helper.py +++ b/python/tests/unit/prompt_template/test_template_helper.py @@ -4,6 +4,7 @@ import pytest +from semantic_kernel.exceptions import FunctionExecutionException from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod from semantic_kernel.kernel import Kernel @@ -95,3 +96,94 @@ async def test_create_helpers_fail(kernel: Kernel, template_format: str, enable_ assert int(str(res)) == 2 else: assert int(str(result(x=1))) == 2 + + +@pytest.mark.parametrize( + ("template_format", "prepend_context"), + [ + pytest.param(JINJA2_TEMPLATE_FORMAT_NAME, False, id="jinja2"), + pytest.param(HANDLEBARS_TEMPLATE_FORMAT_NAME, True, id="handlebars"), + ], +) +def test_create_sync_helpers_with_positional_arguments(kernel: Kernel, template_format: str, prepend_context: bool): + function = KernelFunctionFromMethod(kernel_function(lambda a=1, b=2: a + b, name="test"), plugin_name="test") + helper = create_template_helper_from_function( + function, kernel, {}, template_format, allow_dangerously_set_content=True, enable_async=False + ) + + args = (None, 3, 4) if prepend_context else (3, 4) + result = helper(*args) + + assert int(str(result)) == 7 + + +async def test_create_async_helper_with_positional_arguments(kernel: Kernel): + function = KernelFunctionFromMethod(kernel_function(lambda a=1, b=2: a + b, name="test"), plugin_name="test") + helper = create_template_helper_from_function( + function, kernel, {}, JINJA2_TEMPLATE_FORMAT_NAME, allow_dangerously_set_content=True, enable_async=True + ) + + result = await helper(3, 4) + + assert int(str(result)) == 7 + + +def test_create_helper_rejects_too_many_positional_arguments(kernel: Kernel): + function = KernelFunctionFromMethod(kernel_function(lambda a=1, b=2: a + b, name="test"), plugin_name="test") + helper = create_template_helper_from_function( + function, kernel, {}, HANDLEBARS_TEMPLATE_FORMAT_NAME, allow_dangerously_set_content=True, enable_async=False + ) + + with pytest.raises(FunctionExecutionException, match="accepts at most 2 positional argument"): + helper(None, 1, 2, 3) + + +def test_create_helper_skips_kernel_injected_parameters(kernel: Kernel): + def function_with_kernel(kernel: Kernel, value: str = "default") -> str: + return value + + function = KernelFunctionFromMethod(kernel_function(function_with_kernel, name="test"), plugin_name="test") + helper = create_template_helper_from_function( + function, kernel, {}, JINJA2_TEMPLATE_FORMAT_NAME, allow_dangerously_set_content=True, enable_async=False + ) + + assert str(helper("passed")) == "passed" + + +@pytest.mark.parametrize("input_name", ["service", "kernel", "execution_settings", "arguments"]) +@pytest.mark.parametrize("values", [("first",), ("first", "second")]) +@pytest.mark.parametrize("mode", ["jinja_sync", "jinja_async", "handlebars"]) +async def test_prompt_function_preserves_named_inputs_for_positional_binding(kernel, mocker, input_name, values, mode): + from unittest.mock import AsyncMock + + from semantic_kernel.functions.kernel_function_from_prompt import KernelFunctionFromPrompt + from semantic_kernel.prompt_template.input_variable import InputVariable + from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig + + function = KernelFunctionFromPrompt( + function_name="prompt", + plugin_name="test", + prompt_template_config=PromptTemplateConfig( + template="prompt text", + input_variables=[InputVariable(name=input_name), InputVariable(name="value")], + ), + ) + invoke = mocker.patch.object(KernelFunctionFromPrompt, "invoke", new=AsyncMock(return_value="result")) + helper = create_template_helper_from_function( + function, + kernel, + {}, + HANDLEBARS_TEMPLATE_FORMAT_NAME if mode == "handlebars" else JINJA2_TEMPLATE_FORMAT_NAME, + enable_async=mode == "jinja_async", + ) + if mode == "jinja_async": + await helper(*values) + elif mode == "handlebars": + helper(None, *values) + else: + helper(*values) + + expected = {input_name: "first"} + if len(values) == 2: + expected["value"] = "second" + assert dict(invoke.await_args.kwargs["arguments"]) == expected