From 8bd2832219c9d99ad5c6412c9cd85329553b24b7 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 6 Apr 2026 12:11:15 +0900 Subject: [PATCH 1/3] Improve prompt-template msg serialize and sample usage --- .../azure_chat_gpt_api_handlebars.py | 2 +- .../azure_chat_gpt_api_jinja2.py | 2 +- .../utils/handlebars_system_helpers.py | 16 ++++---- .../utils/jinja2_system_helpers.py | 12 +++--- .../test_handlebars_prompt_template.py | 20 ++++++++++ .../test_handlebars_prompt_template_e2e.py | 37 +++++++++++++++++++ .../test_jinja2_prompt_template.py | 14 +++++++ .../test_jinja2_prompt_template_e2e.py | 35 ++++++++++++++++++ 8 files changed, 122 insertions(+), 16 deletions(-) diff --git a/python/samples/concepts/prompt_templates/azure_chat_gpt_api_handlebars.py b/python/samples/concepts/prompt_templates/azure_chat_gpt_api_handlebars.py index 3b15966391b8..cce70393587a 100644 --- a/python/samples/concepts/prompt_templates/azure_chat_gpt_api_handlebars.py +++ b/python/samples/concepts/prompt_templates/azure_chat_gpt_api_handlebars.py @@ -39,7 +39,7 @@ chat_function = kernel.add_function( prompt_template_config=PromptTemplateConfig( template="""{{system_message}}{{#each chat_history}} - {{#message role=role}}{{~content~}}{{/message}} {{/each}}""", + {{message_to_prompt}} {{/each}}""", template_format="handlebars", allow_dangerously_set_content=True, ), diff --git a/python/samples/concepts/prompt_templates/azure_chat_gpt_api_jinja2.py b/python/samples/concepts/prompt_templates/azure_chat_gpt_api_jinja2.py index 230c3b337a1e..9ee8ebb65bf1 100644 --- a/python/samples/concepts/prompt_templates/azure_chat_gpt_api_jinja2.py +++ b/python/samples/concepts/prompt_templates/azure_chat_gpt_api_jinja2.py @@ -38,7 +38,7 @@ chat_function = kernel.add_function( prompt_template_config=PromptTemplateConfig( - template="""{{system_message}}{% for item in chat_history %}{{ message(item) }}{% endfor %}""", + template="""{{system_message}}{% for item in chat_history %}{{ message_to_prompt(item) }}{% endfor %}""", template_format="jinja2", allow_dangerously_set_content=True, ), diff --git a/python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.py b/python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.py index d85d85a26679..af7d1a5677c3 100644 --- a/python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.py +++ b/python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.py @@ -5,6 +5,7 @@ import re from collections.abc import Callable from enum import Enum +from xml.etree.ElementTree import Element, tostring # nosec B405 logger: logging.Logger = logging.getLogger(__name__) @@ -28,21 +29,20 @@ def _message_to_prompt(this, *args, **kwargs): def _message(this, options, *args, **kwargs): from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG - # everything in kwargs, goes to - # everything in options, goes in between options - start = f"<{CHAT_MESSAGE_CONTENT_TAG}" + # Everything in kwargs becomes an attribute, and the block output is treated as message text. + message = Element(CHAT_MESSAGE_CONTENT_TAG) for key, value in kwargs.items(): if isinstance(value, Enum): value = value.value if value is not None: - start += f' {key}="{value}"' - start += ">" - end = f"" + message.set(key, str(value)) try: - content = options["fn"](this) + content = str(options["fn"](this)) except Exception: # pragma: no cover content = "" - return f"{start}{content}{end}" + if content: + message.text = content + return tostring(message, encoding="unicode", short_empty_elements=False) def _set(this, *args, **kwargs): diff --git a/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py b/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py index 921cd1be3982..e4d98adaa171 100644 --- a/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py +++ b/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py @@ -4,6 +4,7 @@ import re from collections.abc import Callable from enum import Enum +from xml.etree.ElementTree import Element, tostring # nosec B405 logger: logging.Logger = logging.getLogger(__name__) @@ -27,15 +28,14 @@ def _message_to_prompt(context): def _message(item): from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG - start = f"<{CHAT_MESSAGE_CONTENT_TAG}" role = item.role - content = item.content if isinstance(role, Enum): role = role.value - start += f' role="{role}"' - start += ">" - end = f"" - return f"{start}{content}{end}" + message = Element(CHAT_MESSAGE_CONTENT_TAG) + message.set("role", str(role)) + if item.content: + message.text = item.content + return tostring(message, encoding="unicode", short_empty_elements=False) # Wrap the _get function to safely handle calls without arguments diff --git a/python/tests/unit/prompt_template/test_handlebars_prompt_template.py b/python/tests/unit/prompt_template/test_handlebars_prompt_template.py index ab0f648f7891..416b2a127c81 100644 --- a/python/tests/unit/prompt_template/test_handlebars_prompt_template.py +++ b/python/tests/unit/prompt_template/test_handlebars_prompt_template.py @@ -261,6 +261,26 @@ async def test_helpers_message(kernel: Kernel): assert "Assistant message" in rendered +async def test_helpers_message_escapes_xml_metacharacters(kernel: Kernel): + template = """ +{{#each chat_history}} + {{#message role=role}} + {{~content~}} + {{/message}} +{{/each}} +""" + target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True) + chat_history = ChatHistory() + chat_history.add_user_message('What does a < b & "c" mean?') + + rendered = await target.render(kernel, KernelArguments(chat_history=chat_history)) + + assert "<" in rendered + assert "&" in rendered + assert '"c"' in rendered + assert ChatHistory.from_rendered_prompt(rendered) == chat_history + + async def test_helpers_message_to_prompt(kernel: Kernel): template = """{{#each chat_history}}{{message_to_prompt}} {{/each}}""" target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True) diff --git a/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py b/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py index 5c77625b85e2..bb921f0a373e 100644 --- a/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py +++ b/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py @@ -3,6 +3,7 @@ from semantic_kernel import Kernel from semantic_kernel.contents.chat_history import ChatHistory +from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.prompt_template.handlebars_prompt_template import HandlebarsPromptTemplate @@ -100,3 +101,39 @@ async def test_chat_history_round_trip(self, kernel: Kernel): ) chat_history2 = ChatHistory.from_rendered_prompt(rendered) assert chat_history2 == chat_history + + async def test_chat_history_round_trip_with_xml_metacharacters(self, kernel: Kernel): + # Arrange + template = """{{#each chat_history}}{{#message role=role}}{{~content~}}{{/message}} {{/each}}""" + target = create_handlebars_prompt_template(template) + chat_history = ChatHistory() + chat_history.add_user_message("What does a < b mean in Python?") + chat_history.add_assistant_message('Use "&" carefully in XML and HTML.') + + rendered = await target.render(kernel, KernelArguments(chat_history=chat_history)) + + assert "<" in rendered + assert "&" in rendered + assert '"&"' in rendered + assert ChatHistory.from_rendered_prompt(rendered) == chat_history + + async def test_message_helper_preserves_system_role_with_xml_metacharacters(self, kernel: Kernel): + # Arrange + template = ( + """{{system_message}}{{#each chat_history}}{{#message role=role}}{{~content~}}{{/message}} {{/each}}""" + ) + target = create_handlebars_prompt_template(template) + system_message = "You are a helpful assistant." + chat_history = ChatHistory() + chat_history.add_user_message("What does a < b mean in Python?") + + rendered = await target.render( + kernel, + KernelArguments(system_message=system_message, chat_history=chat_history), + ) + + parsed = ChatHistory.from_rendered_prompt(rendered) + assert parsed.messages[0].role == AuthorRole.SYSTEM + assert parsed.messages[0].content == system_message + assert parsed.messages[1].role == AuthorRole.USER + assert parsed.messages[1].content == "What does a < b mean in Python?" diff --git a/python/tests/unit/prompt_template/test_jinja2_prompt_template.py b/python/tests/unit/prompt_template/test_jinja2_prompt_template.py index ef5a11983a04..6710de7a5e37 100644 --- a/python/tests/unit/prompt_template/test_jinja2_prompt_template.py +++ b/python/tests/unit/prompt_template/test_jinja2_prompt_template.py @@ -264,6 +264,20 @@ async def test_helpers_message(kernel: Kernel): assert "Assistant message" in rendered +async def test_helpers_message_escapes_xml_metacharacters(kernel: Kernel): + template = """{% for item in chat_history %}{{ message(item) }}{% endfor %}""" + target = create_jinja2_prompt_template(template, allow_dangerously_set_content=True) + chat_history = ChatHistory() + chat_history.add_user_message('What does a < b & "c" mean?') + + rendered = await target.render(kernel, KernelArguments(chat_history=chat_history)) + + assert "<" in rendered + assert "&" in rendered + assert '"c"' in rendered + assert ChatHistory.from_rendered_prompt(rendered) == chat_history + + async def test_helpers_message_to_prompt(kernel: Kernel): template = """ {% for chat in chat_history %} diff --git a/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py b/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py index 9e057b706d70..b7608d37a54f 100644 --- a/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py +++ b/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py @@ -2,6 +2,7 @@ from semantic_kernel.contents.chat_history import ChatHistory +from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel @@ -104,3 +105,37 @@ async def test_chat_history_round_trip(kernel: Kernel): ) chat_history2 = ChatHistory.from_rendered_prompt(rendered) assert chat_history2 == chat_history + + +async def test_chat_history_round_trip_with_xml_metacharacters(kernel: Kernel): + template = """{% for item in chat_history %}{{ message(item) }}{% endfor %}""" + target = create_jinja2_prompt_template(template) + chat_history = ChatHistory() + chat_history.add_user_message("What does a < b mean in Python?") + chat_history.add_assistant_message('Use "&" carefully in XML and HTML.') + + rendered = await target.render(kernel, KernelArguments(chat_history=chat_history)) + + assert "<" in rendered + assert "&" in rendered + assert '"&"' in rendered + assert ChatHistory.from_rendered_prompt(rendered) == chat_history + + +async def test_message_helper_preserves_system_role_with_xml_metacharacters(kernel: Kernel): + template = """{{system_message}}{% for item in chat_history %}{{ message(item) }}{% endfor %}""" + target = create_jinja2_prompt_template(template) + system_message = "You are a helpful assistant." + chat_history = ChatHistory() + chat_history.add_user_message("What does a < b mean in Python?") + + rendered = await target.render( + kernel, + KernelArguments(system_message=system_message, chat_history=chat_history), + ) + + parsed = ChatHistory.from_rendered_prompt(rendered) + assert parsed.messages[0].role == AuthorRole.SYSTEM + assert parsed.messages[0].content == system_message + assert parsed.messages[1].role == AuthorRole.USER + assert parsed.messages[1].content == "What does a < b mean in Python?" From 4aab2167f0c7dc9073ede72272be45d48d870233 Mon Sep 17 00:00:00 2001 From: MAF Dashboard Bot Date: Mon, 6 Apr 2026 04:25:58 +0000 Subject: [PATCH 2/3] Fix review comments: use public API imports and delegate to to_element() - Use public re-export for AuthorRole (from semantic_kernel.contents) in test_jinja2_prompt_template_e2e.py and test_handlebars_prompt_template_e2e.py - Replace manual XML construction in _message() with item.to_element() to properly serialize all content items (images, function calls, etc.) - Remove unused Enum and Element imports from jinja2_system_helpers.py - Update test assertion to match correct to_element() output format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prompt_template/utils/jinja2_system_helpers.py | 13 ++----------- .../test_handlebars_prompt_template_e2e.py | 2 +- .../test_jinja2_prompt_template_e2e.py | 4 ++-- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py b/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py index e4d98adaa171..480198d8d3cc 100644 --- a/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py +++ b/python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py @@ -3,8 +3,7 @@ import logging import re from collections.abc import Callable -from enum import Enum -from xml.etree.ElementTree import Element, tostring # nosec B405 +from xml.etree.ElementTree import tostring # nosec B405 logger: logging.Logger = logging.getLogger(__name__) @@ -26,15 +25,7 @@ def _message_to_prompt(context): def _message(item): - from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG - - role = item.role - if isinstance(role, Enum): - role = role.value - message = Element(CHAT_MESSAGE_CONTENT_TAG) - message.set("role", str(role)) - if item.content: - message.text = item.content + message = item.to_element() return tostring(message, encoding="unicode", short_empty_elements=False) diff --git a/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py b/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py index bb921f0a373e..45679c047863 100644 --- a/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py +++ b/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py @@ -3,7 +3,7 @@ from semantic_kernel import Kernel from semantic_kernel.contents.chat_history import ChatHistory -from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.contents import AuthorRole from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.prompt_template.handlebars_prompt_template import HandlebarsPromptTemplate diff --git a/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py b/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py index b7608d37a54f..8043aef2f63c 100644 --- a/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py +++ b/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py @@ -2,7 +2,7 @@ from semantic_kernel.contents.chat_history import ChatHistory -from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.contents import AuthorRole from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel @@ -101,7 +101,7 @@ async def test_chat_history_round_trip(kernel: Kernel): rendered = await target.render(kernel, KernelArguments(chat_history=chat_history)) assert ( rendered.strip() - == """User messageAssistant message""" + == """User messageAssistant message""" ) chat_history2 = ChatHistory.from_rendered_prompt(rendered) assert chat_history2 == chat_history From 6752fc5a641d38971bda4cfe5523a847c0b7bf30 Mon Sep 17 00:00:00 2001 From: MAF Dashboard Bot Date: Mon, 6 Apr 2026 04:28:55 +0000 Subject: [PATCH 3/3] Address review feedback for #13738: review comment fixes --- .../test_handlebars_prompt_template_e2e.py | 2 +- .../prompt_template/test_jinja2_prompt_template_e2e.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py b/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py index 45679c047863..bba5eecd33d2 100644 --- a/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py +++ b/python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py @@ -2,8 +2,8 @@ from semantic_kernel import Kernel -from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents import AuthorRole +from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.prompt_template.handlebars_prompt_template import HandlebarsPromptTemplate diff --git a/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py b/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py index 8043aef2f63c..714b4ee25506 100644 --- a/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py +++ b/python/tests/unit/prompt_template/test_jinja2_prompt_template_e2e.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.contents import AuthorRole +from semantic_kernel.contents.chat_history import ChatHistory from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.kernel import Kernel @@ -99,10 +99,11 @@ async def test_chat_history_round_trip(kernel: Kernel): chat_history.add_user_message("User message") chat_history.add_assistant_message("Assistant message") rendered = await target.render(kernel, KernelArguments(chat_history=chat_history)) - assert ( - rendered.strip() - == """User messageAssistant message""" + expected = ( + 'User message' + 'Assistant message' ) + assert rendered.strip() == expected chat_history2 = ChatHistory.from_rendered_prompt(rendered) assert chat_history2 == chat_history