Skip to content

Commit 86b10b9

Browse files
authored
Python: provide AzureAIAgent thread msg id during streaming (#13032)
### Motivation and Context As part of invoking an AzureAIAgent via streaming, we weren't providing the `thread_id` via the `AgentStreamEvent.THREAD_MESSAGE_CREATED` event. <!-- 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. --> ### Description Supply the caller with the `thread_msg_id` by sticking it into the returned SCMC's `metadata` dictionary. - Closes #12959 Sample output looks like: ``` Sample Output: # User: 'Hello' # AuthorRole.ASSISTANT: (thread message id: msg_HZ2h4Wzbj7GEcnVCjnyEuYWT) Hello! How can I assist you with the menu today? # User: 'What is the special soup?' # AuthorRole.ASSISTANT: (thread message id: msg_TSjkJK6hHJojIkPvF6uUofHD) The special soup today is Clam Chowder. Would you like to know more about it or anything else from the menu? # User: 'How much does that cost?' # AuthorRole.ASSISTANT: (thread message id: msg_liwTpBFrB9JpCM1oM9EXKiwq) The Clam Chowder costs $9.99. Is there anything else you'd like to know? # User: 'Thank you' # AuthorRole.ASSISTANT: (thread message id: msg_K6lpR3gYIHethXq17T6gJcxi) You're welcome! If you have any more questions or need assistance, feel free to ask. Enjoy your meal! ``` <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### 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 38a7d91 commit 86b10b9

File tree

5 files changed

+64
-7
lines changed

5 files changed

+64
-7
lines changed

python/samples/concepts/agents/azure_ai_agent/azure_ai_agent_streaming.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
The following sample demonstrates how to create an Azure AI Agent
1313
and use it with streaming responses. The agent is configured to use
1414
a plugin that provides a list of specials from the menu and the price
15-
of the requested menu item.
15+
of the requested menu item. The thread message ID is also printed as each
16+
message is processed.
1617
"""
1718

1819

@@ -72,12 +73,22 @@ async def main() -> None:
7273
]
7374

7475
try:
76+
last_thread_msg_id = None
7577
for user_input in user_inputs:
7678
print(f"# User: '{user_input}'")
7779
first_chunk = True
78-
async for response in agent.invoke_stream(messages=user_input, thread=thread):
80+
async for response in agent.invoke_stream(
81+
messages=user_input,
82+
thread=thread,
83+
):
7984
if first_chunk:
8085
print(f"# {response.role}: ", end="", flush=True)
86+
# Show the thread message id before the first text chunk
87+
if "thread_message_id" in response.content.metadata:
88+
current_id = response.content.metadata["thread_message_id"]
89+
if current_id != last_thread_msg_id:
90+
print(f"(thread message id: {current_id}) ", end="", flush=True)
91+
last_thread_msg_id = current_id
8192
first_chunk = False
8293
print(response.content, end="", flush=True)
8394
thread = response.thread
@@ -87,6 +98,23 @@ async def main() -> None:
8798
await thread.delete() if thread else None
8899
await client.agents.delete_agent(agent.id)
89100

101+
"""
102+
Sample Output:
103+
104+
# User: 'Hello'
105+
# AuthorRole.ASSISTANT: (thread message id: msg_HZ2h4Wzbj7GEcnVCjnyEuYWT) Hello! How can I assist you with
106+
the menu today?
107+
# User: 'What is the special soup?'
108+
# AuthorRole.ASSISTANT: (thread message id: msg_TSjkJK6hHJojIkPvF6uUofHD) The special soup today is
109+
Clam Chowder. Would you like to know more about it or anything else from the menu?
110+
# User: 'How much does that cost?'
111+
# AuthorRole.ASSISTANT: (thread message id: msg_liwTpBFrB9JpCM1oM9EXKiwq) The Clam Chowder costs $9.99.
112+
Is there anything else you'd like to know?
113+
# User: 'Thank you'
114+
# AuthorRole.ASSISTANT: (thread message id: msg_K6lpR3gYIHethXq17T6gJcxi) You're welcome!
115+
If you have any more questions or need assistance, feel free to ask. Enjoy your meal!
116+
"""
117+
90118

91119
if __name__ == "__main__":
92120
asyncio.run(main())

python/semantic_kernel/agents/azure_ai/agent_content_generation.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
RunStepDeltaToolCallObject,
5454
)
5555

56+
THREAD_MESSAGE_ID = "thread_message_id"
57+
5658
"""
5759
The methods in this file are used with Azure AI Agent
5860
related code. They are used to invoke, create chat messages,
@@ -115,6 +117,7 @@ def generate_message_content(
115117
{
116118
"created_at": completed_step.created_at,
117119
"message_id": message.id, # message needs to be defined in context
120+
"thread_message_id": message.id, # Add `thread_message_id` to avoid breaking the existing `message_id` key
118121
"step_id": completed_step.id,
119122
"run_id": completed_step.run_id,
120123
"thread_id": completed_step.thread_id,
@@ -150,7 +153,9 @@ def generate_message_content(
150153

151154
@experimental
152155
def generate_streaming_message_content(
153-
assistant_name: str, message_delta_event: "MessageDeltaChunk"
156+
assistant_name: str,
157+
message_delta_event: "MessageDeltaChunk",
158+
thread_msg_id: str | None = None,
154159
) -> StreamingChatMessageContent:
155160
"""Generate streaming message content from a MessageDeltaEvent."""
156161
delta = message_delta_event.delta
@@ -196,7 +201,11 @@ def generate_streaming_message_content(
196201
)
197202
)
198203

199-
return StreamingChatMessageContent(role=role, name=assistant_name, items=items, choice_index=0) # type: ignore
204+
metadata: dict[str, Any] | None = None
205+
if thread_msg_id:
206+
metadata = {THREAD_MESSAGE_ID: thread_msg_id}
207+
208+
return StreamingChatMessageContent(role=role, name=assistant_name, items=items, choice_index=0, metadata=metadata) # type: ignore
200209

201210

202211
@experimental

python/semantic_kernel/agents/azure_ai/agent_thread_actions.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from azure.ai.agents.models._enums import MessageRole
3939

4040
from semantic_kernel.agents.azure_ai.agent_content_generation import (
41+
THREAD_MESSAGE_ID,
4142
generate_azure_ai_search_content,
4243
generate_bing_grounding_content,
4344
generate_code_interpreter_content,
@@ -550,6 +551,7 @@ async def _process_stream_events(
550551
output_messages: "list[ChatMessageContent] | None" = None,
551552
) -> AsyncIterable["StreamingChatMessageContent"]:
552553
"""Process events from the main stream and delegate tool output handling as needed."""
554+
thread_msg_id = None
553555
while True:
554556
# Use 'async with' only if the stream supports async context management (main agent stream).
555557
# Tool output handlers only support async iteration, not context management.
@@ -567,8 +569,14 @@ async def _process_stream_events(
567569
run_step = cast(RunStep, event_data)
568570
logger.info(f"Assistant run in progress with ID: {run_step.id}")
569571

572+
elif event_type == AgentStreamEvent.THREAD_MESSAGE_CREATED:
573+
# Keep the current message id stable unless a new one arrives
574+
if thread_msg_id != event_data.id:
575+
thread_msg_id = event_data.id
576+
logger.info(f"Assistant message created with ID: {thread_msg_id}")
577+
570578
elif event_type == AgentStreamEvent.THREAD_MESSAGE_DELTA:
571-
yield generate_streaming_message_content(agent.name, event_data)
579+
yield generate_streaming_message_content(agent.name, event_data, thread_msg_id)
572580

573581
elif event_type == AgentStreamEvent.THREAD_RUN_STEP_COMPLETED:
574582
step_completed = cast(RunStep, event_data)
@@ -622,6 +630,8 @@ async def _process_stream_events(
622630
agent_name=agent.name, step_details=details
623631
)
624632
if content:
633+
if thread_msg_id and THREAD_MESSAGE_ID not in content.metadata:
634+
content.metadata[THREAD_MESSAGE_ID] = thread_msg_id
625635
if output_messages is not None:
626636
output_messages.append(content)
627637
if content_is_visible:
@@ -654,6 +664,8 @@ async def _process_stream_events(
654664
action_result.function_result_streaming_content,
655665
):
656666
if content and output_messages is not None:
667+
if thread_msg_id and THREAD_MESSAGE_ID not in content.metadata:
668+
content.metadata[THREAD_MESSAGE_ID] = thread_msg_id
657669
output_messages.append(content)
658670

659671
handler: BaseAsyncAgentEventHandler = AsyncAgentEventHandler()
@@ -692,6 +704,8 @@ async def _process_stream_events(
692704
agent_name=agent.name, mcp_tool_calls=mcp_tool_calls
693705
)
694706
if content:
707+
if thread_msg_id and THREAD_MESSAGE_ID not in content.metadata:
708+
content.metadata[THREAD_MESSAGE_ID] = thread_msg_id
695709
output_messages.append(content)
696710

697711
# Create tool approvals for MCP calls

python/tests/unit/agents/azure_ai_agent/test_agent_content_generation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
)
4040

4141
from semantic_kernel.agents.azure_ai.agent_content_generation import (
42+
THREAD_MESSAGE_ID,
4243
generate_annotation_content,
4344
generate_bing_grounding_content,
4445
generate_code_interpreter_content,
@@ -295,11 +296,12 @@ def test_generate_streaming_message_content_text_only_no_annotations():
295296
],
296297
),
297298
)
298-
out = generate_streaming_message_content("assistant", delta)
299+
out = generate_streaming_message_content("assistant", delta, thread_msg_id="thread_1")
299300
assert out.content == "just text"
300301
assert len(out.items) == 1
301302
assert isinstance(out.items[0], StreamingTextContent)
302303
assert out.items[0].text == "just text"
304+
assert out.metadata.get(THREAD_MESSAGE_ID) == "thread_1"
303305

304306

305307
def test_generate_annotation_content_empty_title_and_url_only():

python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,10 @@ def __iter__(self):
295295

296296

297297
class MockRunData:
298-
def __init__(self, id, status):
298+
def __init__(self, id, status, content: str | None = None):
299299
self.id = id
300300
self.status = status
301+
self.content = content
301302

302303

303304
class MockAsyncIterable:
@@ -332,6 +333,7 @@ async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_de
332333

333334
events = [
334335
MockEvent("thread.run.created", MockRunData(id="run_1", status="queued")),
336+
MockEvent("thread.message.created", MockRunData(id="msg_1", status="created", content="Hello")),
335337
MockEvent("thread.run.in_progress", MockRunData(id="run_1", status="in_progress")),
336338
MockEvent("thread.run.completed", MockRunData(id="run_1", status="completed")),
337339
]
@@ -350,3 +352,5 @@ async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_de
350352
kernel=AsyncMock(spec=Kernel),
351353
):
352354
collected_messages.append(content)
355+
assert isinstance(content, ChatMessageContent)
356+
assert content.metadata.get("message_id") == "msg_1"

0 commit comments

Comments
 (0)