From 41ed698fea38bb31e7ae582217df2607aff0e1b3 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 24 Oct 2025 14:07:39 +0900 Subject: [PATCH 1/2] Preserve non-string kernel args when passed to funcs in prompt templates --- .../template_engine/blocks/code_block.py | 17 +++- .../template_engine/blocks/var_block.py | 15 +++ .../template_engine/blocks/test_code_block.py | 96 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/python/semantic_kernel/template_engine/blocks/code_block.py b/python/semantic_kernel/template_engine/blocks/code_block.py index 35297feaf39a..fe98a2cb56c6 100644 --- a/python/semantic_kernel/template_engine/blocks/code_block.py +++ b/python/semantic_kernel/template_engine/blocks/code_block.py @@ -149,7 +149,22 @@ def _enrich_function_arguments( ) for index, token in enumerate(self.tokens[1:], start=1): logger.debug(f"Parsing variable/value: `{self.tokens[1].content}`") - rendered_value = token.render(kernel, arguments) # type: ignore + + # For VarBlock, get the raw value to preserve the original type + # For other blocks (ValBlock, NamedArgBlock), render to string as usual + from semantic_kernel.template_engine.blocks.var_block import VarBlock + + if isinstance(token, VarBlock): + rendered_value = token.get_value(arguments) + elif isinstance(token, NamedArgBlock): + # NamedArgBlock may contain a VarBlock, so check for that + if token.variable: + rendered_value = token.variable.get_value(arguments) + else: + rendered_value = token.render(kernel, arguments) + else: + rendered_value = token.render(kernel, arguments) # type: ignore + if not isinstance(token, NamedArgBlock) and index == 1: arguments[function_metadata.parameters[0].name] = rendered_value continue diff --git a/python/semantic_kernel/template_engine/blocks/var_block.py b/python/semantic_kernel/template_engine/blocks/var_block.py index 1480e533678a..666b3bec49a6 100644 --- a/python/semantic_kernel/template_engine/blocks/var_block.py +++ b/python/semantic_kernel/template_engine/blocks/var_block.py @@ -82,3 +82,18 @@ def render(self, _: "Kernel", arguments: Optional["KernelArguments"] = None) -> raise VarBlockRenderError( f"Block {self.name} failed to be parsed to a string, type is {type(value)}" ) from e + + def get_value(self, arguments: Optional["KernelArguments"] = None) -> Any: + """Get the raw value of the variable from arguments without converting to string. + + This is used when passing arguments to functions to preserve their original types. + + Args: + arguments: The KernelArguments to get the value from. + + Returns: + The raw value from the arguments, or None if not found. + """ + if arguments is None: + return None + return arguments.get(self.name, None) diff --git a/python/tests/unit/template_engine/blocks/test_code_block.py b/python/tests/unit/template_engine/blocks/test_code_block.py index 32d5df077686..276b6d08f99b 100644 --- a/python/tests/unit/template_engine/blocks/test_code_block.py +++ b/python/tests/unit/template_engine/blocks/test_code_block.py @@ -483,3 +483,99 @@ def test_edge_cases(case, result): def test_no_tokens(): with raises(CodeBlockTokenError): CodeBlock(content="", tokens=[]) + + +class TestNonStringArguments: + """Test that non-string KernelArguments are preserved when passed to functions in templates.""" + + async def test_function_receives_int_type(self, kernel: Kernel): + """Test that an integer argument is passed as int, not converted to string.""" + received_value = None + received_type = None + + @kernel_function(name="check_type") + def check_type(value: int): + nonlocal received_value, received_type + received_value = value + received_type = type(value) + return f"Received {type(value).__name__}: {value}" + + function = KernelFunctionFromMethod(method=check_type, plugin_name="test") + kernel.add_plugin(KernelPlugin(name="test", functions=[function])) + + code_block = CodeBlock(content="test.check_type value=$my_int") + arguments = KernelArguments(my_int=42) + + await code_block.render_code(kernel, arguments) + + assert received_value == 42 + assert isinstance(received_value, int), f"Expected int but got {received_type}" + + async def test_function_receives_list_type(self, kernel: Kernel): + """Test that a list argument is passed as list, not converted to string.""" + received_value = None + received_type = None + + @kernel_function(name="check_type") + def check_type(items: list): + nonlocal received_value, received_type + received_value = items + received_type = type(items) + return f"Received {len(items)} items" + + function = KernelFunctionFromMethod(method=check_type, plugin_name="test") + kernel.add_plugin(KernelPlugin(name="test", functions=[function])) + + code_block = CodeBlock(content="test.check_type items=$my_list") + arguments = KernelArguments(my_list=[1, 2, 3]) + + await code_block.render_code(kernel, arguments) + + assert received_value == [1, 2, 3] + assert isinstance(received_value, list), f"Expected list but got {received_type}" + + async def test_function_receives_dict_type(self, kernel: Kernel): + """Test that a dict argument is passed as dict, not converted to string.""" + received_value = None + received_type = None + + @kernel_function(name="check_type") + def check_type(data: dict): + nonlocal received_value, received_type + received_value = data + received_type = type(data) + return f"Received dict with keys: {list(data.keys())}" + + function = KernelFunctionFromMethod(method=check_type, plugin_name="test") + kernel.add_plugin(KernelPlugin(name="test", functions=[function])) + + code_block = CodeBlock(content="test.check_type data=$my_dict") + arguments = KernelArguments(my_dict={"key": "value", "num": 123}) + + await code_block.render_code(kernel, arguments) + + assert received_value == {"key": "value", "num": 123} + assert isinstance(received_value, dict), f"Expected dict but got {received_type}" + + async def test_named_arg_with_non_string_type(self, kernel: Kernel): + """Test that named arguments with non-string types are preserved.""" + received_count = None + received_type = None + + @kernel_function(name="process") + def process(text: str, count: int): + nonlocal received_count, received_type + received_count = count + received_type = type(count) + return f"{text} x {count}" + + function = KernelFunctionFromMethod(method=process, plugin_name="test") + kernel.add_plugin(KernelPlugin(name="test", functions=[function])) + + code_block = CodeBlock(content="test.process 'hello' count=$repetitions") + arguments = KernelArguments(repetitions=5) + + await code_block.render_code(kernel, arguments) + + assert received_count == 5 + assert isinstance(received_count, int), f"Expected int but got {received_type}" From e1e85cece2ebbdfd63fd30342dc4569a8365cab5 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 24 Oct 2025 14:09:15 +0900 Subject: [PATCH 2/2] Small cleanup --- python/semantic_kernel/template_engine/blocks/var_block.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/semantic_kernel/template_engine/blocks/var_block.py b/python/semantic_kernel/template_engine/blocks/var_block.py index 666b3bec49a6..4a63fdaf4cc9 100644 --- a/python/semantic_kernel/template_engine/blocks/var_block.py +++ b/python/semantic_kernel/template_engine/blocks/var_block.py @@ -83,7 +83,7 @@ def render(self, _: "Kernel", arguments: Optional["KernelArguments"] = None) -> f"Block {self.name} failed to be parsed to a string, type is {type(value)}" ) from e - def get_value(self, arguments: Optional["KernelArguments"] = None) -> Any: + def get_value(self, arguments: "KernelArguments | None" = None) -> Any: """Get the raw value of the variable from arguments without converting to string. This is used when passing arguments to functions to preserve their original types.