Python: Improve AzureAIAgent get thread msg handling. (#12535)

### Motivation and Context

Currently, when fetching thread messages for an AzureAIAgent, it assumes
that the agent exists, so we can pull it down and get its id. This may
not always be the case - an agent could have been deleted, but the
thread still exists. We need to account for this.

When creating user messages that we add to the thread, there's no `name`
or `id` parameter allowed. We can, however, add metadata to the message.
Going forward we are adding the `agent_id` as metadata to the message,
so upon retrieval later, we can attempt to get it and return the id with
the user's message. This allows one to see which agent_id has responded
to the user's message:

```
# Messages in the thread (asc order):

# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: Hello
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: Hello! How can I assist you with the menu today?
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: What is the special soup?
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: The special soup today is Clam Chowder. Would you like to know more about it or anything else on the menu?
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: How much does that cost?
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: The Clam Chowder costs $9.99. Would you like to order it or need information on other items?
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: Thank you
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: You're welcome! If you have any more questions or need assistance with the menu, feel free to ask. Enjoy your meal!
```

<!-- 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

- Closes #12435

<!-- 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 😄
This commit is contained in:
Evan Mattson
2025-06-19 23:52:07 +00:00
committed by GitHub
parent 003cc4c010
commit 9e3cdffc6b
4 changed files with 147 additions and 23 deletions
+1
View File
@@ -24,6 +24,7 @@
- [Azure AI Agent Prompt Templating](./agents/azure_ai_agent/azure_ai_agent_prompt_templating.py)
- [Azure AI Agent Message Callback Streaming](./agents/azure_ai_agent/azure_ai_agent_message_callback_streaming.py)
- [Azure AI Agent Message Callback](./agents/azure_ai_agent/azure_ai_agent_message_callback.py)
- [Azure AI Agent Retrieve Messages from Thread](./agents/azure_ai_agent/azure_ai_agent_retrieve_messages_from_thread.py)
- [Azure AI Agent Streaming](./agents/azure_ai_agent/azure_ai_agent_streaming.py)
- [Azure AI Agent Structured Outputs](./agents/azure_ai_agent/azure_ai_agent_structured_outputs.py)
- [Azure AI Agent Truncation Strategy](./agents/azure_ai_agent/azure_ai_agent_truncation_strategy.py)
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Annotated
from azure.identity.aio import DefaultAzureCredential
from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings, AzureAIAgentThread
from semantic_kernel.functions import kernel_function
"""
The following sample demonstrates how to create an Azure AI agent that answers
questions about a sample menu using a Semantic Kernel Plugin. After all questions
are answered, it retrieves and prints the messages from the thread.
"""
# Define a sample plugin for the sample
class MenuPlugin:
"""A sample Menu Plugin used for the concept sample."""
@kernel_function(description="Provides a list of specials from the menu.")
def get_specials(self) -> Annotated[str, "Returns the specials from the menu."]:
return """
Special Soup: Clam Chowder
Special Salad: Cobb Salad
Special Drink: Chai Tea
"""
@kernel_function(description="Provides the price of the requested menu item.")
def get_item_price(
self, menu_item: Annotated[str, "The name of the menu item."]
) -> Annotated[str, "Returns the price of the menu item."]:
return "$9.99"
# Simulate a conversation with the agent
USER_INPUTS = [
"Hello",
"What is the special soup?",
"How much does that cost?",
"Thank you",
]
async def main() -> None:
async with (
DefaultAzureCredential() as creds,
AzureAIAgent.create_client(credential=creds) as client,
):
# 1. Create an agent on the Azure AI agent service
agent_definition = await client.agents.create_agent(
model=AzureAIAgentSettings().model_deployment_name,
name="Host",
instructions="Answer questions about the menu.",
)
# 2. Create a Semantic Kernel agent for the Azure AI agent
agent = AzureAIAgent(
client=client,
definition=agent_definition,
plugins=[MenuPlugin()], # Add the plugin to the agent
)
# 3. Create a thread for the agent
# If no thread is provided, a new thread will be
# created and returned with the initial response
thread: AzureAIAgentThread | None = None
try:
for user_input in USER_INPUTS:
print(f"# User: {user_input}")
# 4. Invoke the agent for the specified thread for response
async for response in agent.invoke(
messages=user_input,
thread=thread,
):
print(f"# {response.name}: {response}")
thread = response.thread
finally:
# 5. Cleanup: Delete the thread and agent
# await thread.delete() if thread else None
await client.agents.delete_agent(agent.id)
print("*" * 50)
print("# Messages in the thread (asc order):\n")
async for msg in thread.get_messages(sort_order="asc"):
print(f"# {msg.role} for name={msg.name}: {msg.content}")
print("*" * 50)
await thread.delete() if thread else None
"""
# User: Hello
# Host: Hello! How can I assist you with the menu today?
# User: What is the special soup?
# Host: The special soup today is Clam Chowder. Would you like to know more about it or anything else
on the menu?
# User: How much does that cost?
# Host: The Clam Chowder costs $9.99. Would you like to order it or need information on other items?
# User: Thank you
# Host: You're welcome! If you have any more questions or need assistance with the menu, feel free to ask.
Enjoy your meal!
**************************************************
# Messages in the thread (asc order):
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: Hello
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: Hello! How can I assist you with the menu today?
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: What is the special soup?
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: The special soup today is Clam Chowder. Would
you like to know more about it or anything else on the menu?
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: How much does that cost?
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: The Clam Chowder costs $9.99. Would you like to
order it or need information on other items?
# AuthorRole.USER for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: Thank you
# AuthorRole.ASSISTANT for name=asst_mXwZOwyJLxXGtaYKHizRH6Ip: You're welcome! If you have any more questions
or need assistance with the menu, feel free to ask. Enjoy your meal!
"""
if __name__ == "__main__":
asyncio.run(main())
+7
View File
@@ -516,8 +516,15 @@ class Agent(KernelBaseModel, ABC):
f"{self.__class__.__name__} currently only supports agent threads of type {expected_type.__name__}."
)
# Track the agent ID as user msg metadata, which is useful for
# fetching thread messages as the agent may have been deleted.
id_metadata = {
"agent_id": self.id,
}
# Notify the thread that new messages are available.
for msg in normalized_messages:
msg.metadata.update(id_metadata)
await self._notify_thread_of_new_message(thread, msg)
return thread
@@ -58,7 +58,7 @@ from semantic_kernel.connectors.ai.function_calling_utils import kernel_function
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException, AgentThreadOperationException
from semantic_kernel.functions import KernelArguments
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
from semantic_kernel.utils.feature_stage_decorator import experimental
@@ -704,29 +704,23 @@ class AgentThreadActions:
Yields:
An AsyncIterable of ChatMessageContent that includes the thread messages.
Raises:
AgentThreadOperationException: If the messages cannot be retrieved.
"""
agent_names: dict[str, str] = {}
async for message in client.agents.messages.list(
thread_id=thread_id,
run_id=None,
limit=None,
order=sort_order,
before=None,
):
assistant_name: str | None = None
if message.agent_id and message.agent_id.strip() and message.agent_id not in agent_names:
agent = await client.agents.get_agent(message.agent_id)
if agent.name and agent.name.strip():
agent_names[agent.id] = agent.name
assistant_name = agent_names.get(message.agent_id) or message.agent_id
content = generate_message_content(assistant_name, message)
if len(content.items) > 0:
yield content
try:
async for message in client.agents.messages.list(
thread_id=thread_id,
run_id=None,
limit=None,
order=sort_order,
before=None,
):
agent_id = (message.agent_id or message.metadata.get("agent_id") or "").strip() or "agent"
yield generate_message_content(agent_id, message)
except Exception as e:
logger.error(f"Failed to retrieve messages for thread {thread_id}: {e}")
raise AgentThreadOperationException(f"Failed to retrieve messages for thread `{thread_id}`.") from e
# endregion