Skip to content

Commit 05d659d

Browse files
authored
Python: Fix non-string KernelArguments being converted to strings in prompt template function calls (#13292)
### Motivation and Context Fixes #13199 - Non-string kernel arguments are now preserved when passed to functions in prompt templates. Adds unit tests. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
1 parent 1f8fea2 commit 05d659d

File tree

3 files changed

+127
-1
lines changed

3 files changed

+127
-1
lines changed

python/semantic_kernel/template_engine/blocks/code_block.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,22 @@ def _enrich_function_arguments(
149149
)
150150
for index, token in enumerate(self.tokens[1:], start=1):
151151
logger.debug(f"Parsing variable/value: `{self.tokens[1].content}`")
152-
rendered_value = token.render(kernel, arguments) # type: ignore
152+
153+
# For VarBlock, get the raw value to preserve the original type
154+
# For other blocks (ValBlock, NamedArgBlock), render to string as usual
155+
from semantic_kernel.template_engine.blocks.var_block import VarBlock
156+
157+
if isinstance(token, VarBlock):
158+
rendered_value = token.get_value(arguments)
159+
elif isinstance(token, NamedArgBlock):
160+
# NamedArgBlock may contain a VarBlock, so check for that
161+
if token.variable:
162+
rendered_value = token.variable.get_value(arguments)
163+
else:
164+
rendered_value = token.render(kernel, arguments)
165+
else:
166+
rendered_value = token.render(kernel, arguments) # type: ignore
167+
153168
if not isinstance(token, NamedArgBlock) and index == 1:
154169
arguments[function_metadata.parameters[0].name] = rendered_value
155170
continue

python/semantic_kernel/template_engine/blocks/var_block.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,18 @@ def render(self, _: "Kernel", arguments: Optional["KernelArguments"] = None) ->
8282
raise VarBlockRenderError(
8383
f"Block {self.name} failed to be parsed to a string, type is {type(value)}"
8484
) from e
85+
86+
def get_value(self, arguments: "KernelArguments | None" = None) -> Any:
87+
"""Get the raw value of the variable from arguments without converting to string.
88+
89+
This is used when passing arguments to functions to preserve their original types.
90+
91+
Args:
92+
arguments: The KernelArguments to get the value from.
93+
94+
Returns:
95+
The raw value from the arguments, or None if not found.
96+
"""
97+
if arguments is None:
98+
return None
99+
return arguments.get(self.name, None)

python/tests/unit/template_engine/blocks/test_code_block.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,99 @@ def test_edge_cases(case, result):
483483
def test_no_tokens():
484484
with raises(CodeBlockTokenError):
485485
CodeBlock(content="", tokens=[])
486+
487+
488+
class TestNonStringArguments:
489+
"""Test that non-string KernelArguments are preserved when passed to functions in templates."""
490+
491+
async def test_function_receives_int_type(self, kernel: Kernel):
492+
"""Test that an integer argument is passed as int, not converted to string."""
493+
received_value = None
494+
received_type = None
495+
496+
@kernel_function(name="check_type")
497+
def check_type(value: int):
498+
nonlocal received_value, received_type
499+
received_value = value
500+
received_type = type(value)
501+
return f"Received {type(value).__name__}: {value}"
502+
503+
function = KernelFunctionFromMethod(method=check_type, plugin_name="test")
504+
kernel.add_plugin(KernelPlugin(name="test", functions=[function]))
505+
506+
code_block = CodeBlock(content="test.check_type value=$my_int")
507+
arguments = KernelArguments(my_int=42)
508+
509+
await code_block.render_code(kernel, arguments)
510+
511+
assert received_value == 42
512+
assert isinstance(received_value, int), f"Expected int but got {received_type}"
513+
514+
async def test_function_receives_list_type(self, kernel: Kernel):
515+
"""Test that a list argument is passed as list, not converted to string."""
516+
received_value = None
517+
received_type = None
518+
519+
@kernel_function(name="check_type")
520+
def check_type(items: list):
521+
nonlocal received_value, received_type
522+
received_value = items
523+
received_type = type(items)
524+
return f"Received {len(items)} items"
525+
526+
function = KernelFunctionFromMethod(method=check_type, plugin_name="test")
527+
kernel.add_plugin(KernelPlugin(name="test", functions=[function]))
528+
529+
code_block = CodeBlock(content="test.check_type items=$my_list")
530+
arguments = KernelArguments(my_list=[1, 2, 3])
531+
532+
await code_block.render_code(kernel, arguments)
533+
534+
assert received_value == [1, 2, 3]
535+
assert isinstance(received_value, list), f"Expected list but got {received_type}"
536+
537+
async def test_function_receives_dict_type(self, kernel: Kernel):
538+
"""Test that a dict argument is passed as dict, not converted to string."""
539+
received_value = None
540+
received_type = None
541+
542+
@kernel_function(name="check_type")
543+
def check_type(data: dict):
544+
nonlocal received_value, received_type
545+
received_value = data
546+
received_type = type(data)
547+
return f"Received dict with keys: {list(data.keys())}"
548+
549+
function = KernelFunctionFromMethod(method=check_type, plugin_name="test")
550+
kernel.add_plugin(KernelPlugin(name="test", functions=[function]))
551+
552+
code_block = CodeBlock(content="test.check_type data=$my_dict")
553+
arguments = KernelArguments(my_dict={"key": "value", "num": 123})
554+
555+
await code_block.render_code(kernel, arguments)
556+
557+
assert received_value == {"key": "value", "num": 123}
558+
assert isinstance(received_value, dict), f"Expected dict but got {received_type}"
559+
560+
async def test_named_arg_with_non_string_type(self, kernel: Kernel):
561+
"""Test that named arguments with non-string types are preserved."""
562+
received_count = None
563+
received_type = None
564+
565+
@kernel_function(name="process")
566+
def process(text: str, count: int):
567+
nonlocal received_count, received_type
568+
received_count = count
569+
received_type = type(count)
570+
return f"{text} x {count}"
571+
572+
function = KernelFunctionFromMethod(method=process, plugin_name="test")
573+
kernel.add_plugin(KernelPlugin(name="test", functions=[function]))
574+
575+
code_block = CodeBlock(content="test.process 'hello' count=$repetitions")
576+
arguments = KernelArguments(repetitions=5)
577+
578+
await code_block.render_code(kernel, arguments)
579+
580+
assert received_count == 5
581+
assert isinstance(received_count, int), f"Expected int but got {received_type}"

0 commit comments

Comments
 (0)