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..2cdc08087506 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,25 @@ 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 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 +106,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 +119,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 +153,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..beb6f52c36aa 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,55 @@ 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"