mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Thread storage and serialization (#394)
* Updates for message store support * Added unit tests * Added suspend-resume example * Added example with custom chat message store * Small fix * Addressed PR feedback * Renaming and documentation * More renaming * Addressed more PR feedback * Small fixes in Foundry chat client and examples * Small update * Addressed PR feedback * Increased timeout for Azure tests
This commit is contained in:
committed by
GitHub
Unverified
parent
95cb20ca40
commit
dea736e550
@@ -43,7 +43,7 @@ addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
timeout = 300
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
@@ -27,7 +27,7 @@ from agent_framework import (
|
||||
use_tool_calling,
|
||||
)
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
from agent_framework.telemetry import use_telemetry
|
||||
from azure.ai.agents.models import (
|
||||
AgentsNamedToolChoice,
|
||||
@@ -48,6 +48,7 @@ from azure.ai.agents.models import (
|
||||
RequiredFunctionToolCall,
|
||||
ResponseFormatJsonSchema,
|
||||
ResponseFormatJsonSchemaType,
|
||||
RunError,
|
||||
RunStatus,
|
||||
RunStep,
|
||||
SubmitToolOutputsAction,
|
||||
@@ -427,6 +428,12 @@ class FoundryChatClient(ChatClientBase):
|
||||
raw_representation=event_data,
|
||||
response_id=response_id,
|
||||
)
|
||||
elif (
|
||||
event_type == AgentStreamEvent.THREAD_RUN_FAILED
|
||||
and isinstance(event_data, ThreadRun)
|
||||
and isinstance(event_data.last_error, RunError)
|
||||
):
|
||||
raise ServiceResponseException(event_data.last_error.message)
|
||||
else:
|
||||
yield ChatResponseUpdate(
|
||||
contents=[],
|
||||
|
||||
@@ -12,5 +12,6 @@ from ._agents import * # noqa: F403
|
||||
from ._clients import * # noqa: F403
|
||||
from ._logging import * # noqa: F403
|
||||
from ._mcp import * # noqa: F403
|
||||
from ._threads import * # noqa: F403
|
||||
from ._tools import * # noqa: F403
|
||||
from ._types import * # noqa: F403
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Callable, MutableMapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack
|
||||
from enum import Enum
|
||||
from itertools import chain
|
||||
from typing import Any, ClassVar, Literal, Protocol, TypeVar, runtime_checkable
|
||||
from uuid import uuid4
|
||||
@@ -13,6 +12,7 @@ from pydantic import BaseModel, Field, PrivateAttr
|
||||
from ._clients import ChatClient
|
||||
from ._mcp import McpTool
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._threads import AgentThread, ChatMessageStore, deserialize_thread_state, thread_on_new_messages
|
||||
from ._tools import AITool
|
||||
from ._types import (
|
||||
AgentRunResponse,
|
||||
@@ -34,47 +34,7 @@ else:
|
||||
|
||||
TThreadType = TypeVar("TThreadType", bound="AgentThread")
|
||||
|
||||
# region AgentThread
|
||||
|
||||
__all__ = [
|
||||
"AIAgent",
|
||||
"AgentBase",
|
||||
"AgentThread",
|
||||
"ChatClientAgent",
|
||||
"ChatClientAgentThread",
|
||||
"ChatClientAgentThreadType",
|
||||
"MessagesRetrievableThread",
|
||||
]
|
||||
|
||||
|
||||
class AgentThread(AFBaseModel):
|
||||
"""Base class for agent threads."""
|
||||
|
||||
id: str | None = None
|
||||
|
||||
async def on_new_messages(
|
||||
self,
|
||||
new_messages: ChatMessage | Sequence[ChatMessage],
|
||||
) -> None:
|
||||
"""Invoked when a new message has been contributed to the chat by any participant."""
|
||||
await self._on_new_messages(new_messages=new_messages)
|
||||
|
||||
async def _on_new_messages(
|
||||
self,
|
||||
new_messages: ChatMessage | Sequence[ChatMessage],
|
||||
) -> None:
|
||||
"""Invoked when a new message has been contributed to the chat by any participant."""
|
||||
pass
|
||||
|
||||
|
||||
# region MessagesRetrievableThread
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MessagesRetrievableThread(Protocol):
|
||||
def get_messages(self) -> AsyncIterable[ChatMessage]:
|
||||
"""Asynchronously retrieves all messages from thread."""
|
||||
...
|
||||
__all__ = ["AIAgent", "AgentBase", "ChatClientAgent"]
|
||||
|
||||
|
||||
# region Agent Protocol
|
||||
@@ -186,7 +146,7 @@ class AgentBase(AFBaseModel):
|
||||
) -> None:
|
||||
"""Notify the thread of new messages."""
|
||||
if isinstance(new_messages, ChatMessage) or len(new_messages) > 0:
|
||||
await thread.on_new_messages(new_messages)
|
||||
await thread_on_new_messages(thread, new_messages)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -196,116 +156,17 @@ class AgentBase(AFBaseModel):
|
||||
"""
|
||||
return self.name or self.id
|
||||
|
||||
def _validate_or_create_thread_type(
|
||||
self,
|
||||
thread: AgentThread | None,
|
||||
construct_thread: Callable[[], TThreadType],
|
||||
expected_type: type[TThreadType],
|
||||
) -> TThreadType:
|
||||
"""Validate or create a AgentThread of the right type.
|
||||
|
||||
Args:
|
||||
thread: The thread to validate or create.
|
||||
construct_thread: A callable that constructs a new thread if `thread` is None.
|
||||
expected_type: The expected type of the thread.
|
||||
|
||||
Returns:
|
||||
The validated or newly created thread of the expected type.
|
||||
|
||||
Raises:
|
||||
AgentExecutionException: If the thread is not of the expected type.
|
||||
"""
|
||||
if thread is None:
|
||||
return construct_thread()
|
||||
|
||||
if not isinstance(thread, expected_type):
|
||||
raise AgentExecutionException(
|
||||
f"{self.__class__.__name__} currently only supports agent threads of type {expected_type.__name__}."
|
||||
)
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
"""Returns AgentThread instance that is compatible with the agent."""
|
||||
return AgentThread()
|
||||
|
||||
async def deserialize_thread(self, serialized_thread: Any, **kwargs: Any) -> AgentThread:
|
||||
"""Deserializes the thread."""
|
||||
thread: AgentThread = self.get_new_thread()
|
||||
await deserialize_thread_state(thread, serialized_thread, **kwargs)
|
||||
return thread
|
||||
|
||||
|
||||
# region ChatClientAgentThread
|
||||
|
||||
|
||||
class ChatClientAgentThreadType(Enum):
|
||||
"""Defines the different supported storage locations for ChatClientAgentThread."""
|
||||
|
||||
IN_MEMORY_MESSAGES = "InMemoryMessages"
|
||||
"""Messages are stored in memory inside the thread object."""
|
||||
|
||||
CONVERSATION_ID = "ConversationId"
|
||||
"""Messages are stored in the service and the thread object just has an id reference to the service storage."""
|
||||
|
||||
|
||||
class ChatClientAgentThread(AgentThread):
|
||||
"""Chat client agent thread.
|
||||
|
||||
This class manages chat threads either locally (in-memory) or via a service based on initialization.
|
||||
"""
|
||||
|
||||
chat_messages: list[ChatMessage] | None = None
|
||||
storage_location: ChatClientAgentThreadType | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str | None = None,
|
||||
messages: Sequence[ChatMessage] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initialize the chat client agent thread.
|
||||
|
||||
Args:
|
||||
id: Service thread identifier. If provided, thread is managed by the service and messages are
|
||||
not stored locally. Must not be empty or whitespace.
|
||||
messages: Initial messages for local storage. If provided, thread is managed
|
||||
locally in-memory.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
Raises:
|
||||
ValueError: If both id and messages are provided, or if id is empty/whitespace.
|
||||
|
||||
Notes:
|
||||
- If id is set, _id is assigned and _chat_messages is None (service-managed).
|
||||
- If messages is set, _chat_messages is populated and _id is None (local).
|
||||
- If neither is provided, creates an empty local thread.
|
||||
"""
|
||||
processed_messages: list[ChatMessage] | None = None
|
||||
storage_location: ChatClientAgentThreadType | None = None
|
||||
|
||||
if id and messages:
|
||||
raise ValueError("Cannot specify both id and messages")
|
||||
|
||||
if id:
|
||||
if not id.strip():
|
||||
raise ValueError("ID cannot be empty or whitespace")
|
||||
storage_location = ChatClientAgentThreadType.CONVERSATION_ID
|
||||
elif messages:
|
||||
processed_messages = []
|
||||
processed_messages.extend(messages)
|
||||
storage_location = ChatClientAgentThreadType.IN_MEMORY_MESSAGES
|
||||
|
||||
super().__init__(
|
||||
id=id,
|
||||
chat_messages=processed_messages, # type: ignore[reportCallIssue]
|
||||
storage_location=storage_location, # type: ignore[reportCallIssue]
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def get_messages(self) -> AsyncIterable[ChatMessage]:
|
||||
"""Get all messages in the thread."""
|
||||
for message in self.chat_messages or []:
|
||||
yield message
|
||||
|
||||
async def _on_new_messages(self, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Handle new messages."""
|
||||
if self.storage_location == ChatClientAgentThreadType.IN_MEMORY_MESSAGES:
|
||||
if self.chat_messages is None:
|
||||
self.chat_messages = []
|
||||
self.chat_messages.extend([new_messages] if isinstance(new_messages, ChatMessage) else new_messages)
|
||||
|
||||
|
||||
# region ChatClientAgent
|
||||
|
||||
|
||||
@@ -317,6 +178,7 @@ class ChatClientAgent(AgentBase):
|
||||
chat_client: ChatClient
|
||||
instructions: str | None = None
|
||||
chat_options: ChatOptions
|
||||
chat_message_store_factory: Callable[[], ChatMessageStore] | None = None
|
||||
_local_mcp_tools: list[McpTool] = PrivateAttr(default_factory=list) # type: ignore[reportUnknownVariableType]
|
||||
_async_exit_stack: AsyncExitStack = PrivateAttr(default_factory=AsyncExitStack)
|
||||
|
||||
@@ -350,6 +212,7 @@ class ChatClientAgent(AgentBase):
|
||||
top_p: float | None = None,
|
||||
user: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
chat_message_store_factory: Callable[[], ChatMessageStore] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Create a ChatClientAgent.
|
||||
@@ -382,6 +245,8 @@ class ChatClientAgent(AgentBase):
|
||||
top_p: the nucleus sampling probability to use.
|
||||
user: the user to associate with the request.
|
||||
additional_properties: additional properties to include in the request.
|
||||
chat_message_store_factory: factory function to create an instance of ChatMessageStore. If not provided,
|
||||
the default in-memory store will be used.
|
||||
kwargs: any additional keyword arguments.
|
||||
Unused, can be used by subclasses of this Agent.
|
||||
"""
|
||||
@@ -394,6 +259,7 @@ class ChatClientAgent(AgentBase):
|
||||
final_tools = [tool for tool in normalized_tools if not isinstance(tool, McpTool)]
|
||||
args: dict[str, Any] = {
|
||||
"chat_client": chat_client,
|
||||
"chat_message_store_factory": chat_message_store_factory,
|
||||
"chat_options": ChatOptions(
|
||||
ai_model_id=model,
|
||||
frequency_penalty=frequency_penalty,
|
||||
@@ -537,7 +403,7 @@ class ChatClientAgent(AgentBase):
|
||||
chat_options=self.chat_options
|
||||
& ChatOptions(
|
||||
ai_model_id=model,
|
||||
conversation_id=thread.id,
|
||||
conversation_id=thread.service_thread_id,
|
||||
frequency_penalty=frequency_penalty,
|
||||
logit_bias=logit_bias,
|
||||
max_tokens=max_tokens,
|
||||
@@ -660,7 +526,7 @@ class ChatClientAgent(AgentBase):
|
||||
messages=thread_messages,
|
||||
chat_options=self.chat_options
|
||||
& ChatOptions(
|
||||
conversation_id=thread.id,
|
||||
conversation_id=thread.service_thread_id,
|
||||
frequency_penalty=frequency_penalty,
|
||||
logit_bias=logit_bias,
|
||||
max_tokens=max_tokens,
|
||||
@@ -705,44 +571,50 @@ class ChatClientAgent(AgentBase):
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
|
||||
def get_new_thread(self) -> ChatClientAgentThread:
|
||||
return ChatClientAgentThread()
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
message_store: ChatMessageStore | None = None
|
||||
|
||||
if self.chat_message_store_factory:
|
||||
message_store = self.chat_message_store_factory()
|
||||
|
||||
return AgentThread() if message_store is None else AgentThread(message_store=message_store)
|
||||
|
||||
def _update_thread_with_type_and_conversation_id(
|
||||
self, chat_client_thread: ChatClientAgentThread, responseConversationId: str | None
|
||||
self, thread: AgentThread, response_conversation_id: str | None
|
||||
) -> None:
|
||||
"""Update thread with storage type and conversation ID.
|
||||
|
||||
Args:
|
||||
chat_client_thread: The thread to update.
|
||||
responseConversationId: The conversation ID from the response, if any.
|
||||
thread: The thread to update.
|
||||
response_conversation_id: The conversation ID from the response, if any.
|
||||
|
||||
Raises:
|
||||
AgentExecutionException: If conversation ID is missing for service-managed thread.
|
||||
"""
|
||||
# Set the thread's storage location, the first time that we use it.
|
||||
if chat_client_thread.storage_location is None:
|
||||
chat_client_thread.storage_location = (
|
||||
ChatClientAgentThreadType.CONVERSATION_ID
|
||||
if responseConversationId is not None
|
||||
else ChatClientAgentThreadType.IN_MEMORY_MESSAGES
|
||||
if response_conversation_id is None and thread.service_thread_id is not None:
|
||||
# We were passed a thread that is service managed, but we got no conversation id back from the chat client,
|
||||
# meaning the service doesn't support service managed threads,
|
||||
# so the thread cannot be used with this service.
|
||||
raise AgentExecutionException(
|
||||
"Service did not return a valid conversation id when using a service managed thread."
|
||||
)
|
||||
|
||||
# If we got a conversation id back from the chat client, it means that the service supports server side thread
|
||||
# storage so we should capture the id and update the thread with the new id.
|
||||
if chat_client_thread.storage_location == ChatClientAgentThreadType.CONVERSATION_ID:
|
||||
if responseConversationId is None:
|
||||
raise AgentExecutionException(
|
||||
"Service did not return a valid conversation id when using a service managed thread."
|
||||
)
|
||||
chat_client_thread.id = responseConversationId
|
||||
if response_conversation_id is not None:
|
||||
# If we got a conversation id back from the chat client, it means that the service
|
||||
# supports server side thread storage so we should update the thread with the new id.
|
||||
thread.service_thread_id = response_conversation_id
|
||||
elif thread.message_store is None and self.chat_message_store_factory is not None:
|
||||
# If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
|
||||
# the thread has no message_store yet, and we have a custom messages store, we should update the thread
|
||||
# with the custom message_store so that it has somewhere to store the chat history.
|
||||
thread.message_store = self.chat_message_store_factory()
|
||||
|
||||
async def _prepare_thread_and_messages(
|
||||
self,
|
||||
*,
|
||||
thread: AgentThread | None,
|
||||
input_messages: list[ChatMessage] | None = None,
|
||||
) -> tuple[ChatClientAgentThread, list[ChatMessage]]:
|
||||
) -> tuple[AgentThread, list[ChatMessage]]:
|
||||
"""Prepare the messages for agent execution.
|
||||
|
||||
Args:
|
||||
@@ -755,19 +627,14 @@ class ChatClientAgent(AgentBase):
|
||||
Raises:
|
||||
AgentExecutionException: If the thread is not of the expected type.
|
||||
"""
|
||||
validated_thread: ChatClientAgentThread = self._validate_or_create_thread_type( # type: ignore[reportAssignmentType]
|
||||
thread=thread,
|
||||
construct_thread=self.get_new_thread,
|
||||
expected_type=ChatClientAgentThread,
|
||||
)
|
||||
thread = thread or self.get_new_thread()
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
if self.instructions:
|
||||
messages.append(ChatMessage(role=ChatRole.SYSTEM, text=self.instructions))
|
||||
if isinstance(validated_thread, MessagesRetrievableThread):
|
||||
async for message in validated_thread.get_messages():
|
||||
messages.append(message)
|
||||
messages.extend(await thread.list_messages() or [])
|
||||
messages.extend(input_messages or [])
|
||||
return validated_thread, messages
|
||||
return thread, messages
|
||||
|
||||
def _normalize_messages(
|
||||
self,
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._threads import ChatMessageStore
|
||||
from ._tools import AIFunction, AITool
|
||||
from ._types import (
|
||||
AIContents,
|
||||
@@ -645,6 +646,7 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
| MutableMapping[str, Any]
|
||||
| list[MutableMapping[str, Any]]
|
||||
| None = None,
|
||||
chat_message_store_factory: Callable[[], ChatMessageStore] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> "ChatClientAgent":
|
||||
"""Create an agent with the given name and instructions.
|
||||
@@ -653,6 +655,8 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
name: The name of the agent.
|
||||
instructions: The instructions for the agent.
|
||||
tools: Optional list of tools to associate with the agent.
|
||||
chat_message_store_factory: Factory function to create an instance of ChatMessageStore. If not provided,
|
||||
the default in-memory store will be used.
|
||||
**kwargs: Additional keyword arguments to pass to the agent.
|
||||
See ChatClientAgent for all the available options.
|
||||
|
||||
@@ -661,7 +665,14 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
"""
|
||||
from ._agents import ChatClientAgent
|
||||
|
||||
return ChatClientAgent(chat_client=self, name=name, instructions=instructions, tools=tools, **kwargs)
|
||||
return ChatClientAgent(
|
||||
chat_client=self,
|
||||
name=name,
|
||||
instructions=instructions,
|
||||
tools=tools,
|
||||
chat_message_store_factory=chat_message_store_factory,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# region Embedding Client
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, overload
|
||||
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._types import ChatMessage
|
||||
|
||||
__all__ = ["AgentThread", "ChatMessageList", "ChatMessageStore"]
|
||||
|
||||
|
||||
class ChatMessageStore(Protocol):
|
||||
"""Defines methods for storing and retrieving chat messages associated with a specific thread.
|
||||
|
||||
Implementations of this protocol are responsible for managing the storage of chat messages,
|
||||
including handling large volumes of data by truncating or summarizing messages as necessary.
|
||||
"""
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Gets all the messages from the store that should be used for the next agent invocation.
|
||||
|
||||
Messages are returned in ascending chronological order, with the oldest message first.
|
||||
|
||||
If the messages stored in the store become very large, it is up to the store to
|
||||
truncate, summarize or otherwise limit the number of messages returned.
|
||||
|
||||
When using implementations of ChatMessageStore, a new one should be created for each thread
|
||||
since they may contain state that is specific to a thread.
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Adds messages to the store."""
|
||||
...
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserializes the state into the properties on this store.
|
||||
|
||||
This method, together with serialize_state can be used to save and load messages from a persistent store
|
||||
if this store only has messages in memory.
|
||||
"""
|
||||
...
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
"""Serializes the current object's state.
|
||||
|
||||
This method, together with deserialize_state can be used to save and load messages from a persistent store
|
||||
if this store only has messages in memory.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class AgentThread(AFBaseModel):
|
||||
"""Base class for agent threads."""
|
||||
|
||||
_service_thread_id: str | None = None
|
||||
_message_store: ChatMessageStore | None = None
|
||||
|
||||
@overload
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty AgentThread with no service thread ID or message store."""
|
||||
...
|
||||
|
||||
@overload
|
||||
def __init__(self, service_thread_id: str) -> None:
|
||||
"""Initialize an AgentThread with a service thread ID.
|
||||
|
||||
Args:
|
||||
service_thread_id: The ID of the thread managed by the agent service.
|
||||
"""
|
||||
...
|
||||
|
||||
@overload
|
||||
def __init__(self, *, message_store: ChatMessageStore) -> None:
|
||||
"""Initialize an AgentThread with a custom message store.
|
||||
|
||||
Args:
|
||||
message_store: The ChatMessageStore implementation for managing chat messages.
|
||||
"""
|
||||
...
|
||||
|
||||
def __init__(self, service_thread_id: str | None = None, *, message_store: ChatMessageStore | None = None) -> None:
|
||||
"""Initialize an AgentThread.
|
||||
|
||||
Args:
|
||||
service_thread_id: Optional ID of the thread managed by the agent service.
|
||||
message_store: Optional ChatMessageStore implementation for managing chat messages.
|
||||
|
||||
Note:
|
||||
Either service_thread_id or message_store may be set, but not both.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.service_thread_id = service_thread_id
|
||||
self.message_store = message_store
|
||||
|
||||
@property
|
||||
def service_thread_id(self) -> str | None:
|
||||
"""Gets the ID of the current thread to support cases where the thread is owned by the agent service."""
|
||||
return self._service_thread_id
|
||||
|
||||
@service_thread_id.setter
|
||||
def service_thread_id(self, service_thread_id: str | None) -> None:
|
||||
"""Sets the ID of the current thread to support cases where the thread is owned by the agent service.
|
||||
|
||||
Note that either service_thread_id or message_store may be set, but not both.
|
||||
"""
|
||||
if not self._service_thread_id and not service_thread_id:
|
||||
return
|
||||
|
||||
if self._message_store is not None:
|
||||
raise ValueError(
|
||||
"Only the service_thread_id or message_store may be set, "
|
||||
"but not both and switching from one to another is not supported."
|
||||
)
|
||||
|
||||
self._service_thread_id = service_thread_id
|
||||
|
||||
@property
|
||||
def message_store(self) -> ChatMessageStore | None:
|
||||
"""Gets the ChatMessageStore used by this thread, when messages should be stored in a custom location."""
|
||||
return self._message_store
|
||||
|
||||
@message_store.setter
|
||||
def message_store(self, message_store: ChatMessageStore | None) -> None:
|
||||
"""Sets the ChatMessageStore used by this thread, when messages should be stored in a custom location.
|
||||
|
||||
Note that either service_thread_id or message_store may be set, but not both.
|
||||
"""
|
||||
if self._message_store is None and message_store is None:
|
||||
return
|
||||
|
||||
if self._service_thread_id:
|
||||
raise ValueError(
|
||||
"Only the service_thread_id or message_store may be set, "
|
||||
"but not both and switching from one to another is not supported."
|
||||
)
|
||||
|
||||
self._message_store = message_store
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage] | None:
|
||||
"""Retrieves any messages stored in ChatMessageStore of the thread, otherwise returns an empty collection."""
|
||||
return await self._message_store.list_messages() if self._message_store is not None else None
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Serializes the current object's state.
|
||||
|
||||
Args:
|
||||
**kwargs: Arguments for serialization.
|
||||
"""
|
||||
chat_message_store_state = None
|
||||
if self._message_store is not None:
|
||||
chat_message_store_state = await self._message_store.serialize_state(**kwargs)
|
||||
|
||||
state = ThreadState(
|
||||
service_thread_id=self._service_thread_id, chat_message_store_state=chat_message_store_state
|
||||
)
|
||||
|
||||
return state.model_dump()
|
||||
|
||||
|
||||
async def thread_on_new_messages(thread: AgentThread, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Invoked when a new message has been contributed to the chat by any participant."""
|
||||
if thread.service_thread_id is not None:
|
||||
# If the thread messages are stored in the service there is nothing to do here,
|
||||
# since invoking the service should already update the thread.
|
||||
return
|
||||
|
||||
if thread.message_store is None:
|
||||
# If there is no conversation id, and no store we can
|
||||
# create a default in memory store.
|
||||
thread.message_store = ChatMessageList()
|
||||
|
||||
# If a store has been provided, we need to add the messages to the store.
|
||||
if isinstance(new_messages, ChatMessage):
|
||||
new_messages = [new_messages]
|
||||
|
||||
await thread.message_store.add_messages(new_messages)
|
||||
|
||||
|
||||
async def deserialize_thread_state(
|
||||
thread: AgentThread,
|
||||
serialized_thread: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Deserializes the state from a dictionary into the thread properties."""
|
||||
state = ThreadState.model_validate(serialized_thread)
|
||||
|
||||
if state.service_thread_id:
|
||||
thread.service_thread_id = state.service_thread_id
|
||||
# Since we have an ID, we should not have a chat message store and we can return here.
|
||||
return
|
||||
|
||||
# If we don't have any ChatMessageStore state return here.
|
||||
if state.chat_message_store_state is None:
|
||||
return
|
||||
|
||||
if thread.message_store is None:
|
||||
# If we don't have a chat message store yet, create an in-memory one.
|
||||
thread.message_store = ChatMessageList()
|
||||
|
||||
await thread.message_store.deserialize_state(state.chat_message_store_state, **kwargs)
|
||||
|
||||
|
||||
class ThreadState(AFBaseModel):
|
||||
"""State model for serializing and deserializing thread information.
|
||||
|
||||
Attributes:
|
||||
service_thread_id: Optional ID of the thread managed by the agent service.
|
||||
chat_message_store_state: Optional serialized state of the chat message store.
|
||||
"""
|
||||
|
||||
service_thread_id: str | None = None
|
||||
chat_message_store_state: Any | None = None
|
||||
|
||||
|
||||
class StoreState(AFBaseModel):
|
||||
"""State model for serializing and deserializing chat message store data.
|
||||
|
||||
Attributes:
|
||||
messages: List of chat messages stored in the message store.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class ChatMessageList:
|
||||
"""An in-memory implementation of ChatMessageStore that stores messages in a list.
|
||||
|
||||
This implementation provides a simple, list-based storage for chat messages
|
||||
with support for serialization and deserialization. It implements all the
|
||||
required methods of the ChatMessageStore protocol and provides additional
|
||||
list-like operations for direct message manipulation.
|
||||
|
||||
The store maintains messages in memory and provides methods to serialize
|
||||
and deserialize the state for persistence purposes.
|
||||
"""
|
||||
|
||||
def __init__(self, messages: Sequence[ChatMessage] | None = None) -> None:
|
||||
"""Initialize the message store with optional initial messages.
|
||||
|
||||
Args:
|
||||
messages: Optional collection of initial ChatMessage objects to store.
|
||||
"""
|
||||
self._messages: list[ChatMessage] = []
|
||||
if messages:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Add messages to the store.
|
||||
|
||||
Args:
|
||||
messages: Sequence of ChatMessage objects to add to the store.
|
||||
"""
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Get all messages from the store in chronological order.
|
||||
|
||||
Returns:
|
||||
List of ChatMessage objects, ordered from oldest to newest.
|
||||
"""
|
||||
return self._messages
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserialize state data into this store instance.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data containing messages.
|
||||
**kwargs: Additional arguments for deserialization.
|
||||
"""
|
||||
if serialized_store_state:
|
||||
state = StoreState.model_validate(obj=serialized_store_state, **kwargs)
|
||||
if state.messages:
|
||||
self._messages.extend(state.messages)
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
"""Serialize the current store state for persistence.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments for serialization.
|
||||
|
||||
Returns:
|
||||
Serialized state data that can be used with deserialize_state.
|
||||
"""
|
||||
state = StoreState(messages=self._messages)
|
||||
return state.model_dump(**kwargs)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of messages in the store.
|
||||
|
||||
Returns:
|
||||
The count of messages currently stored.
|
||||
"""
|
||||
return len(self._messages)
|
||||
|
||||
def __getitem__(self, index: int) -> ChatMessage:
|
||||
"""Get a message by index.
|
||||
|
||||
Args:
|
||||
index: The index of the message to retrieve.
|
||||
|
||||
Returns:
|
||||
The ChatMessage at the specified index.
|
||||
"""
|
||||
return self._messages[index]
|
||||
|
||||
def __setitem__(self, index: int, item: ChatMessage) -> None:
|
||||
"""Set a message at the specified index.
|
||||
|
||||
Args:
|
||||
index: The index at which to set the message.
|
||||
item: The ChatMessage to set at the specified index.
|
||||
"""
|
||||
self._messages[index] = item
|
||||
|
||||
def append(self, item: ChatMessage) -> None:
|
||||
"""Append a message to the end of the store.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to append.
|
||||
"""
|
||||
self._messages.append(item)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all messages from the store."""
|
||||
self._messages.clear()
|
||||
|
||||
def index(self, item: ChatMessage) -> int:
|
||||
"""Return the index of the first occurrence of the specified message.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to find.
|
||||
|
||||
Returns:
|
||||
The index of the first occurrence of the message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message is not found in the store.
|
||||
"""
|
||||
return self._messages.index(item)
|
||||
|
||||
def insert(self, index: int, item: ChatMessage) -> None:
|
||||
"""Insert a message at the specified index.
|
||||
|
||||
Args:
|
||||
index: The index at which to insert the message.
|
||||
item: The ChatMessage to insert.
|
||||
"""
|
||||
self._messages.insert(index, item)
|
||||
|
||||
def remove(self, item: ChatMessage) -> None:
|
||||
"""Remove the first occurrence of the specified message from the store.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message is not found in the store.
|
||||
"""
|
||||
self._messages.remove(item)
|
||||
|
||||
def pop(self, index: int = -1) -> ChatMessage:
|
||||
"""Remove and return a message at the specified index.
|
||||
|
||||
Args:
|
||||
index: The index of the message to remove and return. Defaults to -1 (last item).
|
||||
|
||||
Returns:
|
||||
The ChatMessage that was removed.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of range.
|
||||
"""
|
||||
return self._messages.pop(index)
|
||||
@@ -18,8 +18,9 @@ from ._pydantic import AFBaseSettings
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage]
|
||||
|
||||
from ._agents import AgentThread, AIAgent, ChatClientAgent
|
||||
from ._agents import AIAgent, ChatClientAgent
|
||||
from ._clients import ChatClientBase
|
||||
from ._threads import AgentThread
|
||||
from ._tools import AIFunction
|
||||
from ._types import (
|
||||
AgentRunResponse,
|
||||
@@ -650,8 +651,8 @@ def _get_agent_run_span(
|
||||
span.set_attribute(GenAIAttributes.AGENT_NAME.value, agent.name)
|
||||
if agent.description:
|
||||
span.set_attribute(GenAIAttributes.AGENT_DESCRIPTION.value, agent.description)
|
||||
if thread and thread.id:
|
||||
span.set_attribute(GenAIAttributes.CONVERSATION_ID.value, thread.id)
|
||||
if thread and thread.service_thread_id:
|
||||
span.set_attribute(GenAIAttributes.CONVERSATION_ID.value, thread.service_thread_id)
|
||||
if "model" in kwargs:
|
||||
span.set_attribute(GenAIAttributes.MODEL.value, kwargs["model"])
|
||||
if "seed" in kwargs:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable, MutableSequence, Sequence
|
||||
from collections.abc import AsyncIterable, MutableSequence
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -13,10 +13,9 @@ from agent_framework import (
|
||||
AIAgent,
|
||||
ChatClient,
|
||||
ChatClientAgent,
|
||||
ChatClientAgentThread,
|
||||
ChatClientAgentThreadType,
|
||||
ChatClientBase,
|
||||
ChatMessage,
|
||||
ChatMessageList,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
@@ -28,8 +27,7 @@ from agent_framework.exceptions import AgentExecutionException
|
||||
|
||||
# Mock AgentThread implementation for testing
|
||||
class MockAgentThread(AgentThread):
|
||||
async def _on_new_messages(self, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
# Mock Agent implementation for testing
|
||||
@@ -142,58 +140,6 @@ async def test_agent_run_streaming(agent: AIAgent) -> None:
|
||||
assert updates[0].text == "Response"
|
||||
|
||||
|
||||
async def test_chat_client_agent_thread_init_in_memory() -> None:
|
||||
messages = [ChatMessage(role=ChatRole.USER, contents=[TextContent("Hello")])]
|
||||
thread = ChatClientAgentThread(messages=messages)
|
||||
|
||||
assert thread.storage_location == ChatClientAgentThreadType.IN_MEMORY_MESSAGES
|
||||
assert thread.id is None
|
||||
assert thread.chat_messages == messages
|
||||
|
||||
|
||||
async def test_chat_client_agent_thread_empty() -> None:
|
||||
thread = ChatClientAgentThread()
|
||||
|
||||
assert thread.storage_location is None
|
||||
assert thread.id is None
|
||||
assert thread.chat_messages is None
|
||||
|
||||
|
||||
async def test_chat_client_agent_thread_init_invalid() -> None:
|
||||
with raises(ValueError, match="Cannot specify both id and messages"):
|
||||
ChatClientAgentThread(id="123", messages=[ChatMessage(role=ChatRole.USER, contents=[TextContent("Hello")])])
|
||||
|
||||
with raises(ValueError, match="ID cannot be empty or whitespace"):
|
||||
ChatClientAgentThread(id=" ")
|
||||
|
||||
|
||||
async def test_chat_client_agent_thread_init_conversation_id() -> None:
|
||||
thread_id = str(uuid4())
|
||||
thread = ChatClientAgentThread(id=thread_id)
|
||||
|
||||
assert thread.storage_location == ChatClientAgentThreadType.CONVERSATION_ID
|
||||
assert thread.id == thread_id
|
||||
assert thread.chat_messages is None
|
||||
|
||||
|
||||
async def test_chat_client_agent_thread_get_messages() -> None:
|
||||
messages = [ChatMessage(role=ChatRole.USER, contents=[TextContent("Hello")])]
|
||||
thread = ChatClientAgentThread(messages=messages)
|
||||
|
||||
result = [msg async for msg in thread.get_messages()]
|
||||
assert result == messages
|
||||
|
||||
|
||||
async def test_chat_client_agent_thread_on_new_messages_in_memory() -> None:
|
||||
initial_message = ChatMessage(role=ChatRole.USER, contents=[TextContent("Initial message")])
|
||||
new_message = ChatMessage(role=ChatRole.USER, contents=[TextContent("New message")])
|
||||
|
||||
thread = ChatClientAgentThread(messages=[initial_message])
|
||||
|
||||
await thread._on_new_messages(new_message) # type: ignore[reportPrivateUsage]
|
||||
assert thread.chat_messages == [initial_message, new_message]
|
||||
|
||||
|
||||
def test_chat_client_agent_type(chat_client: ChatClient) -> None:
|
||||
chat_client_agent = ChatClientAgent(chat_client=chat_client)
|
||||
assert isinstance(chat_client_agent, AIAgent)
|
||||
@@ -239,24 +185,16 @@ async def test_chat_client_agent_get_new_thread(chat_client: ChatClient) -> None
|
||||
agent = ChatClientAgent(chat_client=chat_client)
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
assert thread.storage_location is None
|
||||
assert isinstance(thread, AgentThread)
|
||||
|
||||
|
||||
async def test_chat_client_agent_prepare_thread_and_messages(chat_client: ChatClient) -> None:
|
||||
agent = ChatClientAgent(chat_client=chat_client)
|
||||
message = ChatMessage(role=ChatRole.USER, text="Hello")
|
||||
thread = ChatClientAgentThread(messages=[message])
|
||||
|
||||
result_thread = agent._validate_or_create_thread_type( # type: ignore[reportPrivateUsage]
|
||||
thread, lambda: ChatClientAgentThread(), expected_type=ChatClientAgentThread
|
||||
) # type: ignore[reportPrivateUsage]
|
||||
|
||||
assert result_thread == thread
|
||||
assert isinstance(result_thread, ChatClientAgentThread)
|
||||
thread = AgentThread(message_store=ChatMessageList(messages=[message]))
|
||||
|
||||
_, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=result_thread,
|
||||
thread=thread,
|
||||
input_messages=[ChatMessage(role=ChatRole.USER, text="Test")],
|
||||
)
|
||||
|
||||
@@ -265,18 +203,6 @@ async def test_chat_client_agent_prepare_thread_and_messages(chat_client: ChatCl
|
||||
assert result_messages[1].text == "Test"
|
||||
|
||||
|
||||
async def test_chat_client_agent_validate_or_create_thread(chat_client: ChatClient) -> None:
|
||||
agent = ChatClientAgent(chat_client=chat_client)
|
||||
thread = None
|
||||
|
||||
result_thread = agent._validate_or_create_thread_type( # type: ignore[reportPrivateUsage]
|
||||
thread, lambda: ChatClientAgentThread(), expected_type=ChatClientAgentThread
|
||||
) # type: ignore[reportPrivateUsage]
|
||||
|
||||
assert result_thread != thread
|
||||
assert isinstance(result_thread, ChatClientAgentThread)
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_thread_id() -> None:
|
||||
chat_client = MockChatClient(
|
||||
mock_response=ChatResponse(
|
||||
@@ -290,9 +216,7 @@ async def test_chat_client_agent_update_thread_id() -> None:
|
||||
result = await agent.run("Hello", thread=thread)
|
||||
assert result.text == "test response"
|
||||
|
||||
assert thread.id == "123"
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
assert thread.storage_location == ChatClientAgentThreadType.CONVERSATION_ID
|
||||
assert thread.service_thread_id == "123"
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_thread_messages(chat_client: ChatClient) -> None:
|
||||
@@ -302,19 +226,19 @@ async def test_chat_client_agent_update_thread_messages(chat_client: ChatClient)
|
||||
result = await agent.run("Hello", thread=thread)
|
||||
assert result.text == "test response"
|
||||
|
||||
assert thread.id is None
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
assert thread.storage_location == ChatClientAgentThreadType.IN_MEMORY_MESSAGES
|
||||
assert thread.service_thread_id is None
|
||||
|
||||
assert thread.chat_messages is not None
|
||||
assert len(thread.chat_messages) == 2
|
||||
assert thread.chat_messages[0].text == "Hello"
|
||||
assert thread.chat_messages[1].text == "test response"
|
||||
chat_messages: list[ChatMessage] | None = await thread.list_messages()
|
||||
|
||||
assert chat_messages is not None
|
||||
assert len(chat_messages) == 2
|
||||
assert chat_messages[0].text == "Hello"
|
||||
assert chat_messages[1].text == "test response"
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_thread_conversation_id_missing(chat_client: ChatClient) -> None:
|
||||
agent = ChatClientAgent(chat_client=chat_client)
|
||||
thread = ChatClientAgentThread(id="123")
|
||||
thread = AgentThread(service_thread_id="123")
|
||||
|
||||
with raises(AgentExecutionException, match="Service did not return a valid conversation id"):
|
||||
agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import AgentThread, ChatMessage, ChatMessageList, ChatRole
|
||||
from agent_framework._threads import StoreState, ThreadState, deserialize_thread_state, thread_on_new_messages
|
||||
|
||||
|
||||
class MockChatMessageStore:
|
||||
"""Mock implementation of ChatMessageStore for testing."""
|
||||
|
||||
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
|
||||
self._messages = messages or []
|
||||
self._serialize_calls = 0
|
||||
self._deserialize_calls = 0
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
return self._messages
|
||||
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
self._serialize_calls += 1
|
||||
return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs}
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
self._deserialize_calls += 1
|
||||
if serialized_store_state and "messages" in serialized_store_state:
|
||||
self._messages = serialized_store_state["messages"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_messages() -> list[ChatMessage]:
|
||||
"""Fixture providing sample chat messages for testing."""
|
||||
return [
|
||||
ChatMessage(role=ChatRole.USER, text="Hello", message_id="msg1"),
|
||||
ChatMessage(role=ChatRole.ASSISTANT, text="Hi there!", message_id="msg2"),
|
||||
ChatMessage(role=ChatRole.USER, text="How are you?", message_id="msg3"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message() -> ChatMessage:
|
||||
"""Fixture providing a single sample chat message for testing."""
|
||||
return ChatMessage(role=ChatRole.USER, text="Test message", message_id="test1")
|
||||
|
||||
|
||||
class TestAgentThread:
|
||||
"""Test cases for AgentThread class."""
|
||||
|
||||
def test_init_with_no_parameters(self) -> None:
|
||||
"""Test AgentThread initialization with no parameters."""
|
||||
thread = AgentThread()
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is None
|
||||
|
||||
def test_init_with_service_thread_id(self) -> None:
|
||||
"""Test AgentThread initialization with service_thread_id."""
|
||||
service_thread_id = "test-conversation-123"
|
||||
thread = AgentThread(service_thread_id=service_thread_id)
|
||||
assert thread.service_thread_id == service_thread_id
|
||||
assert thread.message_store is None
|
||||
|
||||
def test_init_with_message_store(self) -> None:
|
||||
"""Test AgentThread initialization with message_store."""
|
||||
store = ChatMessageList()
|
||||
thread = AgentThread(message_store=store)
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is store
|
||||
|
||||
def test_service_thread_id_property_setter(self) -> None:
|
||||
"""Test service_thread_id property setter."""
|
||||
thread = AgentThread()
|
||||
service_thread_id = "test-conversation-456"
|
||||
|
||||
thread.service_thread_id = service_thread_id
|
||||
assert thread.service_thread_id == service_thread_id
|
||||
|
||||
def test_service_thread_id_setter_with_existing_message_store_raises_error(self) -> None:
|
||||
"""Test that setting service_thread_id when message_store exists raises ValueError."""
|
||||
store = ChatMessageList()
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
with pytest.raises(ValueError, match="Only the service_thread_id or message_store may be set"):
|
||||
thread.service_thread_id = "test-conversation-789"
|
||||
|
||||
def test_service_thread_id_setter_with_none_values(self) -> None:
|
||||
"""Test service_thread_id setter with None values does nothing."""
|
||||
thread = AgentThread()
|
||||
thread.service_thread_id = None # Should not raise error
|
||||
assert thread.service_thread_id is None
|
||||
|
||||
def test_message_store_property_setter(self) -> None:
|
||||
"""Test message_store property setter."""
|
||||
thread = AgentThread()
|
||||
store = ChatMessageList()
|
||||
|
||||
thread.message_store = store
|
||||
assert thread.message_store is store
|
||||
|
||||
def test_message_store_setter_with_existing_service_thread_id_raises_error(self) -> None:
|
||||
"""Test that setting message_store when service_thread_id exists raises ValueError."""
|
||||
service_thread_id = "test-conversation-999"
|
||||
thread = AgentThread(service_thread_id=service_thread_id)
|
||||
store = ChatMessageList()
|
||||
|
||||
with pytest.raises(ValueError, match="Only the service_thread_id or message_store may be set"):
|
||||
thread.message_store = store
|
||||
|
||||
def test_message_store_setter_with_none_values(self) -> None:
|
||||
"""Test message_store setter with None values does nothing."""
|
||||
thread = AgentThread()
|
||||
thread.message_store = None # Should not raise error
|
||||
assert thread.message_store is None
|
||||
|
||||
async def test_get_messages_with_message_store(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test get_messages when message_store is set."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
messages: list[ChatMessage] | None = await thread.list_messages()
|
||||
|
||||
assert messages is not None
|
||||
assert len(messages) == 3
|
||||
assert messages[0].text == "Hello"
|
||||
assert messages[1].text == "Hi there!"
|
||||
assert messages[2].text == "How are you?"
|
||||
|
||||
async def test_get_messages_with_no_message_store(self) -> None:
|
||||
"""Test get_messages when no message_store is set."""
|
||||
thread = AgentThread()
|
||||
|
||||
messages: list[ChatMessage] | None = await thread.list_messages()
|
||||
|
||||
assert messages is None
|
||||
|
||||
async def test_on_new_messages_with_service_thread_id(self, sample_message: ChatMessage) -> None:
|
||||
"""Test _on_new_messages when service_thread_id is set (should do nothing)."""
|
||||
thread = AgentThread(service_thread_id="test-conv")
|
||||
|
||||
await thread_on_new_messages(thread, sample_message)
|
||||
|
||||
# Should not create a message store
|
||||
assert thread.message_store is None
|
||||
|
||||
async def test_on_new_messages_single_message_creates_store(self, sample_message: ChatMessage) -> None:
|
||||
"""Test _on_new_messages with single message creates ChatMessageList."""
|
||||
thread = AgentThread()
|
||||
|
||||
await thread_on_new_messages(thread, sample_message)
|
||||
|
||||
assert thread.message_store is not None
|
||||
assert isinstance(thread.message_store, ChatMessageList)
|
||||
messages = await thread.message_store.list_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].text == "Test message"
|
||||
|
||||
async def test_on_new_messages_multiple_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test _on_new_messages with multiple messages."""
|
||||
thread = AgentThread()
|
||||
|
||||
await thread_on_new_messages(thread, sample_messages)
|
||||
|
||||
assert thread.message_store is not None
|
||||
messages = await thread.message_store.list_messages()
|
||||
assert len(messages) == 3
|
||||
|
||||
async def test_on_new_messages_with_existing_store(self, sample_message: ChatMessage) -> None:
|
||||
"""Test _on_new_messages adds to existing message store."""
|
||||
initial_messages = [ChatMessage(role=ChatRole.USER, text="Initial", message_id="init1")]
|
||||
store = ChatMessageList(initial_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
await thread_on_new_messages(thread, sample_message)
|
||||
|
||||
assert thread.message_store is not None
|
||||
messages = await thread.message_store.list_messages()
|
||||
assert len(messages) == 2
|
||||
assert messages[0].text == "Initial"
|
||||
assert messages[1].text == "Test message"
|
||||
|
||||
async def test_deserialize_with_service_thread_id(self) -> None:
|
||||
"""Test _deserialize with service_thread_id."""
|
||||
thread = AgentThread()
|
||||
serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
|
||||
assert thread.service_thread_id == "test-conv-123"
|
||||
assert thread.message_store is None
|
||||
|
||||
async def test_deserialize_with_store_state(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test _deserialize with chat_message_store_state."""
|
||||
thread = AgentThread()
|
||||
store_state = {"messages": sample_messages}
|
||||
serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is not None
|
||||
assert isinstance(thread.message_store, ChatMessageList)
|
||||
|
||||
async def test_deserialize_with_no_state(self) -> None:
|
||||
"""Test _deserialize with no state."""
|
||||
thread = AgentThread()
|
||||
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is None
|
||||
|
||||
async def test_deserialize_with_existing_store(self) -> None:
|
||||
"""Test _deserialize with existing message store."""
|
||||
store = MockChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
serialized_data: dict[str, Any] = {"service_thread_id": None, "chat_message_store_state": {"messages": []}}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
|
||||
assert store._deserialize_calls == 1 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
async def test_serialize_with_service_thread_id(self) -> None:
|
||||
"""Test serialize with service_thread_id."""
|
||||
thread = AgentThread(service_thread_id="test-conv-456")
|
||||
|
||||
result = await thread.serialize()
|
||||
|
||||
assert result["service_thread_id"] == "test-conv-456"
|
||||
assert result["chat_message_store_state"] is None
|
||||
|
||||
async def test_serialize_with_message_store(self) -> None:
|
||||
"""Test serialize with message_store."""
|
||||
store = MockChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
result = await thread.serialize()
|
||||
|
||||
assert result["service_thread_id"] is None
|
||||
assert result["chat_message_store_state"] is not None
|
||||
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
async def test_serialize_with_no_state(self) -> None:
|
||||
"""Test serialize with no state."""
|
||||
thread = AgentThread()
|
||||
|
||||
result = await thread.serialize()
|
||||
|
||||
assert result["service_thread_id"] is None
|
||||
assert result["chat_message_store_state"] is None
|
||||
|
||||
async def test_serialize_with_kwargs(self) -> None:
|
||||
"""Test serialize passes kwargs to message store."""
|
||||
store = MockChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
await thread.serialize(custom_param="test_value")
|
||||
|
||||
assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
class TestChatMessageList:
|
||||
"""Test cases for ChatMessageList class."""
|
||||
|
||||
def test_init_empty(self) -> None:
|
||||
"""Test ChatMessageList initialization with no messages."""
|
||||
store = ChatMessageList()
|
||||
assert len(store) == 0
|
||||
|
||||
def test_init_with_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test ChatMessageList initialization with messages."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
assert len(store) == 3
|
||||
|
||||
async def test_add_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test adding messages to the store."""
|
||||
store = ChatMessageList()
|
||||
|
||||
await store.add_messages(sample_messages)
|
||||
|
||||
assert len(store) == 3
|
||||
messages = await store.list_messages()
|
||||
assert messages[0].text == "Hello"
|
||||
|
||||
async def test_get_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test getting messages from the store."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
messages = await store.list_messages()
|
||||
|
||||
assert len(messages) == 3
|
||||
assert messages[0].message_id == "msg1"
|
||||
|
||||
async def test_serialize_state(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test serializing store state."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
result = await store.serialize_state()
|
||||
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 3
|
||||
|
||||
async def test_serialize_state_empty(self) -> None:
|
||||
"""Test serializing empty store state."""
|
||||
store = ChatMessageList()
|
||||
|
||||
result = await store.serialize_state()
|
||||
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 0
|
||||
|
||||
async def test_deserialize_state(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test deserializing store state."""
|
||||
store = ChatMessageList()
|
||||
state_data = {"messages": sample_messages}
|
||||
|
||||
await store.deserialize_state(state_data)
|
||||
|
||||
messages = await store.list_messages()
|
||||
assert len(messages) == 3
|
||||
assert messages[0].text == "Hello"
|
||||
|
||||
async def test_deserialize_state_none(self) -> None:
|
||||
"""Test deserializing None state."""
|
||||
store = ChatMessageList()
|
||||
|
||||
await store.deserialize_state(None)
|
||||
|
||||
assert len(store) == 0
|
||||
|
||||
async def test_deserialize_state_empty(self) -> None:
|
||||
"""Test deserializing empty state."""
|
||||
store = ChatMessageList()
|
||||
|
||||
await store.deserialize_state({})
|
||||
|
||||
assert len(store) == 0
|
||||
|
||||
def test_len(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test __len__ method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
assert len(store) == 3
|
||||
|
||||
empty_store = ChatMessageList()
|
||||
assert len(empty_store) == 0
|
||||
|
||||
def test_getitem(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test __getitem__ method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
assert store[0].text == "Hello"
|
||||
assert store[1].text == "Hi there!"
|
||||
assert store[2].text == "How are you?"
|
||||
|
||||
def test_setitem(self, sample_messages: list[ChatMessage], sample_message: ChatMessage) -> None:
|
||||
"""Test __setitem__ method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
store[1] = sample_message
|
||||
assert store[1].text == "Test message"
|
||||
assert store[1].message_id == "test1"
|
||||
|
||||
def test_append(self, sample_message: ChatMessage) -> None:
|
||||
"""Test append method."""
|
||||
store = ChatMessageList()
|
||||
|
||||
store.append(sample_message)
|
||||
|
||||
assert len(store) == 1
|
||||
assert store[0].text == "Test message"
|
||||
|
||||
def test_clear(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test clear method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
assert len(store) == 3
|
||||
|
||||
store.clear()
|
||||
assert len(store) == 0
|
||||
|
||||
def test_index(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test index method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
index = store.index(sample_messages[1])
|
||||
assert index == 1
|
||||
|
||||
def test_insert(self, sample_messages: list[ChatMessage], sample_message: ChatMessage) -> None:
|
||||
"""Test insert method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
store.insert(1, sample_message)
|
||||
|
||||
assert len(store) == 4
|
||||
assert store[1].text == "Test message"
|
||||
assert store[2].text == "Hi there!" # Original message at index 1 is now at index 2
|
||||
|
||||
def test_remove(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test remove method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
message_to_remove = sample_messages[1]
|
||||
|
||||
store.remove(message_to_remove)
|
||||
|
||||
assert len(store) == 2
|
||||
assert store[0].text == "Hello"
|
||||
assert store[1].text == "How are you?"
|
||||
|
||||
def test_pop_default(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test pop method with default index."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
popped_message = store.pop()
|
||||
|
||||
assert len(store) == 2
|
||||
assert popped_message.text == "How are you?" # Last message
|
||||
|
||||
def test_pop_with_index(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test pop method with specific index."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
popped_message = store.pop(1)
|
||||
|
||||
assert len(store) == 2
|
||||
assert popped_message.text == "Hi there!"
|
||||
assert store[0].text == "Hello"
|
||||
assert store[1].text == "How are you?"
|
||||
|
||||
|
||||
class TestStoreState:
|
||||
"""Test cases for StoreState class."""
|
||||
|
||||
def test_init(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test StoreState initialization."""
|
||||
state = StoreState(messages=sample_messages)
|
||||
|
||||
assert len(state.messages) == 3
|
||||
assert state.messages[0].text == "Hello"
|
||||
|
||||
def test_init_empty(self) -> None:
|
||||
"""Test StoreState initialization with empty messages."""
|
||||
state = StoreState(messages=[])
|
||||
|
||||
assert len(state.messages) == 0
|
||||
|
||||
|
||||
class TestThreadState:
|
||||
"""Test cases for ThreadState class."""
|
||||
|
||||
def test_init_with_service_thread_id(self) -> None:
|
||||
"""Test ThreadState initialization with service_thread_id."""
|
||||
state = ThreadState(service_thread_id="test-conv-123")
|
||||
|
||||
assert state.service_thread_id == "test-conv-123"
|
||||
assert state.chat_message_store_state is None
|
||||
|
||||
def test_init_with_chat_message_store_state(self) -> None:
|
||||
"""Test ThreadState initialization with chat_message_store_state."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
state = ThreadState(chat_message_store_state=store_data)
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state == store_data
|
||||
|
||||
def test_init_with_both(self) -> None:
|
||||
"""Test ThreadState initialization with both parameters."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
state = ThreadState(service_thread_id="test-conv-456", chat_message_store_state=store_data)
|
||||
|
||||
assert state.service_thread_id == "test-conv-456"
|
||||
assert state.chat_message_store_state == store_data
|
||||
|
||||
def test_init_defaults(self) -> None:
|
||||
"""Test ThreadState initialization with defaults."""
|
||||
state = ThreadState()
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state is None
|
||||
+3
-3
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent
|
||||
from agent_framework.azure import AzureAssistantsClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from pydantic import Field
|
||||
@@ -95,7 +95,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.id
|
||||
existing_thread_id = thread.service_thread_id
|
||||
print(f"Thread ID: {existing_thread_id}")
|
||||
|
||||
if existing_thread_id:
|
||||
@@ -108,7 +108,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
tools=get_weather,
|
||||
) as agent:
|
||||
# Create a thread with the existing ID
|
||||
thread = ChatClientAgentThread(id=existing_thread_id)
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
|
||||
query2 = "What was the last city I asked about?"
|
||||
print(f"User: {query2}")
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent, ChatMessageList
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from pydantic import Field
|
||||
@@ -88,7 +88,6 @@ async def example_with_existing_thread_messages() -> None:
|
||||
|
||||
# Start a conversation and build up message history
|
||||
thread = agent.get_new_thread()
|
||||
assert isinstance(thread, ChatClientAgentThread) # Ensure we have the right type
|
||||
|
||||
query1 = "What's the weather in Paris?"
|
||||
print(f"User: {query1}")
|
||||
@@ -96,7 +95,9 @@ async def example_with_existing_thread_messages() -> None:
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# The thread now contains the conversation history in memory
|
||||
message_count = len(thread.chat_messages or [])
|
||||
messages = await thread.list_messages()
|
||||
|
||||
message_count = len(messages or [])
|
||||
print(f"Thread contains {message_count} messages")
|
||||
|
||||
print("\n--- Continuing with the same thread in a new agent instance ---")
|
||||
@@ -118,8 +119,9 @@ async def example_with_existing_thread_messages() -> None:
|
||||
print("\n--- Alternative: Creating a new thread from existing messages ---")
|
||||
|
||||
# You can also create a new thread from existing messages
|
||||
existing_messages = thread.chat_messages or []
|
||||
new_thread = ChatClientAgentThread(messages=existing_messages)
|
||||
messages = await thread.list_messages()
|
||||
|
||||
new_thread = AgentThread(message_store=ChatMessageList(messages))
|
||||
|
||||
query3 = "How does the Paris weather compare to London?"
|
||||
print(f"User: {query3}")
|
||||
|
||||
+8
-10
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent
|
||||
from agent_framework.azure import AzureResponsesClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from pydantic import Field
|
||||
@@ -57,28 +57,27 @@ async def example_with_thread_persistence_in_memory() -> None:
|
||||
|
||||
# Create a new thread that will be reused
|
||||
thread = agent.get_new_thread()
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
|
||||
# First conversation
|
||||
query1 = "What's the weather like in Tokyo?"
|
||||
print(f"User: {query1}")
|
||||
result1 = await agent.run(query1, thread=thread)
|
||||
print(f"Agent: {result1.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
|
||||
# Second conversation using the same thread - maintains context
|
||||
query2 = "How about London?"
|
||||
print(f"\nUser: {query2}")
|
||||
result2 = await agent.run(query2, thread=thread)
|
||||
print(f"Agent: {result2.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
|
||||
# Third conversation - agent should remember both previous cities
|
||||
query3 = "Which of the cities I asked about has better weather?"
|
||||
print(f"\nUser: {query3}")
|
||||
result3 = await agent.run(query3, thread=thread)
|
||||
print(f"Agent: {result3.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
print("Note: The agent remembers context from previous messages in the same thread.\n")
|
||||
|
||||
|
||||
@@ -100,17 +99,16 @@ async def example_with_existing_thread_id() -> None:
|
||||
|
||||
# Start a conversation and get the thread ID
|
||||
thread = agent.get_new_thread()
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
|
||||
query1 = "What's the weather in Paris?"
|
||||
print(f"User: {query1}")
|
||||
# Enable Azure OpenAI conversation state by setting `store` parameter to True
|
||||
result1 = await agent.run(query1, thread=thread, store=True)
|
||||
print(f"Agent: {result1.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.id
|
||||
existing_thread_id = thread.service_thread_id
|
||||
print(f"Thread ID: {existing_thread_id}")
|
||||
|
||||
if existing_thread_id:
|
||||
@@ -123,13 +121,13 @@ async def example_with_existing_thread_id() -> None:
|
||||
)
|
||||
|
||||
# Create a thread with the existing ID
|
||||
thread = ChatClientAgentThread(id=existing_thread_id)
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
|
||||
query2 = "What was the last city I asked about?"
|
||||
print(f"User: {query2}")
|
||||
result2 = await agent.run(query2, thread=thread, store=True)
|
||||
print(f"Agent: {result2.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
print("Note: The agent continues the conversation from the previous thread by using thread ID.\n")
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from pydantic import Field
|
||||
@@ -104,7 +104,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.id
|
||||
existing_thread_id = thread.service_thread_id
|
||||
print(f"Thread ID: {existing_thread_id}")
|
||||
|
||||
if existing_thread_id:
|
||||
@@ -120,7 +120,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
) as agent,
|
||||
):
|
||||
# Create a thread with the existing ID
|
||||
thread = ChatClientAgentThread(id=existing_thread_id)
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
|
||||
query2 = "What was the last city I asked about?"
|
||||
print(f"User: {query2}")
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent
|
||||
from agent_framework.openai import OpenAIAssistantsClient
|
||||
from pydantic import Field
|
||||
|
||||
@@ -94,7 +94,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.id
|
||||
existing_thread_id = thread.service_thread_id
|
||||
print(f"Thread ID: {existing_thread_id}")
|
||||
|
||||
if existing_thread_id:
|
||||
@@ -107,7 +107,7 @@ async def example_with_existing_thread_id() -> None:
|
||||
tools=get_weather,
|
||||
) as agent:
|
||||
# Create a thread with the existing ID
|
||||
thread = ChatClientAgentThread(id=existing_thread_id)
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
|
||||
query2 = "What was the last city I asked about?"
|
||||
print(f"User: {query2}")
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent, ChatMessageList
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from pydantic import Field
|
||||
|
||||
@@ -87,7 +87,6 @@ async def example_with_existing_thread_messages() -> None:
|
||||
|
||||
# Start a conversation and build up message history
|
||||
thread = agent.get_new_thread()
|
||||
assert isinstance(thread, ChatClientAgentThread) # Ensure we have the right type
|
||||
|
||||
query1 = "What's the weather in Paris?"
|
||||
print(f"User: {query1}")
|
||||
@@ -95,7 +94,9 @@ async def example_with_existing_thread_messages() -> None:
|
||||
print(f"Agent: {result1.text}")
|
||||
|
||||
# The thread now contains the conversation history in memory
|
||||
message_count = len(thread.chat_messages or [])
|
||||
messages = await thread.list_messages()
|
||||
|
||||
message_count = len(messages or [])
|
||||
print(f"Thread contains {message_count} messages")
|
||||
|
||||
print("\n--- Continuing with the same thread in a new agent instance ---")
|
||||
@@ -117,8 +118,9 @@ async def example_with_existing_thread_messages() -> None:
|
||||
print("\n--- Alternative: Creating a new thread from existing messages ---")
|
||||
|
||||
# You can also create a new thread from existing messages
|
||||
existing_messages = thread.chat_messages or []
|
||||
new_thread = ChatClientAgentThread(messages=existing_messages)
|
||||
messages = await thread.list_messages()
|
||||
|
||||
new_thread = AgentThread(message_store=ChatMessageList(messages))
|
||||
|
||||
query3 = "How does the Paris weather compare to London?"
|
||||
print(f"User: {query3}")
|
||||
|
||||
+10
-10
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatClientAgent, ChatClientAgentThread
|
||||
from agent_framework import AgentThread, ChatClientAgent
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from pydantic import Field
|
||||
|
||||
@@ -56,28 +56,29 @@ async def example_with_thread_persistence_in_memory() -> None:
|
||||
|
||||
# Create a new thread that will be reused
|
||||
thread = agent.get_new_thread()
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
|
||||
# First conversation
|
||||
query1 = "What's the weather like in Tokyo?"
|
||||
print(f"User: {query1}")
|
||||
result1 = await agent.run(query1, thread=thread)
|
||||
print(f"Agent: {result1.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
|
||||
# Second conversation using the same thread - maintains context
|
||||
query2 = "How about London?"
|
||||
print(f"\nUser: {query2}")
|
||||
result2 = await agent.run(query2, thread=thread)
|
||||
print(f"Agent: {result2.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
|
||||
# Third conversation - agent should remember both previous cities
|
||||
query3 = "Which of the cities I asked about has better weather?"
|
||||
print(f"\nUser: {query3}")
|
||||
result3 = await agent.run(query3, thread=thread)
|
||||
print(f"Agent: {result3.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
print("Note: The agent remembers context from previous messages in the same thread.\n")
|
||||
|
||||
|
||||
@@ -99,17 +100,16 @@ async def example_with_existing_thread_id() -> None:
|
||||
|
||||
# Start a conversation and get the thread ID
|
||||
thread = agent.get_new_thread()
|
||||
assert isinstance(thread, ChatClientAgentThread)
|
||||
|
||||
query1 = "What's the weather in Paris?"
|
||||
print(f"User: {query1}")
|
||||
# Enable OpenAI conversation state by setting `store` parameter to True
|
||||
result1 = await agent.run(query1, thread=thread, store=True)
|
||||
print(f"Agent: {result1.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.id
|
||||
existing_thread_id = thread.service_thread_id
|
||||
print(f"Thread ID: {existing_thread_id}")
|
||||
|
||||
if existing_thread_id:
|
||||
@@ -122,13 +122,13 @@ async def example_with_existing_thread_id() -> None:
|
||||
)
|
||||
|
||||
# Create a thread with the existing ID
|
||||
thread = ChatClientAgentThread(id=existing_thread_id)
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
|
||||
query2 = "What was the last city I asked about?"
|
||||
print(f"User: {query2}")
|
||||
result2 = await agent.run(query2, thread=thread, store=True)
|
||||
print(f"Agent: {result2.text}")
|
||||
print(f"Thread contains {len(thread.chat_messages or [])} messages in-memory.")
|
||||
print(f"Thread contains {len(await thread.list_messages() or [])} messages in-memory.")
|
||||
print("Note: The agent continues the conversation from the previous thread by using thread ID.\n")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, ChatMessageStore
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CustomStoreState(BaseModel):
|
||||
"""Implementation of custom chat message store state."""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class CustomChatMessageStore(ChatMessageStore):
|
||||
"""Implementation of custom chat message store.
|
||||
In real applications, this can be an implementation of relational database or vector store."""
|
||||
|
||||
def __init__(self, messages: Collection[ChatMessage] | None = None) -> None:
|
||||
self._messages: list[ChatMessage] = []
|
||||
if messages:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def add_messages(self, messages: Collection[ChatMessage]) -> None:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
return self._messages
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
if serialized_store_state:
|
||||
state = CustomStoreState.model_validate(serialized_store_state, **kwargs)
|
||||
if state.messages:
|
||||
self._messages.extend(state.messages)
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
state = CustomStoreState(messages=self._messages)
|
||||
return state.model_dump(**kwargs)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Demonstrates how to use 3rd party or custom chat message store for threads."""
|
||||
print("=== Thread with 3rd party or custom chat message store ===")
|
||||
|
||||
# OpenAI Chat Client is used as an example here,
|
||||
# other chat clients can be used as well.
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="Joker",
|
||||
instructions="You are good at telling jokes.",
|
||||
# Use custom chat message store.
|
||||
# If not provided, the default in-memory store will be used.
|
||||
chat_message_store_factory=CustomChatMessageStore,
|
||||
)
|
||||
|
||||
# Start a new thread for the agent conversation.
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Respond to user input.
|
||||
query = "Tell me a joke about a pirate."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
|
||||
# Serialize the thread state, so it can be stored for later use.
|
||||
serialized_thread = await thread.serialize()
|
||||
|
||||
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
|
||||
print(f"Serialized thread: {serialized_thread}\n")
|
||||
|
||||
# Deserialize the thread state after loading from storage.
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Respond to user input.
|
||||
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
|
||||
async def suspend_resume_service_managed_thread() -> None:
|
||||
"""Demonstrates how to suspend and resume a service-managed thread."""
|
||||
print("=== Suspend-Resume Service-Managed Thread ===")
|
||||
|
||||
# Foundry Chat Client is used as an example here,
|
||||
# other chat clients can be used as well.
|
||||
async with (
|
||||
DefaultAzureCredential() as credential,
|
||||
FoundryChatClient(async_ad_credential=credential).create_agent(
|
||||
name="Joker", instructions="You are good at telling jokes."
|
||||
) as agent,
|
||||
):
|
||||
# Start a new thread for the agent conversation.
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Respond to user input.
|
||||
query = "Tell me a joke about a pirate."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
|
||||
# Serialize the thread state, so it can be stored for later use.
|
||||
serialized_thread = await thread.serialize()
|
||||
|
||||
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
|
||||
print(f"Serialized thread: {serialized_thread}\n")
|
||||
|
||||
# Deserialize the thread state after loading from storage.
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Respond to user input.
|
||||
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
|
||||
|
||||
async def suspend_resume_in_memory_thread() -> None:
|
||||
"""Demonstrates how to suspend and resume an in-memory thread."""
|
||||
print("=== Suspend-Resume In-Memory Thread ===")
|
||||
|
||||
# OpenAI Chat Client is used as an example here,
|
||||
# other chat clients can be used as well.
|
||||
agent = OpenAIChatClient().create_agent(name="Joker", instructions="You are good at telling jokes.")
|
||||
|
||||
# Start a new thread for the agent conversation.
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
# Respond to user input.
|
||||
query = "Tell me a joke about a pirate."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=thread)}\n")
|
||||
|
||||
# Serialize the thread state, so it can be stored for later use.
|
||||
serialized_thread = await thread.serialize()
|
||||
|
||||
# The thread can now be saved to a database, file, or any other storage mechanism and loaded again later.
|
||||
print(f"Serialized thread: {serialized_thread}\n")
|
||||
|
||||
# Deserialize the thread state after loading from storage.
|
||||
resumed_thread = await agent.deserialize_thread(serialized_thread)
|
||||
|
||||
# Respond to user input.
|
||||
query = "Now tell the same joke in the voice of a pirate, and add some emojis to the joke."
|
||||
print(f"User: {query}")
|
||||
print(f"Agent: {await agent.run(query, thread=resumed_thread)}\n")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== Suspend-Resume Thread Examples ===")
|
||||
await suspend_resume_service_managed_thread()
|
||||
await suspend_resume_in_memory_thread()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user