Skip to content

Commit 9ea65ae

Browse files
authored
Merge branch 'main' into python-google-user-agent
2 parents ceeb985 + 12f14bd commit 9ea65ae

11 files changed

Lines changed: 278 additions & 40 deletions

python/pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ dependencies = [
2828
"azure-ai-agents >= 1.2.0b3",
2929
"aiohttp ~= 3.8",
3030
"cloudevents ~=1.0",
31-
"pydantic >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.12",
31+
"pydantic >=2.0,!=2.10.0,!=2.10.1,!=2.10.2,!=2.10.3,<2.13",
3232
"pydantic-settings ~= 2.0",
3333
"defusedxml ~= 0.7",
3434
# azure identity
@@ -77,7 +77,7 @@ azure = [
7777
"azure-cosmos ~= 4.7"
7878
]
7979
chroma = [
80-
"chromadb >= 0.5,< 1.1"
80+
"chromadb >= 0.5,< 1.4"
8181
]
8282
copilotstudio = [
8383
"microsoft-agents-copilotstudio-client >= 0.3.1",
@@ -106,11 +106,11 @@ mistralai = [
106106
"mistralai >= 1.2,< 2.0"
107107
]
108108
mongo = [
109-
"pymongo >= 4.8.0, < 4.15",
109+
"pymongo >= 4.8.0, < 4.16",
110110
"motor >= 3.3.2,< 3.8.0"
111111
]
112112
notebooks = [
113-
"ipykernel ~= 6.29"
113+
"ipykernel >= 6.29,< 8.0"
114114
]
115115
ollama = [
116116
"ollama ~= 0.4"
@@ -136,7 +136,7 @@ qdrant = [
136136
"qdrant-client ~= 1.9"
137137
]
138138
redis = [
139-
"redis[hiredis] ~= 6.0",
139+
"redis[hiredis] >= 6,< 8",
140140
"types-redis ~= 4.6.0.20240425",
141141
"redisvl ~= 0.4"
142142
]
@@ -158,7 +158,7 @@ weaviate = [
158158
[dependency-groups]
159159
dev = [
160160
"pre-commit ~= 3.7",
161-
"ipykernel ~= 6.29",
161+
"ipykernel >= 6.29,< 8.0",
162162
"nbconvert ~= 7.16",
163163
"pytest ~= 8.2",
164164
"pytest-xdist[psutil] ~= 3.6",

python/samples/concepts/prompt_templates/azure_chat_gpt_api_handlebars.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
chat_function = kernel.add_function(
4040
prompt_template_config=PromptTemplateConfig(
4141
template="""{{system_message}}{{#each chat_history}}
42-
{{#message role=role}}{{~content~}}{{/message}} {{/each}}""",
42+
{{message_to_prompt}} {{/each}}""",
4343
template_format="handlebars",
4444
allow_dangerously_set_content=True,
4545
),

python/samples/concepts/prompt_templates/azure_chat_gpt_api_jinja2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
chat_function = kernel.add_function(
4040
prompt_template_config=PromptTemplateConfig(
41-
template="""{{system_message}}{% for item in chat_history %}{{ message(item) }}{% endfor %}""",
41+
template="""{{system_message}}{% for item in chat_history %}{{ message_to_prompt(item) }}{% endfor %}""",
4242
template_format="jinja2",
4343
allow_dangerously_set_content=True,
4444
),

python/semantic_kernel/agents/open_ai/responses_agent_thread_actions.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,10 +1214,19 @@ def _get_tools(
12141214
if agent.tools:
12151215
tools.extend(agent.tools)
12161216

1217-
# TODO(evmattso): make sure to respect filters on FCB
1218-
if kernel.plugins:
1219-
funcs = kernel.get_full_list_of_function_metadata()
1220-
tools.extend([kernel_function_metadata_to_response_function_call_format(f) for f in funcs])
1217+
if not function_choice_behavior.enable_kernel_functions:
1218+
return tools
1219+
1220+
if not kernel.plugins:
1221+
return tools
1222+
1223+
funcs = (
1224+
kernel.get_list_of_function_metadata(function_choice_behavior.filters)
1225+
if function_choice_behavior.filters
1226+
else kernel.get_full_list_of_function_metadata()
1227+
)
1228+
1229+
tools.extend([kernel_function_metadata_to_response_function_call_format(f) for f in funcs])
12211230

12221231
return tools
12231232

python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
from collections.abc import Callable
77
from enum import Enum
8+
from xml.etree.ElementTree import Element, SubElement, tostring # nosec B405
89

910
logger: logging.Logger = logging.getLogger(__name__)
1011

@@ -26,23 +27,32 @@ def _message_to_prompt(this, *args, **kwargs):
2627

2728

2829
def _message(this, options, *args, **kwargs):
30+
from semantic_kernel.contents.chat_message_content import ChatMessageContent
2931
from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG
3032

31-
# everything in kwargs, goes to <ROOT_KEY_MESSAGE kwargs_key="kwargs_value">
32-
# everything in options, goes in between <ROOT_KEY_MESSAGE>options</ROOT_KEY_MESSAGE>
33-
start = f"<{CHAT_MESSAGE_CONTENT_TAG}"
33+
# When the context is a ChatMessageContent, delegate to to_element() so that
34+
# the XML contract is consistent with the Jinja2 path.
35+
if isinstance(this.context, ChatMessageContent):
36+
message = this.context.to_element()
37+
return tostring(message, encoding="unicode", short_empty_elements=False)
38+
39+
# Fallback: build the element manually from kwargs and block content.
40+
from semantic_kernel.contents.const import TEXT_CONTENT_TAG
41+
42+
message = Element(CHAT_MESSAGE_CONTENT_TAG)
3443
for key, value in kwargs.items():
3544
if isinstance(value, Enum):
3645
value = value.value
3746
if value is not None:
38-
start += f' {key}="{value}"'
39-
start += ">"
40-
end = f"</{CHAT_MESSAGE_CONTENT_TAG}>"
47+
message.set(key, str(value))
4148
try:
42-
content = options["fn"](this)
49+
content = str(options["fn"](this))
4350
except Exception: # pragma: no cover
4451
content = ""
45-
return f"{start}{content}{end}"
52+
if content:
53+
text_elem = SubElement(message, TEXT_CONTENT_TAG)
54+
text_elem.text = content
55+
return tostring(message, encoding="unicode", short_empty_elements=False)
4656

4757

4858
def _set(this, *args, **kwargs):

python/semantic_kernel/prompt_template/utils/jinja2_system_helpers.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import re
55
from collections.abc import Callable
6-
from enum import Enum
6+
from xml.etree.ElementTree import tostring # nosec B405
77

88
logger: logging.Logger = logging.getLogger(__name__)
99

@@ -25,17 +25,8 @@ def _message_to_prompt(context):
2525

2626

2727
def _message(item):
28-
from semantic_kernel.contents.const import CHAT_MESSAGE_CONTENT_TAG
29-
30-
start = f"<{CHAT_MESSAGE_CONTENT_TAG}"
31-
role = item.role
32-
content = item.content
33-
if isinstance(role, Enum):
34-
role = role.value
35-
start += f' role="{role}"'
36-
start += ">"
37-
end = f"</{CHAT_MESSAGE_CONTENT_TAG}>"
38-
return f"{start}{content}{end}"
28+
message = item.to_element()
29+
return tostring(message, encoding="unicode", short_empty_elements=False)
3930

4031

4132
# Wrap the _get function to safely handle calls without arguments

python/tests/unit/agents/openai_responses/test_openai_responses_thread_actions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from semantic_kernel.agents.open_ai.openai_responses_agent import OpenAIResponsesAgent
1717
from semantic_kernel.agents.open_ai.responses_agent_thread_actions import ResponsesAgentThreadActions
18+
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
1819
from semantic_kernel.contents.chat_message_content import ChatMessageContent
1920
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
2021
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
@@ -499,11 +500,11 @@ async def mock_invoke_function_call(*args, **kwargs):
499500

500501
def test_get_tools(mock_agent, kernel, custom_plugin_class):
501502
kernel.add_plugin(custom_plugin_class)
502-
503+
fcb = FunctionChoiceBehavior()
503504
tools = ResponsesAgentThreadActions._get_tools(
504505
agent=mock_agent,
505506
kernel=kernel,
506-
function_choice_behavior=MagicMock(),
507+
function_choice_behavior=fcb,
507508
)
508509

509510
assert len(tools) == len(mock_agent.tools) + len(kernel.get_full_list_of_function_metadata())

python/tests/unit/prompt_template/test_handlebars_prompt_template.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,108 @@ async def test_helpers_message(kernel: Kernel):
261261
assert "Assistant message" in rendered
262262

263263

264+
async def test_helpers_message_escapes_xml_metacharacters(kernel: Kernel):
265+
template = """
266+
{{#each chat_history}}
267+
{{#message role=role}}
268+
{{~content~}}
269+
{{/message}}
270+
{{/each}}
271+
"""
272+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
273+
chat_history = ChatHistory()
274+
chat_history.add_user_message('What does a < b & a > c & "d" mean?')
275+
276+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
277+
278+
assert "&lt;" in rendered
279+
assert "&amp;" in rendered
280+
# ElementTree does not escape > in text content (valid XML); verify round-trip works regardless.
281+
assert "a > c" in rendered or "a &gt; c" in rendered
282+
assert '"d"' in rendered
283+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
284+
285+
286+
async def test_helpers_message_uses_text_element(kernel: Kernel):
287+
"""Verify handlebars {{#message}} wraps content in <text> like the Jinja2 path."""
288+
template = """
289+
{{#each chat_history}}
290+
{{#message role=role}}
291+
{{~content~}}
292+
{{/message}}
293+
{{/each}}
294+
"""
295+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
296+
chat_history = ChatHistory()
297+
chat_history.add_user_message("User message")
298+
chat_history.add_assistant_message("Assistant message")
299+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
300+
assert '<message role="user"><text>User message</text></message>' in rendered
301+
assert '<message role="assistant"><text>Assistant message</text></message>' in rendered
302+
chat_history2 = ChatHistory.from_rendered_prompt(rendered)
303+
assert chat_history2 == chat_history
304+
305+
306+
async def test_helpers_message_empty_content(kernel: Kernel):
307+
"""Empty message content should produce <message role="..."></message>, not self-closing."""
308+
template = """
309+
{{#each chat_history}}
310+
{{#message role=role}}
311+
{{~content~}}
312+
{{/message}}
313+
{{/each}}
314+
"""
315+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
316+
chat_history = ChatHistory()
317+
chat_history.add_user_message("")
318+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
319+
assert "<message" in rendered
320+
assert "/>" not in rendered
321+
assert ChatHistory.from_rendered_prompt(rendered) is not None
322+
323+
324+
async def test_helpers_message_fallback_empty_content(kernel: Kernel):
325+
"""Fallback path (non-ChatMessageContent context) with empty block content.
326+
327+
Should produce <message role="..."></message>, not self-closing.
328+
"""
329+
template = '{{#message role="user"}}{{/message}}'
330+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
331+
rendered = await target.render(kernel, KernelArguments())
332+
assert '<message role="user"></message>' in rendered
333+
assert "/>" not in rendered
334+
assert ChatHistory.from_rendered_prompt(rendered) is not None
335+
336+
337+
async def test_helpers_message_fallback_with_content(kernel: Kernel):
338+
"""Fallback path wraps block content in a <text> child element."""
339+
template = '{{#message role="user"}}Hello world{{/message}}'
340+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
341+
rendered = await target.render(kernel, KernelArguments())
342+
assert '<message role="user"><text>Hello world</text></message>' in rendered
343+
chat_history = ChatHistory.from_rendered_prompt(rendered)
344+
assert chat_history is not None
345+
assert len(chat_history) == 1
346+
assert chat_history[0].content == "Hello world"
347+
348+
349+
async def test_helpers_message_escapes_greater_than(kernel: Kernel):
350+
"""ElementTree does not escape > in text; verify round-trip still works."""
351+
template = """
352+
{{#each chat_history}}
353+
{{#message role=role}}
354+
{{~content~}}
355+
{{/message}}
356+
{{/each}}
357+
"""
358+
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)
359+
chat_history = ChatHistory()
360+
chat_history.add_user_message("Is a > b true?")
361+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
362+
assert "a > b" in rendered or "a &gt; b" in rendered
363+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
364+
365+
264366
async def test_helpers_message_to_prompt(kernel: Kernel):
265367
template = """{{#each chat_history}}{{message_to_prompt}} {{/each}}"""
266368
target = create_handlebars_prompt_template(template, allow_dangerously_set_content=True)

python/tests/unit/prompt_template/test_handlebars_prompt_template_e2e.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33

44
from semantic_kernel import Kernel
5+
from semantic_kernel.contents import AuthorRole
56
from semantic_kernel.contents.chat_history import ChatHistory
67
from semantic_kernel.functions import kernel_function
78
from semantic_kernel.functions.kernel_arguments import KernelArguments
@@ -94,9 +95,46 @@ async def test_chat_history_round_trip(self, kernel: Kernel):
9495
chat_history.add_user_message("User message")
9596
chat_history.add_assistant_message("Assistant message")
9697
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
97-
assert (
98-
rendered.strip()
99-
== """<message role="user">User message</message> <message role="assistant">Assistant message</message>"""
98+
expected = (
99+
'<message role="user"><text>User message</text></message>'
100+
' <message role="assistant"><text>Assistant message</text></message>'
100101
)
102+
assert rendered.strip() == expected
101103
chat_history2 = ChatHistory.from_rendered_prompt(rendered)
102104
assert chat_history2 == chat_history
105+
106+
async def test_chat_history_round_trip_with_xml_metacharacters(self, kernel: Kernel):
107+
# Arrange
108+
template = """{{#each chat_history}}{{#message role=role}}{{~content~}}{{/message}} {{/each}}"""
109+
target = create_handlebars_prompt_template(template)
110+
chat_history = ChatHistory()
111+
chat_history.add_user_message("What does a < b mean in Python?")
112+
chat_history.add_assistant_message('Use "&" carefully in XML and HTML.')
113+
114+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
115+
116+
assert "&lt;" in rendered
117+
assert "&amp;" in rendered
118+
assert '"&amp;"' in rendered
119+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
120+
121+
async def test_message_helper_preserves_system_role_with_xml_metacharacters(self, kernel: Kernel):
122+
# Arrange
123+
template = (
124+
"""{{system_message}}{{#each chat_history}}{{#message role=role}}{{~content~}}{{/message}} {{/each}}"""
125+
)
126+
target = create_handlebars_prompt_template(template)
127+
system_message = "You are a helpful assistant."
128+
chat_history = ChatHistory()
129+
chat_history.add_user_message("What does a < b mean in Python?")
130+
131+
rendered = await target.render(
132+
kernel,
133+
KernelArguments(system_message=system_message, chat_history=chat_history),
134+
)
135+
136+
parsed = ChatHistory.from_rendered_prompt(rendered)
137+
assert parsed.messages[0].role == AuthorRole.SYSTEM
138+
assert parsed.messages[0].content == system_message
139+
assert parsed.messages[1].role == AuthorRole.USER
140+
assert parsed.messages[1].content == "What does a < b mean in Python?"

python/tests/unit/prompt_template/test_jinja2_prompt_template.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,20 @@ async def test_helpers_message(kernel: Kernel):
264264
assert "Assistant message" in rendered
265265

266266

267+
async def test_helpers_message_escapes_xml_metacharacters(kernel: Kernel):
268+
template = """{% for item in chat_history %}{{ message(item) }}{% endfor %}"""
269+
target = create_jinja2_prompt_template(template, allow_dangerously_set_content=True)
270+
chat_history = ChatHistory()
271+
chat_history.add_user_message('What does a < b & "c" mean?')
272+
273+
rendered = await target.render(kernel, KernelArguments(chat_history=chat_history))
274+
275+
assert "&lt;" in rendered
276+
assert "&amp;" in rendered
277+
assert '"c"' in rendered
278+
assert ChatHistory.from_rendered_prompt(rendered) == chat_history
279+
280+
267281
async def test_helpers_message_to_prompt(kernel: Kernel):
268282
template = """
269283
{% for chat in chat_history %}

0 commit comments

Comments
 (0)