mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] PR2 — Wire context provider pipeline, remove old types, update all consumers (#3850)
* PR2: Wire context provider pipeline and update all internal consumers - Replace AgentThread with AgentSession across all packages - Replace ContextProvider with BaseContextProvider across all packages - Replace context_provider param with context_providers (Sequence) - Replace thread= with session= in run() signatures - Replace get_new_thread() with create_session() - Add get_session(service_session_id) to agent interface - DurableAgentThread -> DurableAgentSession - Remove _notify_thread_of_new_messages from WorkflowAgent - Wire before_run/after_run context provider pipeline in RawAgent - Auto-inject InMemoryHistoryProvider when no providers configured * fix: update all tests for context provider pipeline, fix lazy-loaders, remove old test files * refactor: update all sample files for context provider pipeline (AgentThread→AgentSession, ContextProvider→BaseContextProvider) * fix: update remaining ag-ui references (client docstring, getting_started sample) * fix: make get_session service_session_id keyword-only to avoid confusion with session_id * refactor: rename _RunContext.thread_messages to session_messages * refactor: remove _threads.py, _memory.py, and old provider files; migrate devui to use plain message lists * rename: remove _new_ prefix from test files * refactor: rewrite SlidingWindowChatMessageStore as SlidingWindowHistoryProvider(InMemoryHistoryProvider) * fix: read full history from session state directly instead of reaching into provider internals * fix: update stale .pyi stubs, sample imports, and README references for new provider types * fix: remove stale message_store, _notify_thread_of_new_messages, and session_id.key references in samples * refactor: merge context_providers and sessions sample folders into sessions, remove aggregate_context_provider * refactor: UserInfoMemory stores state in session.state instead of instance attributes * feat: add Pydantic BaseModel support to session state serialization Pydantic models stored in session.state are now automatically serialized via model_dump() and restored via model_validate() during to_dict()/from_dict() round-trips. Models are auto-registered on first serialization; use register_state_type() for cold-start deserialization. Also export register_state_type as a public API. * fix mem0 * Update sample README links and descriptions for session terminology - Replace 'thread' with 'session' in sample descriptions across all READMEs - Update file links for renamed samples (mem0_sessions, redis_sessions, etc.) - Fix Threads section → Sessions section in main samples/README.md - Update tools, middleware, workflows, durabletask, azure_functions READMEs - Update architecture diagrams in concepts/tools/README.md - Update migration guides (autogen, semantic-kernel) * Fix broken Redis README link to renamed sample * Fix Mem0 OSS client search: pass scoping params as direct kwargs AsyncMemory (OSS) expects user_id/agent_id/run_id as direct kwargs, while AsyncMemoryClient (Platform) expects them in a filters dict. Adds tests for both client types. Port of fix from #3844 to new Mem0ContextProvider. * Fix rebase issues: restore missing _conversation_state.py and checkpoint decode logic - Add back _conversation_state.py (encode/decode_chat_messages) lost in rebase - Fix on_checkpoint_restore to decode cache/conversation with decode_chat_messages - Fix on_checkpoint_restore to use decode_checkpoint_value for pending requests - Add tests/workflow/__init__.py for relative import support - Fix test_agent_executor checkpoint selection (checkpoints[1] not superstep) * Add STORES_BY_DEFAULT ClassVar to skip redundant InMemoryHistoryProvider injection Chat clients that store history server-side by default (OpenAI Responses API, Azure AI Agent) now declare STORES_BY_DEFAULT = True. The agent checks this during auto-injection and skips InMemoryHistoryProvider unless the user explicitly sets store=False. * Fix broken markdown links in azure_ai and redis READMEs * Fix getting-started samples to use session API instead of removed thread/ContextProvider API * updates to workflow as agent * fix group chat import * Rename Thread→Session throughout, fix service_session_id propagation, remove stale AGUIThread - Fix: Propagate conversation_id from ChatResponse back to session.service_session_id in both streaming and non-streaming paths in _agents.py - Rename AgentThreadException → AgentSessionException - Remove stale AGUIThread from ag_ui lazy-loader - Rename use_service_thread → use_service_session in ag-ui package - Rename test functions from *_thread_* to *_session_* - Rename sample files from *_thread* to *_session* - Update docstrings and comments: thread → session - Update _mcp.py kwargs filter: add 'session' alongside 'thread' - Fix ContinuationToken docstring example: thread=thread → session=session - Fix _clients.py docstring: 'Agent threads' → 'Agent sessions' * Fix broken markdown links after thread→session file renames * fix azure ai test
This commit is contained in:
committed by
GitHub
Unverified
parent
0c67dbbce5
commit
1e350ea22f
@@ -12,7 +12,7 @@ from agent_framework import (
|
||||
Agent,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Message,
|
||||
@@ -433,70 +433,70 @@ async def test_azure_assistants_agent_basic_run_streaming():
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_thread_persistence():
|
||||
"""Test Agent thread persistence across runs with AzureOpenAIAssistantsClient."""
|
||||
async def test_azure_assistants_agent_session_persistence():
|
||||
"""Test Agent session persistence across runs with AzureOpenAIAssistantsClient."""
|
||||
async with Agent(
|
||||
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent:
|
||||
# Create a new thread that will be reused
|
||||
thread = agent.get_new_thread()
|
||||
# Create a new session that will be reused
|
||||
session = agent.create_session()
|
||||
|
||||
# First message - establish context
|
||||
first_response = await agent.run(
|
||||
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
|
||||
"Remember this number: 42. What number did I just tell you to remember?", session=session
|
||||
)
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert "42" in first_response.text
|
||||
|
||||
# Second message - test conversation memory
|
||||
second_response = await agent.run(
|
||||
"What number did I tell you to remember in my previous message?", thread=thread
|
||||
"What number did I tell you to remember in my previous message?", session=session
|
||||
)
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert "42" in second_response.text
|
||||
|
||||
# Verify thread has been populated with conversation ID
|
||||
assert thread.service_thread_id is not None
|
||||
# Verify session has been populated with conversation ID
|
||||
assert session.service_session_id is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_existing_thread_id():
|
||||
"""Test Agent with existing thread ID to continue conversations across agent instances."""
|
||||
# First, create a conversation and capture the thread ID
|
||||
existing_thread_id = None
|
||||
async def test_azure_assistants_agent_existing_session_id():
|
||||
"""Test Agent with existing session ID to continue conversations across agent instances."""
|
||||
# First, create a conversation and capture the session ID
|
||||
existing_session_id = None
|
||||
|
||||
async with Agent(
|
||||
client=AzureOpenAIAssistantsClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
) as agent:
|
||||
# Start a conversation and get the thread ID
|
||||
thread = agent.get_new_thread()
|
||||
response1 = await agent.run("What's the weather in Paris?", thread=thread)
|
||||
# Start a conversation and get the session ID
|
||||
session = agent.create_session()
|
||||
response1 = await agent.run("What's the weather in Paris?", session=session)
|
||||
|
||||
# Validate first response
|
||||
assert isinstance(response1, AgentResponse)
|
||||
assert response1.text is not None
|
||||
assert any(word in response1.text.lower() for word in ["weather", "paris"])
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.service_thread_id
|
||||
assert existing_thread_id is not None
|
||||
# The session ID is set after the first response
|
||||
existing_session_id = session.service_session_id
|
||||
assert existing_session_id is not None
|
||||
|
||||
# Now continue with the same thread ID in a new agent instance
|
||||
# Now continue with the same session ID in a new agent instance
|
||||
|
||||
async with Agent(
|
||||
client=AzureOpenAIAssistantsClient(thread_id=existing_thread_id, credential=AzureCliCredential()),
|
||||
client=AzureOpenAIAssistantsClient(thread_id=existing_session_id, credential=AzureCliCredential()),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
) as agent:
|
||||
# Create a thread with the existing ID
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
# Create a session with the existing ID
|
||||
session = AgentSession(service_session_id=existing_session_id)
|
||||
|
||||
# Ask about the previous conversation
|
||||
response2 = await agent.run("What was the last city I asked about?", thread=thread)
|
||||
response2 = await agent.run("What was the last city I asked about?", session=session)
|
||||
|
||||
# Validate that the agent remembers the previous conversation
|
||||
assert isinstance(response2, AgentResponse)
|
||||
|
||||
@@ -800,23 +800,23 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming():
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_agent_thread_persistence():
|
||||
"""Test Azure OpenAI chat client agent thread persistence across runs with AzureOpenAIChatClient."""
|
||||
async def test_azure_openai_chat_client_agent_session_persistence():
|
||||
"""Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient."""
|
||||
async with Agent(
|
||||
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent:
|
||||
# Create a new thread that will be reused
|
||||
thread = agent.get_new_thread()
|
||||
# Create a new session that will be reused
|
||||
session = agent.create_session()
|
||||
|
||||
# First interaction
|
||||
response1 = await agent.run("My name is Alice. Remember this.", thread=thread)
|
||||
response1 = await agent.run("My name is Alice. Remember this.", session=session)
|
||||
|
||||
assert isinstance(response1, AgentResponse)
|
||||
assert response1.text is not None
|
||||
|
||||
# Second interaction - test memory
|
||||
response2 = await agent.run("What is my name?", thread=thread)
|
||||
response2 = await agent.run("What is my name?", session=session)
|
||||
|
||||
assert isinstance(response2, AgentResponse)
|
||||
assert response2.text is not None
|
||||
@@ -825,33 +825,33 @@ async def test_azure_openai_chat_client_agent_thread_persistence():
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_agent_existing_thread():
|
||||
"""Test Azure OpenAI chat client agent with existing thread to continue conversations across agent instances."""
|
||||
# First conversation - capture the thread
|
||||
preserved_thread = None
|
||||
async def test_azure_openai_chat_client_agent_existing_session():
|
||||
"""Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances."""
|
||||
# First conversation - capture the session
|
||||
preserved_session = None
|
||||
|
||||
async with Agent(
|
||||
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
# Start a conversation and capture the thread
|
||||
thread = first_agent.get_new_thread()
|
||||
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
|
||||
# Start a conversation and capture the session
|
||||
session = first_agent.create_session()
|
||||
first_response = await first_agent.run("My name is Alice. Remember this.", session=session)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
|
||||
# Preserve the thread for reuse
|
||||
preserved_thread = thread
|
||||
# Preserve the session for reuse
|
||||
preserved_session = session
|
||||
|
||||
# Second conversation - reuse the thread in a new agent instance
|
||||
if preserved_thread:
|
||||
# Second conversation - reuse the session in a new agent instance
|
||||
if preserved_session:
|
||||
async with Agent(
|
||||
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
# Reuse the preserved thread
|
||||
second_response = await second_agent.run("What is my name?", thread=preserved_thread)
|
||||
# Reuse the preserved session
|
||||
second_response = await second_agent.run("What is my name?", session=preserved_session)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
|
||||
@@ -537,33 +537,33 @@ async def test_integration_client_agent_hosted_code_interpreter_tool():
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_integration_client_agent_existing_thread():
|
||||
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
|
||||
# First conversation - capture the thread
|
||||
preserved_thread = None
|
||||
async def test_integration_client_agent_existing_session():
|
||||
"""Test Azure Responses Client agent with existing session to continue conversations across agent instances."""
|
||||
# First conversation - capture the session
|
||||
preserved_session = None
|
||||
|
||||
async with Agent(
|
||||
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
# Start a conversation and capture the thread
|
||||
thread = first_agent.get_new_thread()
|
||||
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True)
|
||||
# Start a conversation and capture the session
|
||||
session = first_agent.create_session()
|
||||
first_response = await first_agent.run("My hobby is photography. Remember this.", session=session, store=True)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
|
||||
# Preserve the thread for reuse
|
||||
preserved_thread = thread
|
||||
# Preserve the session for reuse
|
||||
preserved_session = session
|
||||
|
||||
# Second conversation - reuse the thread in a new agent instance
|
||||
if preserved_thread:
|
||||
# Second conversation - reuse the session in a new agent instance
|
||||
if preserved_session:
|
||||
async with Agent(
|
||||
client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
# Reuse the preserved thread
|
||||
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
|
||||
# Reuse the preserved session
|
||||
second_response = await second_agent.run("What is my hobby?", session=preserved_session)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
|
||||
@@ -13,7 +13,7 @@ from pytest import fixture
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseChatClient,
|
||||
ChatMiddlewareLayer,
|
||||
ChatResponse,
|
||||
@@ -261,7 +261,7 @@ def chat_client_base(enable_function_calling: bool, max_iterations: int) -> Mock
|
||||
|
||||
|
||||
# region Agents
|
||||
class MockAgentThread(AgentThread):
|
||||
class MockAgentSession(AgentSession):
|
||||
pass
|
||||
|
||||
|
||||
@@ -284,41 +284,41 @@ class MockAgent(SupportsAgentRun):
|
||||
self,
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
stream: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]:
|
||||
if stream:
|
||||
return self._run_stream_impl(messages=messages, thread=thread, **kwargs)
|
||||
return self._run_impl(messages=messages, thread=thread, **kwargs)
|
||||
return self._run_stream_impl(messages=messages, session=session, **kwargs)
|
||||
return self._run_impl(messages=messages, session=session, **kwargs)
|
||||
|
||||
async def _run_impl(
|
||||
self,
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
logger.debug(f"Running mock agent, with: {messages=}, {thread=}, {kwargs=}")
|
||||
logger.debug(f"Running mock agent, with: {messages=}, {session=}, {kwargs=}")
|
||||
return AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Response")])])
|
||||
|
||||
async def _run_stream_impl(
|
||||
self,
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
logger.debug(f"Running mock agent stream, with: {messages=}, {thread=}, {kwargs=}")
|
||||
logger.debug(f"Running mock agent stream, with: {messages=}, {session=}, {kwargs=}")
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("Response")])
|
||||
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
return MockAgentThread()
|
||||
def create_session(self) -> AgentSession:
|
||||
return MockAgentSession()
|
||||
|
||||
|
||||
@fixture
|
||||
def agent_thread() -> AgentThread:
|
||||
return MockAgentThread()
|
||||
def agent_session() -> AgentSession:
|
||||
return MockAgentSession()
|
||||
|
||||
|
||||
@fixture
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterable, MutableSequence, Sequence
|
||||
from collections.abc import AsyncIterable, MutableSequence
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
@@ -13,13 +13,11 @@ from agent_framework import (
|
||||
Agent,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
ChatMessageStore,
|
||||
AgentSession,
|
||||
BaseContextProvider,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
Content,
|
||||
Context,
|
||||
ContextProvider,
|
||||
FunctionTool,
|
||||
Message,
|
||||
SupportsAgentRun,
|
||||
@@ -28,11 +26,10 @@ from agent_framework import (
|
||||
)
|
||||
from agent_framework._agents import _merge_options, _sanitize_agent_name
|
||||
from agent_framework._mcp import MCPTool
|
||||
from agent_framework.exceptions import AgentExecutionException, AgentInitializationError
|
||||
|
||||
|
||||
def test_agent_thread_type(agent_thread: AgentThread) -> None:
|
||||
assert isinstance(agent_thread, AgentThread)
|
||||
def test_agent_session_type(agent_session: AgentSession) -> None:
|
||||
assert isinstance(agent_session, AgentSession)
|
||||
|
||||
|
||||
def test_agent_type(agent: SupportsAgentRun) -> None:
|
||||
@@ -93,38 +90,42 @@ async def test_chat_client_agent_run_streaming(client: SupportsChatGetResponse)
|
||||
assert result.text == "test streaming response another update"
|
||||
|
||||
|
||||
async def test_chat_client_agent_get_new_thread(client: SupportsChatGetResponse) -> None:
|
||||
async def test_chat_client_agent_create_session(client: SupportsChatGetResponse) -> None:
|
||||
agent = Agent(client=client)
|
||||
thread = agent.get_new_thread()
|
||||
session = agent.create_session()
|
||||
|
||||
assert isinstance(thread, AgentThread)
|
||||
assert isinstance(session, AgentSession)
|
||||
|
||||
|
||||
async def test_chat_client_agent_prepare_thread_and_messages(client: SupportsChatGetResponse) -> None:
|
||||
agent = Agent(client=client)
|
||||
async def test_chat_client_agent_prepare_session_and_messages(client: SupportsChatGetResponse) -> None:
|
||||
from agent_framework._sessions import InMemoryHistoryProvider
|
||||
|
||||
agent = Agent(client=client, context_providers=[InMemoryHistoryProvider("memory")])
|
||||
message = Message(role="user", text="Hello")
|
||||
thread = AgentThread(message_store=ChatMessageStore(messages=[message]))
|
||||
session = AgentSession()
|
||||
session.state["memory"] = {"messages": [message]}
|
||||
|
||||
_, _, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=thread,
|
||||
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", text="Test")],
|
||||
)
|
||||
result_messages = session_context.get_messages(include_input=True)
|
||||
|
||||
assert len(result_messages) == 2
|
||||
assert result_messages[0] == message
|
||||
assert result_messages[0].text == "Hello"
|
||||
assert result_messages[1].text == "Test"
|
||||
|
||||
|
||||
async def test_prepare_thread_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
|
||||
async def test_prepare_session_does_not_mutate_agent_chat_options(client: SupportsChatGetResponse) -> None:
|
||||
tool = {"type": "code_interpreter"}
|
||||
agent = Agent(client=client, tools=[tool])
|
||||
|
||||
assert agent.default_options.get("tools") is not None
|
||||
base_tools = agent.default_options["tools"]
|
||||
thread = agent.get_new_thread()
|
||||
session = agent.create_session()
|
||||
|
||||
_, prepared_chat_options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=thread,
|
||||
_, prepared_chat_options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", text="Test")],
|
||||
)
|
||||
|
||||
@@ -135,7 +136,7 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(client: Support
|
||||
assert len(agent.default_options["tools"]) == 1
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChatGetResponse) -> None:
|
||||
async def test_chat_client_agent_run_with_session(chat_client_base: SupportsChatGetResponse) -> None:
|
||||
mock_response = ChatResponse(
|
||||
messages=[Message(role="assistant", contents=[Content.from_text("test response")])],
|
||||
conversation_id="123",
|
||||
@@ -145,25 +146,24 @@ async def test_chat_client_agent_update_thread_id(chat_client_base: SupportsChat
|
||||
client=chat_client_base,
|
||||
tools={"type": "code_interpreter"},
|
||||
)
|
||||
thread = agent.get_new_thread()
|
||||
session = agent.get_session(service_session_id="123")
|
||||
|
||||
result = await agent.run("Hello", thread=thread)
|
||||
result = await agent.run("Hello", session=session)
|
||||
assert result.text == "test response"
|
||||
|
||||
assert thread.service_thread_id == "123"
|
||||
assert session.service_session_id == "123"
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_thread_messages(client: SupportsChatGetResponse) -> None:
|
||||
async def test_chat_client_agent_update_session_messages(client: SupportsChatGetResponse) -> None:
|
||||
agent = Agent(client=client)
|
||||
thread = agent.get_new_thread()
|
||||
session = agent.create_session()
|
||||
|
||||
result = await agent.run("Hello", thread=thread)
|
||||
result = await agent.run("Hello", session=session)
|
||||
assert result.text == "test response"
|
||||
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is not None
|
||||
assert session.service_session_id is None
|
||||
|
||||
chat_messages: list[Message] = await thread.message_store.list_messages()
|
||||
chat_messages: list[Message] = session.state.get("memory", {}).get("messages", [])
|
||||
|
||||
assert chat_messages is not None
|
||||
assert len(chat_messages) == 2
|
||||
@@ -171,12 +171,12 @@ async def test_chat_client_agent_update_thread_messages(client: SupportsChatGetR
|
||||
assert chat_messages[1].text == "test response"
|
||||
|
||||
|
||||
async def test_chat_client_agent_update_thread_conversation_id_missing(client: SupportsChatGetResponse) -> None:
|
||||
async def test_chat_client_agent_update_session_conversation_id_missing(client: SupportsChatGetResponse) -> None:
|
||||
agent = Agent(client=client)
|
||||
thread = AgentThread(service_thread_id="123")
|
||||
session = agent.get_session(service_session_id="123")
|
||||
|
||||
with raises(AgentExecutionException, match="Service did not return a valid conversation id"):
|
||||
await agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
|
||||
# With the session-based API, service_session_id is managed directly on the session
|
||||
assert session.service_session_id == "123"
|
||||
|
||||
|
||||
async def test_chat_client_agent_default_author_name(client: SupportsChatGetResponse) -> None:
|
||||
@@ -214,54 +214,41 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
|
||||
|
||||
|
||||
# Mock context provider for testing
|
||||
class MockContextProvider(ContextProvider):
|
||||
class MockContextProvider(BaseContextProvider):
|
||||
def __init__(self, messages: list[Message] | None = None) -> None:
|
||||
super().__init__(source_id="mock")
|
||||
self.context_messages = messages
|
||||
self.thread_created_called = False
|
||||
self.invoked_called = False
|
||||
self.invoking_called = False
|
||||
self.thread_created_thread_id = None
|
||||
self.invoked_thread_id = None
|
||||
self.before_run_called = False
|
||||
self.after_run_called = False
|
||||
self.new_messages: list[Message] = []
|
||||
self.last_service_session_id: str | None = None
|
||||
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
self.thread_created_called = True
|
||||
self.thread_created_thread_id = thread_id
|
||||
async def before_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None:
|
||||
self.before_run_called = True
|
||||
if self.context_messages:
|
||||
context.extend_messages(self, self.context_messages)
|
||||
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: Message | Sequence[Message],
|
||||
response_messages: Message | Sequence[Message] | None = None,
|
||||
invoke_exception: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.invoked_called = True
|
||||
if isinstance(request_messages, Message):
|
||||
self.new_messages.append(request_messages)
|
||||
else:
|
||||
self.new_messages.extend(request_messages)
|
||||
if isinstance(response_messages, Message):
|
||||
self.new_messages.append(response_messages)
|
||||
else:
|
||||
self.new_messages.extend(response_messages)
|
||||
|
||||
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
|
||||
self.invoking_called = True
|
||||
return Context(messages=self.context_messages)
|
||||
async def after_run(self, *, agent: Any, session: Any, context: Any, state: Any) -> None:
|
||||
self.after_run_called = True
|
||||
if session:
|
||||
self.last_service_session_id = session.service_session_id
|
||||
if context.response:
|
||||
self.new_messages.extend(context.input_messages)
|
||||
self.new_messages.extend(context.response.messages)
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_model_invoking(client: SupportsChatGetResponse) -> None:
|
||||
"""Test that context providers' invoking is called during agent run."""
|
||||
async def test_chat_agent_context_providers_model_before_run(client: SupportsChatGetResponse) -> None:
|
||||
"""Test that context providers' before_run is called during agent run."""
|
||||
mock_provider = MockContextProvider(messages=[Message(role="system", text="Test context instructions")])
|
||||
agent = Agent(client=client, context_provider=mock_provider)
|
||||
agent = Agent(client=client, context_providers=[mock_provider])
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
assert mock_provider.invoking_called
|
||||
assert mock_provider.before_run_called
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_thread_created(chat_client_base: SupportsChatGetResponse) -> None:
|
||||
"""Test that context providers' thread_created is called during agent run."""
|
||||
async def test_chat_agent_context_providers_after_run(chat_client_base: SupportsChatGetResponse) -> None:
|
||||
"""Test that context providers' after_run is called during agent run."""
|
||||
mock_provider = MockContextProvider()
|
||||
chat_client_base.run_responses = [
|
||||
ChatResponse(
|
||||
@@ -270,22 +257,23 @@ async def test_chat_agent_context_providers_thread_created(chat_client_base: Sup
|
||||
)
|
||||
]
|
||||
|
||||
agent = Agent(client=chat_client_base, context_provider=mock_provider)
|
||||
agent = Agent(client=chat_client_base, context_providers=[mock_provider])
|
||||
|
||||
await agent.run("Hello")
|
||||
session = agent.get_session(service_session_id="test-thread-id")
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert mock_provider.thread_created_called
|
||||
assert mock_provider.thread_created_thread_id == "test-thread-id"
|
||||
assert mock_provider.after_run_called
|
||||
assert mock_provider.last_service_session_id == "test-thread-id"
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_messages_adding(client: SupportsChatGetResponse) -> None:
|
||||
"""Test that context providers' invoked is called during agent run."""
|
||||
"""Test that context providers' after_run is called during agent run."""
|
||||
mock_provider = MockContextProvider()
|
||||
agent = Agent(client=client, context_provider=mock_provider)
|
||||
agent = Agent(client=client, context_providers=[mock_provider])
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
assert mock_provider.invoked_called
|
||||
assert mock_provider.after_run_called
|
||||
# Should be called with both input and response messages
|
||||
assert len(mock_provider.new_messages) >= 2
|
||||
|
||||
@@ -293,12 +281,13 @@ async def test_chat_agent_context_providers_messages_adding(client: SupportsChat
|
||||
async def test_chat_agent_context_instructions_in_messages(client: SupportsChatGetResponse) -> None:
|
||||
"""Test that AI context instructions are included in messages."""
|
||||
mock_provider = MockContextProvider(messages=[Message(role="system", text="Context-specific instructions")])
|
||||
agent = Agent(client=client, instructions="Agent instructions", context_provider=mock_provider)
|
||||
agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider])
|
||||
|
||||
# We need to test the _prepare_thread_and_messages method directly
|
||||
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, input_messages=[Message(role="user", text="Hello")]
|
||||
# We need to test the _prepare_session_and_messages method directly
|
||||
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=None, input_messages=[Message(role="user", text="Hello")]
|
||||
)
|
||||
messages = session_context.get_messages(include_input=True)
|
||||
|
||||
# Should have context instructions, and user message
|
||||
assert len(messages) == 2
|
||||
@@ -312,11 +301,12 @@ async def test_chat_agent_context_instructions_in_messages(client: SupportsChatG
|
||||
async def test_chat_agent_no_context_instructions(client: SupportsChatGetResponse) -> None:
|
||||
"""Test behavior when AI context has no instructions."""
|
||||
mock_provider = MockContextProvider()
|
||||
agent = Agent(client=client, instructions="Agent instructions", context_provider=mock_provider)
|
||||
agent = Agent(client=client, instructions="Agent instructions", context_providers=[mock_provider])
|
||||
|
||||
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, input_messages=[Message(role="user", text="Hello")]
|
||||
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=None, input_messages=[Message(role="user", text="Hello")]
|
||||
)
|
||||
messages = session_context.get_messages(include_input=True)
|
||||
|
||||
# Should have agent instructions and user message only
|
||||
assert len(messages) == 1
|
||||
@@ -327,7 +317,7 @@ async def test_chat_agent_no_context_instructions(client: SupportsChatGetRespons
|
||||
async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetResponse) -> None:
|
||||
"""Test that context providers work with run method."""
|
||||
mock_provider = MockContextProvider(messages=[Message(role="system", text="Stream context instructions")])
|
||||
agent = Agent(client=client, context_provider=mock_provider)
|
||||
agent = Agent(client=client, context_providers=[mock_provider])
|
||||
|
||||
# Collect all stream updates and get final response
|
||||
stream = agent.run("Hello", stream=True)
|
||||
@@ -338,14 +328,12 @@ async def test_chat_agent_run_stream_context_providers(client: SupportsChatGetRe
|
||||
await stream.get_final_response()
|
||||
|
||||
# Verify context provider was called
|
||||
assert mock_provider.invoking_called
|
||||
# no conversation id is created, so no need to thread_create to be called.
|
||||
assert not mock_provider.thread_created_called
|
||||
assert mock_provider.invoked_called
|
||||
assert mock_provider.before_run_called
|
||||
assert mock_provider.after_run_called
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_with_thread_service_id(chat_client_base: SupportsChatGetResponse) -> None:
|
||||
"""Test context providers with service-managed thread."""
|
||||
async def test_chat_agent_context_providers_with_service_session_id(chat_client_base: SupportsChatGetResponse) -> None:
|
||||
"""Test context providers with service-managed session."""
|
||||
mock_provider = MockContextProvider()
|
||||
chat_client_base.run_responses = [
|
||||
ChatResponse(
|
||||
@@ -354,14 +342,14 @@ async def test_chat_agent_context_providers_with_thread_service_id(chat_client_b
|
||||
)
|
||||
]
|
||||
|
||||
agent = Agent(client=chat_client_base, context_provider=mock_provider)
|
||||
agent = Agent(client=chat_client_base, context_providers=[mock_provider])
|
||||
|
||||
# Use existing service-managed thread
|
||||
thread = agent.get_new_thread(service_thread_id="existing-thread-id")
|
||||
await agent.run("Hello", thread=thread)
|
||||
# Use existing service-managed session
|
||||
session = agent.get_session(service_session_id="existing-thread-id")
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# invoked should be called with the service thread ID from response
|
||||
assert mock_provider.invoked_called
|
||||
# after_run should be called
|
||||
assert mock_provider.after_run_called
|
||||
|
||||
|
||||
# Tests for as_tool method
|
||||
@@ -562,16 +550,16 @@ async def test_chat_agent_with_local_mcp_tools(client: SupportsChatGetResponse)
|
||||
pass
|
||||
|
||||
|
||||
async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> None:
|
||||
"""Verify tool execution receives 'thread' inside **kwargs when function is called by client."""
|
||||
async def test_agent_tool_receives_session_in_kwargs(chat_client_base: Any) -> None:
|
||||
"""Verify tool execution receives 'session' inside **kwargs when function is called by client."""
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
@tool(name="echo_thread_info", approval_mode="never_require")
|
||||
def echo_thread_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
|
||||
thread = kwargs.get("thread")
|
||||
captured["has_thread"] = thread is not None
|
||||
captured["has_message_store"] = thread.message_store is not None if isinstance(thread, AgentThread) else False
|
||||
@tool(name="echo_session_info", approval_mode="never_require")
|
||||
def echo_session_info(text: str, **kwargs: Any) -> str: # type: ignore[reportUnknownParameterType]
|
||||
session = kwargs.get("session")
|
||||
captured["has_session"] = session is not None
|
||||
captured["has_state"] = session.state is not None if isinstance(session, AgentSession) else False
|
||||
return f"echo: {text}"
|
||||
|
||||
# Make the base client emit a function call for our tool
|
||||
@@ -580,21 +568,21 @@ async def test_agent_tool_receives_thread_in_kwargs(chat_client_base: Any) -> No
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="1", name="echo_thread_info", arguments='{"text": "hello"}')
|
||||
Content.from_function_call(call_id="1", name="echo_session_info", arguments='{"text": "hello"}')
|
||||
],
|
||||
)
|
||||
),
|
||||
ChatResponse(messages=Message(role="assistant", text="done")),
|
||||
]
|
||||
|
||||
agent = Agent(client=chat_client_base, tools=[echo_thread_info], chat_message_store_factory=ChatMessageStore)
|
||||
thread = agent.get_new_thread()
|
||||
agent = Agent(client=chat_client_base, tools=[echo_session_info])
|
||||
session = agent.create_session()
|
||||
|
||||
result = await agent.run("hello", thread=thread, options={"additional_function_arguments": {"thread": thread}})
|
||||
result = await agent.run("hello", session=session, options={"additional_function_arguments": {"session": session}})
|
||||
|
||||
assert result.text == "done"
|
||||
assert captured.get("has_thread") is True
|
||||
assert captured.get("has_message_store") is True
|
||||
assert captured.get("has_session") is True
|
||||
assert captured.get("has_state") is True
|
||||
|
||||
|
||||
async def test_chat_agent_tool_choice_run_level_overrides_agent_level(chat_client_base: Any, tool_tool: Any) -> None:
|
||||
@@ -801,73 +789,67 @@ def test_sanitize_agent_name_replaces_invalid_chars():
|
||||
# endregion
|
||||
|
||||
|
||||
# region Test SupportsAgentRun.get_new_thread and deserialize_thread
|
||||
# region Test SupportsAgentRun.create_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_get_new_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
|
||||
"""Test that get_new_thread returns a new AgentThread."""
|
||||
async def test_agent_create_session(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
|
||||
"""Test that create_session returns a new AgentSession."""
|
||||
agent = Agent(client=chat_client_base, tools=[tool_tool])
|
||||
|
||||
thread = agent.get_new_thread()
|
||||
session = agent.create_session()
|
||||
|
||||
assert thread is not None
|
||||
assert isinstance(thread, AgentThread)
|
||||
assert session is not None
|
||||
assert isinstance(session, AgentSession)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_get_new_thread_with_context_provider(
|
||||
async def test_agent_create_session_with_context_providers(
|
||||
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
|
||||
):
|
||||
"""Test that get_new_thread passes context_provider to the thread."""
|
||||
"""Test that create_session works when context_providers are set on the agent."""
|
||||
|
||||
class TestContextProvider(ContextProvider):
|
||||
async def invoking(self, messages, **kwargs):
|
||||
return Context()
|
||||
class TestContextProvider(BaseContextProvider):
|
||||
def __init__(self):
|
||||
super().__init__(source_id="test")
|
||||
|
||||
provider = TestContextProvider()
|
||||
agent = Agent(client=chat_client_base, tools=[tool_tool], context_provider=provider)
|
||||
agent = Agent(client=chat_client_base, tools=[tool_tool], context_providers=[provider])
|
||||
|
||||
thread = agent.get_new_thread()
|
||||
session = agent.create_session()
|
||||
|
||||
assert thread is not None
|
||||
assert thread.context_provider is provider
|
||||
assert session is not None
|
||||
assert agent.context_providers[0] is provider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_get_new_thread_with_service_thread_id(
|
||||
async def test_agent_get_session_with_service_session_id(
|
||||
chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool
|
||||
):
|
||||
"""Test that get_new_thread passes kwargs like service_thread_id to the thread."""
|
||||
"""Test that get_session creates a session with service_session_id."""
|
||||
agent = Agent(client=chat_client_base, tools=[tool_tool])
|
||||
|
||||
thread = agent.get_new_thread(service_thread_id="test-thread-123")
|
||||
session = agent.get_session(service_session_id="test-thread-123")
|
||||
|
||||
assert thread is not None
|
||||
assert thread.service_thread_id == "test-thread-123"
|
||||
assert session is not None
|
||||
assert session.service_session_id == "test-thread-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
|
||||
"""Test deserialize_thread restores a thread from serialized state."""
|
||||
agent = Agent(client=chat_client_base, tools=[tool_tool])
|
||||
|
||||
# Create serialized thread state with messages
|
||||
def test_agent_session_from_dict(chat_client_base: SupportsChatGetResponse, tool_tool: FunctionTool):
|
||||
"""Test AgentSession.from_dict restores a session from serialized state."""
|
||||
# Create serialized session state
|
||||
serialized_state = {
|
||||
"service_thread_id": None,
|
||||
"chat_message_store_state": {
|
||||
"messages": [{"role": "user", "text": "Hello"}],
|
||||
},
|
||||
"type": "session",
|
||||
"session_id": "test-session",
|
||||
"service_session_id": None,
|
||||
"state": {},
|
||||
}
|
||||
|
||||
thread = await agent.deserialize_thread(serialized_state)
|
||||
session = AgentSession.from_dict(serialized_state)
|
||||
|
||||
assert thread is not None
|
||||
assert isinstance(thread, AgentThread)
|
||||
assert thread.message_store is not None
|
||||
messages = await thread.message_store.list_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].text == "Hello"
|
||||
assert session is not None
|
||||
assert isinstance(session, AgentSession)
|
||||
assert session.session_id == "test-session"
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -876,20 +858,6 @@ async def test_agent_deserialize_thread(chat_client_base: SupportsChatGetRespons
|
||||
# region Test Agent initialization edge cases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_agent_raises_with_both_conversation_id_and_store():
|
||||
"""Test Agent raises error with both conversation_id and chat_message_store_factory."""
|
||||
mock_client = MagicMock()
|
||||
mock_store_factory = MagicMock()
|
||||
|
||||
with pytest.raises(AgentInitializationError, match="Cannot specify both"):
|
||||
Agent(
|
||||
client=mock_client,
|
||||
default_options={"conversation_id": "test_id"},
|
||||
chat_message_store_factory=mock_store_factory,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_agent_calls_update_agent_name_on_client():
|
||||
"""Test that Agent calls _update_agent_name_and_description on client if available."""
|
||||
mock_client = MagicMock()
|
||||
@@ -914,19 +882,22 @@ async def test_chat_agent_context_provider_adds_tools_when_agent_has_none(chat_c
|
||||
"""A tool provided by context."""
|
||||
return text
|
||||
|
||||
class ToolContextProvider(ContextProvider):
|
||||
async def invoking(self, messages, **kwargs):
|
||||
return Context(tools=[context_tool])
|
||||
class ToolContextProvider(BaseContextProvider):
|
||||
def __init__(self):
|
||||
super().__init__(source_id="tool-context")
|
||||
|
||||
async def before_run(self, *, agent, session, context, state):
|
||||
context.extend_tools("tool-context", [context_tool])
|
||||
|
||||
provider = ToolContextProvider()
|
||||
agent = Agent(client=chat_client_base, context_provider=provider)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
# Agent starts with empty tools list
|
||||
assert agent.default_options.get("tools") == []
|
||||
|
||||
# Run the agent and verify context tools are added
|
||||
_, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, input_messages=[Message(role="user", text="Hello")]
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=None, input_messages=[Message(role="user", text="Hello")]
|
||||
)
|
||||
|
||||
# The context tools should now be in the options
|
||||
@@ -940,40 +911,76 @@ async def test_chat_agent_context_provider_adds_instructions_when_agent_has_none
|
||||
):
|
||||
"""Test that context provider instructions are used when agent has no default instructions."""
|
||||
|
||||
class InstructionContextProvider(ContextProvider):
|
||||
async def invoking(self, messages, **kwargs):
|
||||
return Context(instructions="Context-provided instructions")
|
||||
class InstructionContextProvider(BaseContextProvider):
|
||||
def __init__(self):
|
||||
super().__init__(source_id="instruction-context")
|
||||
|
||||
async def before_run(self, *, agent, session, context, state):
|
||||
context.extend_instructions("instruction-context", "Context-provided instructions")
|
||||
|
||||
provider = InstructionContextProvider()
|
||||
agent = Agent(client=chat_client_base, context_provider=provider)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
# Verify agent has no default instructions
|
||||
assert agent.default_options.get("instructions") is None
|
||||
|
||||
# Run the agent and verify context instructions are available
|
||||
_, options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, input_messages=[Message(role="user", text="Hello")]
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=None, input_messages=[Message(role="user", text="Hello")]
|
||||
)
|
||||
|
||||
# The context instructions should now be in the options
|
||||
assert options.get("instructions") == "Context-provided instructions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_agent_raises_on_conversation_id_mismatch(chat_client_base: SupportsChatGetResponse):
|
||||
"""Test that Agent raises when thread and agent have different conversation IDs."""
|
||||
agent = Agent(
|
||||
client=chat_client_base,
|
||||
default_options={"conversation_id": "agent-conversation-id"},
|
||||
)
|
||||
# region STORES_BY_DEFAULT tests
|
||||
|
||||
# Create a thread with a different service_thread_id
|
||||
thread = AgentThread(service_thread_id="different-thread-id")
|
||||
|
||||
with pytest.raises(AgentExecutionException, match="conversation_id set on the agent is different"):
|
||||
await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=thread, input_messages=[Message(role="user", text="Hello")]
|
||||
)
|
||||
async def test_stores_by_default_skips_inmemory_injection(client: SupportsChatGetResponse) -> None:
|
||||
"""Client with STORES_BY_DEFAULT=True should not auto-inject InMemoryHistoryProvider."""
|
||||
from agent_framework._sessions import InMemoryHistoryProvider
|
||||
|
||||
# Simulate a client that stores by default
|
||||
client.STORES_BY_DEFAULT = True # type: ignore[attr-defined]
|
||||
|
||||
agent = Agent(client=client)
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# No InMemoryHistoryProvider should have been injected
|
||||
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
|
||||
|
||||
|
||||
async def test_stores_by_default_false_injects_inmemory(client: SupportsChatGetResponse) -> None:
|
||||
"""Client with STORES_BY_DEFAULT=False (default) should auto-inject InMemoryHistoryProvider."""
|
||||
from agent_framework._sessions import InMemoryHistoryProvider
|
||||
|
||||
agent = Agent(client=client)
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# InMemoryHistoryProvider should have been injected
|
||||
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
|
||||
|
||||
|
||||
async def test_stores_by_default_with_store_false_injects_inmemory(client: SupportsChatGetResponse) -> None:
|
||||
"""Client with STORES_BY_DEFAULT=True but store=False should still inject InMemoryHistoryProvider."""
|
||||
from agent_framework._sessions import InMemoryHistoryProvider
|
||||
|
||||
client.STORES_BY_DEFAULT = True # type: ignore[attr-defined]
|
||||
|
||||
agent = Agent(client=client)
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello", session=session, options={"store": False})
|
||||
|
||||
# User explicitly disabled server storage, so InMemoryHistoryProvider should be injected
|
||||
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -168,8 +168,8 @@ async def test_function_invocation_inside_aiohttp_server(chat_client_base: Suppo
|
||||
agent = Agent(client=chat_client_base, tools=[ai_func])
|
||||
|
||||
async def handler(request: web.Request) -> web.Response:
|
||||
thread = agent.get_new_thread()
|
||||
result = await agent.run("Fix issue", thread=thread)
|
||||
session = agent.create_session()
|
||||
result = await agent.run("Fix issue", session=session)
|
||||
return web.Response(text=result.text or "")
|
||||
|
||||
app = web.Application()
|
||||
@@ -230,8 +230,8 @@ async def test_function_invocation_in_threaded_aiohttp_app(chat_client_base: Sup
|
||||
|
||||
async def init_app() -> web.Application:
|
||||
async def handler(request: web.Request) -> web.Response:
|
||||
thread = agent.get_new_thread()
|
||||
result = await agent.run("Fix issue", thread=thread)
|
||||
session = agent.create_session()
|
||||
result = await agent.run("Fix issue", session=session)
|
||||
return web.Response(text=result.text or "")
|
||||
|
||||
app = web.Application()
|
||||
|
||||
@@ -25,8 +25,8 @@ from agent_framework._mcp import (
|
||||
_get_input_model_from_mcp_tool,
|
||||
_normalize_mcp_name,
|
||||
_parse_content_from_mcp,
|
||||
_parse_tool_result_from_mcp,
|
||||
_parse_message_from_mcp,
|
||||
_parse_tool_result_from_mcp,
|
||||
_prepare_content_for_mcp,
|
||||
_prepare_message_for_mcp,
|
||||
logger,
|
||||
@@ -97,9 +97,7 @@ def test_parse_tool_result_from_mcp():
|
||||
|
||||
def test_parse_tool_result_from_mcp_single_text():
|
||||
"""Test conversion from MCP tool result with a single text item."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Simple result")]
|
||||
)
|
||||
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")])
|
||||
result = _parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
# Single text item returns just the text
|
||||
@@ -2590,7 +2588,7 @@ async def test_mcp_tool_filters_framework_kwargs():
|
||||
chat_options={"some": "option"}, # Should be filtered
|
||||
tools=[Mock()], # Should be filtered
|
||||
tool_choice="auto", # Should be filtered
|
||||
thread=Mock(), # Should be filtered
|
||||
session=Mock(), # Should be filtered
|
||||
conversation_id="conv-123", # Should be filtered
|
||||
options={"metadata": "value"}, # Should be filtered
|
||||
)
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from collections.abc import MutableSequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework._memory import Context, ContextProvider
|
||||
|
||||
|
||||
class MockContextProvider(ContextProvider):
|
||||
"""Mock ContextProvider for testing."""
|
||||
|
||||
def __init__(self, messages: list[Message] | None = None) -> None:
|
||||
self.context_messages = messages
|
||||
self.thread_created_called = False
|
||||
self.invoked_called = False
|
||||
self.invoking_called = False
|
||||
self.thread_created_thread_id = None
|
||||
self.new_messages = None
|
||||
self.model_invoking_messages = None
|
||||
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
"""Track thread_created calls."""
|
||||
self.thread_created_called = True
|
||||
self.thread_created_thread_id = thread_id
|
||||
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: Any,
|
||||
response_messages: Any | None = None,
|
||||
invoke_exception: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Track invoked calls."""
|
||||
self.invoked_called = True
|
||||
self.new_messages = request_messages
|
||||
|
||||
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
|
||||
"""Track invoking calls and return context."""
|
||||
self.invoking_called = True
|
||||
self.model_invoking_messages = messages
|
||||
context = Context()
|
||||
context.messages = self.context_messages
|
||||
return context
|
||||
|
||||
|
||||
class MinimalContextProvider(ContextProvider):
|
||||
"""Minimal ContextProvider that only implements the required abstract method.
|
||||
|
||||
Used to test the base class default implementations of thread_created,
|
||||
invoked, __aenter__, and __aexit__.
|
||||
"""
|
||||
|
||||
async def invoking(self, messages: Message | MutableSequence[Message], **kwargs: Any) -> Context:
|
||||
"""Return empty context."""
|
||||
return Context()
|
||||
|
||||
|
||||
class TestContext:
|
||||
"""Tests for Context class."""
|
||||
|
||||
def test_context_default_values(self) -> None:
|
||||
"""Test Context has correct default values."""
|
||||
context = Context()
|
||||
assert context.instructions is None
|
||||
assert context.messages == []
|
||||
assert context.tools == []
|
||||
|
||||
def test_context_with_values(self) -> None:
|
||||
"""Test Context can be initialized with values."""
|
||||
messages = [Message(role="user", text="Test message")]
|
||||
context = Context(instructions="Test instructions", messages=messages)
|
||||
assert context.instructions == "Test instructions"
|
||||
assert len(context.messages) == 1
|
||||
assert context.messages[0].text == "Test message"
|
||||
|
||||
|
||||
class TestContextProvider:
|
||||
"""Tests for ContextProvider class."""
|
||||
|
||||
async def test_thread_created(self) -> None:
|
||||
"""Test thread_created is called."""
|
||||
provider = MockContextProvider()
|
||||
await provider.thread_created("test-thread-id")
|
||||
assert provider.thread_created_called
|
||||
assert provider.thread_created_thread_id == "test-thread-id"
|
||||
|
||||
async def test_invoked(self) -> None:
|
||||
"""Test invoked is called."""
|
||||
provider = MockContextProvider()
|
||||
message = Message(role="user", text="Test message")
|
||||
await provider.invoked(message)
|
||||
assert provider.invoked_called
|
||||
assert provider.new_messages == message
|
||||
|
||||
async def test_invoking(self) -> None:
|
||||
"""Test invoking is called and returns context."""
|
||||
provider = MockContextProvider(messages=[Message(role="user", text="Context message")])
|
||||
message = Message(role="user", text="Test message")
|
||||
context = await provider.invoking(message)
|
||||
assert provider.invoking_called
|
||||
assert provider.model_invoking_messages == message
|
||||
assert context.messages is not None
|
||||
assert len(context.messages) == 1
|
||||
assert context.messages[0].text == "Context message"
|
||||
|
||||
async def test_base_thread_created_does_nothing(self) -> None:
|
||||
"""Test that base ContextProvider.thread_created does nothing by default."""
|
||||
provider = MinimalContextProvider()
|
||||
await provider.thread_created("some-thread-id")
|
||||
await provider.thread_created(None)
|
||||
|
||||
async def test_base_invoked_does_nothing(self) -> None:
|
||||
"""Test that base ContextProvider.invoked does nothing by default."""
|
||||
provider = MinimalContextProvider()
|
||||
message = Message(role="user", text="Test")
|
||||
await provider.invoked(message)
|
||||
await provider.invoked(message, response_messages=message)
|
||||
await provider.invoked(message, invoke_exception=Exception("test"))
|
||||
|
||||
async def test_base_aenter_returns_self(self) -> None:
|
||||
"""Test that base ContextProvider.__aenter__ returns self."""
|
||||
provider = MinimalContextProvider()
|
||||
async with provider as p:
|
||||
assert p is provider
|
||||
|
||||
async def test_base_aexit_does_nothing(self) -> None:
|
||||
"""Test that base ContextProvider.__aexit__ handles exceptions gracefully."""
|
||||
provider = MinimalContextProvider()
|
||||
await provider.__aexit__(None, None, None)
|
||||
try:
|
||||
raise ValueError("test error")
|
||||
except ValueError:
|
||||
exc_info = sys.exc_info()
|
||||
await provider.__aexit__(exc_info[0], exc_info[1], exc_info[2])
|
||||
@@ -56,17 +56,17 @@ class TestAgentContext:
|
||||
assert context.stream is True
|
||||
assert context.metadata == metadata
|
||||
|
||||
def test_init_with_thread(self, mock_agent: SupportsAgentRun) -> None:
|
||||
"""Test AgentContext initialization with thread parameter."""
|
||||
from agent_framework import AgentThread
|
||||
def test_init_with_session(self, mock_agent: SupportsAgentRun) -> None:
|
||||
"""Test AgentContext initialization with session parameter."""
|
||||
from agent_framework import AgentSession
|
||||
|
||||
messages = [Message(role="user", text="test")]
|
||||
thread = AgentThread()
|
||||
context = AgentContext(agent=mock_agent, messages=messages, thread=thread)
|
||||
session = AgentSession()
|
||||
context = AgentContext(agent=mock_agent, messages=messages, session=session)
|
||||
|
||||
assert context.agent is mock_agent
|
||||
assert context.messages == messages
|
||||
assert context.thread is thread
|
||||
assert context.session is session
|
||||
assert context.stream is False
|
||||
assert context.metadata == {}
|
||||
|
||||
@@ -356,23 +356,23 @@ class TestAgentMiddlewarePipeline:
|
||||
assert updates[1].text == "chunk2"
|
||||
assert execution_order == ["handler_start", "handler_end"]
|
||||
|
||||
async def test_execute_with_thread_in_context(self, mock_agent: SupportsAgentRun) -> None:
|
||||
"""Test pipeline execution properly passes thread to middleware."""
|
||||
from agent_framework import AgentThread
|
||||
async def test_execute_with_session_in_context(self, mock_agent: SupportsAgentRun) -> None:
|
||||
"""Test pipeline execution properly passes session to middleware."""
|
||||
from agent_framework import AgentSession
|
||||
|
||||
captured_thread = None
|
||||
captured_session = None
|
||||
|
||||
class ThreadCapturingMiddleware(AgentMiddleware):
|
||||
class SessionCapturingMiddleware(AgentMiddleware):
|
||||
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
nonlocal captured_thread
|
||||
captured_thread = context.thread
|
||||
nonlocal captured_session
|
||||
captured_session = context.session
|
||||
await call_next()
|
||||
|
||||
middleware = ThreadCapturingMiddleware()
|
||||
middleware = SessionCapturingMiddleware()
|
||||
pipeline = AgentMiddlewarePipeline(middleware)
|
||||
messages = [Message(role="user", text="test")]
|
||||
thread = AgentThread()
|
||||
context = AgentContext(agent=mock_agent, messages=messages, thread=thread)
|
||||
session = AgentSession()
|
||||
context = AgentContext(agent=mock_agent, messages=messages, session=session)
|
||||
|
||||
expected_response = AgentResponse(messages=[Message(role="assistant", text="response")])
|
||||
|
||||
@@ -381,22 +381,22 @@ class TestAgentMiddlewarePipeline:
|
||||
|
||||
result = await pipeline.execute(context, final_handler)
|
||||
assert result == expected_response
|
||||
assert captured_thread is thread
|
||||
assert captured_session is session
|
||||
|
||||
async def test_execute_with_no_thread_in_context(self, mock_agent: SupportsAgentRun) -> None:
|
||||
"""Test pipeline execution when no thread is provided."""
|
||||
captured_thread = "not_none" # Use string to distinguish from None
|
||||
async def test_execute_with_no_session_in_context(self, mock_agent: SupportsAgentRun) -> None:
|
||||
"""Test pipeline execution when no session is provided."""
|
||||
captured_session = "not_none" # Use string to distinguish from None
|
||||
|
||||
class ThreadCapturingMiddleware(AgentMiddleware):
|
||||
class SessionCapturingMiddleware(AgentMiddleware):
|
||||
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
nonlocal captured_thread
|
||||
captured_thread = context.thread
|
||||
nonlocal captured_session
|
||||
captured_session = context.session
|
||||
await call_next()
|
||||
|
||||
middleware = ThreadCapturingMiddleware()
|
||||
middleware = SessionCapturingMiddleware()
|
||||
pipeline = AgentMiddlewarePipeline(middleware)
|
||||
messages = [Message(role="user", text="test")]
|
||||
context = AgentContext(agent=mock_agent, messages=messages, thread=None)
|
||||
context = AgentContext(agent=mock_agent, messages=messages, session=None)
|
||||
|
||||
expected_response = AgentResponse(messages=[Message(role="assistant", text="response")])
|
||||
|
||||
@@ -405,7 +405,7 @@ class TestAgentMiddlewarePipeline:
|
||||
|
||||
result = await pipeline.execute(context, final_handler)
|
||||
assert result == expected_response
|
||||
assert captured_thread is None
|
||||
assert captured_session is None
|
||||
|
||||
|
||||
class TestFunctionMiddlewarePipeline:
|
||||
|
||||
@@ -1405,19 +1405,19 @@ class TestMiddlewareDecoratorLogic:
|
||||
assert test_function_middleware._middleware_type == MiddlewareType.FUNCTION # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestChatAgentThreadBehavior:
|
||||
"""Test cases for thread behavior in AgentContext across multiple runs."""
|
||||
class TestChatAgentSessionBehavior:
|
||||
"""Test cases for session behavior in AgentContext across multiple runs."""
|
||||
|
||||
async def test_agent_context_thread_behavior_across_multiple_runs(self, client: "MockChatClient") -> None:
|
||||
"""Test that AgentContext.thread property behaves correctly across multiple agent runs."""
|
||||
async def test_agent_context_session_behavior_across_multiple_runs(self, client: "MockChatClient") -> None:
|
||||
"""Test that AgentContext.session property behaves correctly across multiple agent runs."""
|
||||
thread_states: list[dict[str, Any]] = []
|
||||
|
||||
class ThreadTrackingMiddleware(AgentMiddleware):
|
||||
class SessionTrackingMiddleware(AgentMiddleware):
|
||||
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
# Capture state before next() call
|
||||
thread_messages = []
|
||||
if context.thread and context.thread.message_store:
|
||||
thread_messages = await context.thread.message_store.list_messages()
|
||||
if context.session and context.session.state.get("memory"):
|
||||
thread_messages = context.session.state.get("memory", {}).get("messages", [])
|
||||
|
||||
before_state = {
|
||||
"before_next": True,
|
||||
@@ -1432,8 +1432,8 @@ class TestChatAgentThreadBehavior:
|
||||
|
||||
# Capture state after next() call
|
||||
thread_messages_after = []
|
||||
if context.thread and context.thread.message_store:
|
||||
thread_messages_after = await context.thread.message_store.list_messages()
|
||||
if context.session and context.session.state.get("memory"):
|
||||
thread_messages_after = context.session.state.get("memory", {}).get("messages", [])
|
||||
|
||||
after_state = {
|
||||
"before_next": False,
|
||||
@@ -1444,19 +1444,16 @@ class TestChatAgentThreadBehavior:
|
||||
}
|
||||
thread_states.append(after_state)
|
||||
|
||||
# Import the ChatMessageStore to configure the agent with a message store factory
|
||||
from agent_framework import ChatMessageStore
|
||||
# Create Agent with session tracking middleware
|
||||
middleware = SessionTrackingMiddleware()
|
||||
agent = Agent(client=client, middleware=[middleware])
|
||||
|
||||
# Create Agent with thread tracking middleware and a message store factory
|
||||
middleware = ThreadTrackingMiddleware()
|
||||
agent = Agent(client=client, middleware=[middleware], chat_message_store_factory=ChatMessageStore)
|
||||
|
||||
# Create a thread that will persist messages between runs
|
||||
thread = agent.get_new_thread()
|
||||
# Create a session that will persist messages between runs
|
||||
session = agent.create_session()
|
||||
|
||||
# First run
|
||||
first_messages = [Message(role="user", text="first message")]
|
||||
first_response = await agent.run(first_messages, thread=thread)
|
||||
first_response = await agent.run(first_messages, session=session)
|
||||
|
||||
# Verify first response
|
||||
assert first_response is not None
|
||||
@@ -1464,7 +1461,7 @@ class TestChatAgentThreadBehavior:
|
||||
|
||||
# Second run - use the same thread
|
||||
second_messages = [Message(role="user", text="second message")]
|
||||
second_response = await agent.run(second_messages, thread=thread)
|
||||
second_response = await agent.run(second_messages, session=session)
|
||||
|
||||
# Verify second response
|
||||
assert second_response is not None
|
||||
|
||||
@@ -441,19 +441,19 @@ def mock_chat_agent():
|
||||
self.description = "Test agent description"
|
||||
self.default_options: dict[str, Any] = {"model_id": "TestModel"}
|
||||
|
||||
def run(self, messages=None, *, thread=None, stream=False, **kwargs):
|
||||
def run(self, messages=None, *, session=None, stream=False, **kwargs):
|
||||
if stream:
|
||||
return self._run_stream_impl(messages=messages, **kwargs)
|
||||
return self._run_impl(messages=messages, **kwargs)
|
||||
|
||||
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run_impl(self, messages=None, *, session=None, **kwargs):
|
||||
return AgentResponse(
|
||||
messages=[Message("assistant", ["Agent response"])],
|
||||
usage_details=UsageDetails(input_token_count=15, output_token_count=25),
|
||||
response_id="test_response_id",
|
||||
)
|
||||
|
||||
async def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream
|
||||
|
||||
async def _stream():
|
||||
@@ -1572,12 +1572,12 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s
|
||||
messages=None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread=None,
|
||||
session=None,
|
||||
**kwargs,
|
||||
):
|
||||
if stream:
|
||||
return ResponseStream(
|
||||
self._run_stream(messages=messages, thread=thread),
|
||||
self._run_stream(messages=messages, session=session),
|
||||
finalizer=lambda x: AgentResponse.from_updates(x),
|
||||
)
|
||||
return AgentResponse(messages=[Message("assistant", ["Test response"])])
|
||||
@@ -1586,7 +1586,7 @@ async def test_agent_observability(span_exporter: InMemorySpanExporter, enable_s
|
||||
self,
|
||||
messages=None,
|
||||
*,
|
||||
thread=None,
|
||||
session=None,
|
||||
**kwargs,
|
||||
):
|
||||
from agent_framework import AgentResponseUpdate
|
||||
@@ -1635,7 +1635,7 @@ async def test_agent_observability_with_exception(span_exporter: InMemorySpanExp
|
||||
def default_options(self):
|
||||
return self._default_options
|
||||
|
||||
async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
|
||||
async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
|
||||
raise RuntimeError("Agent failed")
|
||||
|
||||
class FailingAgent(AgentTelemetryLayer, _FailingAgent):
|
||||
@@ -1685,15 +1685,15 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter
|
||||
def default_options(self):
|
||||
return self._default_options
|
||||
|
||||
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
|
||||
def run(self, messages=None, *, stream=False, session=None, **kwargs):
|
||||
if stream:
|
||||
return self._run_stream_impl(messages=messages, **kwargs)
|
||||
return self._run_impl(messages=messages, **kwargs)
|
||||
|
||||
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run_impl(self, messages=None, *, session=None, **kwargs):
|
||||
return AgentResponse(messages=[Message("assistant", ["Test"])])
|
||||
|
||||
def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
|
||||
def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
|
||||
async def _stream():
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant")
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("World")], role="assistant")
|
||||
@@ -1822,15 +1822,15 @@ async def test_agent_streaming_exception(span_exporter: InMemorySpanExporter, en
|
||||
def default_options(self):
|
||||
return self._default_options
|
||||
|
||||
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
|
||||
def run(self, messages=None, *, stream=False, session=None, **kwargs):
|
||||
if stream:
|
||||
return self._run_stream_impl(messages=messages, **kwargs)
|
||||
return self._run_impl(messages=messages, **kwargs)
|
||||
|
||||
async def _run_impl(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run_impl(self, messages=None, *, session=None, **kwargs):
|
||||
return AgentResponse(messages=[])
|
||||
|
||||
def _run_stream_impl(self, messages=None, *, thread=None, **kwargs):
|
||||
def _run_stream_impl(self, messages=None, *, session=None, **kwargs):
|
||||
async def _stream():
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("Starting")], role="assistant")
|
||||
raise RuntimeError("Stream failed")
|
||||
@@ -1919,7 +1919,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter):
|
||||
def default_options(self):
|
||||
return self._default_options
|
||||
|
||||
async def run(self, messages=None, *, stream: bool = False, thread=None, **kwargs):
|
||||
async def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
|
||||
if stream:
|
||||
return ResponseStream(
|
||||
self._run_stream(messages=messages, **kwargs),
|
||||
@@ -1927,7 +1927,7 @@ async def test_agent_when_disabled(span_exporter: InMemorySpanExporter):
|
||||
)
|
||||
return AgentResponse(messages=[])
|
||||
|
||||
async def _run_stream(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run_stream(self, messages=None, *, session=None, **kwargs):
|
||||
from agent_framework import AgentResponseUpdate
|
||||
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant")
|
||||
@@ -1974,15 +1974,15 @@ async def test_agent_streaming_when_disabled(span_exporter: InMemorySpanExporter
|
||||
def default_options(self):
|
||||
return self._default_options
|
||||
|
||||
def run(self, messages=None, *, stream=False, thread=None, **kwargs):
|
||||
def run(self, messages=None, *, stream=False, session=None, **kwargs):
|
||||
if stream:
|
||||
return self._run_stream(messages=messages, **kwargs)
|
||||
return self._run(messages=messages, **kwargs)
|
||||
|
||||
async def _run(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run(self, messages=None, *, session=None, **kwargs):
|
||||
return AgentResponse(messages=[])
|
||||
|
||||
async def _run_stream(self, messages=None, *, thread=None, **kwargs):
|
||||
async def _run_stream(self, messages=None, *, session=None, **kwargs):
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("test")], role="assistant")
|
||||
|
||||
class TestAgent(AgentTelemetryLayer, _TestAgent):
|
||||
|
||||
@@ -28,6 +28,12 @@ class SecretSettings(TypedDict, total=False):
|
||||
username: str | None
|
||||
|
||||
|
||||
class ExclusiveSettings(TypedDict, total=False):
|
||||
source_a: str | None
|
||||
source_b: str | None
|
||||
other: str | None
|
||||
|
||||
|
||||
class TestLoadSettingsBasic:
|
||||
"""Test basic load_settings functionality."""
|
||||
|
||||
@@ -236,3 +242,89 @@ class TestOverrideTypeValidation:
|
||||
|
||||
assert isinstance(settings["api_key"], SecretString)
|
||||
assert settings["api_key"] == "plain-string"
|
||||
|
||||
|
||||
class TestMutuallyExclusive:
|
||||
"""Test mutually exclusive field validation via tuple entries in required_fields."""
|
||||
|
||||
def test_exactly_one_set_passes(self) -> None:
|
||||
settings = load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=[("source_a", "source_b")],
|
||||
source_a="value-a",
|
||||
)
|
||||
|
||||
assert settings["source_a"] == "value-a"
|
||||
assert settings["source_b"] is None
|
||||
|
||||
def test_none_set_raises(self) -> None:
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="none was set"):
|
||||
load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=[("source_a", "source_b")],
|
||||
)
|
||||
|
||||
def test_both_set_raises(self) -> None:
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="multiple were set"):
|
||||
load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=[("source_a", "source_b")],
|
||||
source_a="a",
|
||||
source_b="b",
|
||||
)
|
||||
|
||||
def test_env_var_counts_as_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TEST_SOURCE_B", "env-b")
|
||||
|
||||
settings = load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=[("source_a", "source_b")],
|
||||
)
|
||||
|
||||
assert settings["source_b"] == "env-b"
|
||||
|
||||
def test_env_var_and_override_both_set_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
|
||||
monkeypatch.setenv("TEST_SOURCE_B", "env-b")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="multiple were set"):
|
||||
load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=[("source_a", "source_b")],
|
||||
source_a="a",
|
||||
)
|
||||
|
||||
def test_other_fields_unaffected(self) -> None:
|
||||
settings = load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=[("source_a", "source_b")],
|
||||
source_a="a",
|
||||
other="extra",
|
||||
)
|
||||
|
||||
assert settings["source_a"] == "a"
|
||||
assert settings["other"] == "extra"
|
||||
|
||||
def test_mixed_required_and_exclusive(self) -> None:
|
||||
settings = load_settings(
|
||||
ExclusiveSettings,
|
||||
env_prefix="TEST_",
|
||||
required_fields=["other", ("source_a", "source_b")],
|
||||
source_b="b",
|
||||
other="required-val",
|
||||
)
|
||||
|
||||
assert settings["other"] == "required-val"
|
||||
assert settings["source_b"] == "b"
|
||||
assert settings["source_a"] is None
|
||||
|
||||
@@ -1,600 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import AgentThread, ChatMessageStore, Message
|
||||
from agent_framework._threads import AgentThreadState, ChatMessageStoreState
|
||||
from agent_framework.exceptions import AgentThreadException
|
||||
|
||||
|
||||
class MockChatMessageStore:
|
||||
"""Mock implementation of ChatMessageStoreProtocol for testing."""
|
||||
|
||||
def __init__(self, messages: list[Message] | None = None) -> None:
|
||||
self._messages = messages or []
|
||||
self._serialize_calls = 0
|
||||
self._deserialize_calls = 0
|
||||
|
||||
async def list_messages(self) -> list[Message]:
|
||||
return self._messages
|
||||
|
||||
async def add_messages(self, messages: Sequence[Message]) -> None:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> Any:
|
||||
self._serialize_calls += 1
|
||||
return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs}
|
||||
|
||||
async def update_from_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"]
|
||||
|
||||
@classmethod
|
||||
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "MockChatMessageStore":
|
||||
instance = cls()
|
||||
await instance.update_from_state(serialized_store_state, **kwargs)
|
||||
return instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_messages() -> list[Message]:
|
||||
"""Fixture providing sample chat messages for testing."""
|
||||
return [
|
||||
Message(role="user", text="Hello", message_id="msg1"),
|
||||
Message(role="assistant", text="Hi there!", message_id="msg2"),
|
||||
Message(role="user", text="How are you?", message_id="msg3"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message() -> Message:
|
||||
"""Fixture providing a single sample chat message for testing."""
|
||||
return Message(role="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 = ChatMessageStore()
|
||||
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 AgentThreadException."""
|
||||
store = ChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
with pytest.raises(AgentThreadException, 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 = ChatMessageStore()
|
||||
|
||||
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 AgentThreadException."""
|
||||
service_thread_id = "test-conversation-999"
|
||||
thread = AgentThread(service_thread_id=service_thread_id)
|
||||
store = ChatMessageStore()
|
||||
|
||||
with pytest.raises(AgentThreadException, 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[Message]) -> None:
|
||||
"""Test get_messages when message_store is set."""
|
||||
store = ChatMessageStore(sample_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
assert thread.message_store is not None
|
||||
|
||||
messages: list[Message] = await thread.message_store.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()
|
||||
|
||||
assert thread.message_store is None
|
||||
|
||||
async def test_on_new_messages_with_service_thread_id(self, sample_message: Message) -> 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(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: Message) -> None:
|
||||
"""Test _on_new_messages with single message creates ChatMessageStore."""
|
||||
thread = AgentThread()
|
||||
|
||||
await thread.on_new_messages(sample_message)
|
||||
|
||||
assert thread.message_store is not None
|
||||
assert isinstance(thread.message_store, ChatMessageStore)
|
||||
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[Message]) -> None:
|
||||
"""Test _on_new_messages with multiple messages."""
|
||||
thread = AgentThread()
|
||||
|
||||
await thread.on_new_messages(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: Message) -> None:
|
||||
"""Test _on_new_messages adds to existing message store."""
|
||||
initial_messages = [Message(role="user", text="Initial", message_id="init1")]
|
||||
store = ChatMessageStore(initial_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
await thread.on_new_messages(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."""
|
||||
serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None}
|
||||
|
||||
thread = await AgentThread.deserialize(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[Message]) -> None:
|
||||
"""Test _deserialize with chat_message_store_state."""
|
||||
store_state = {"messages": sample_messages}
|
||||
serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state}
|
||||
|
||||
thread = await AgentThread.deserialize(serialized_data)
|
||||
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is not None
|
||||
assert isinstance(thread.message_store, ChatMessageStore)
|
||||
|
||||
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 thread.deserialize(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": [Message(role="user", text="test")]},
|
||||
}
|
||||
|
||||
await thread.update_from_thread_state(serialized_data)
|
||||
|
||||
assert store._messages
|
||||
assert store._messages[0].text == "test"
|
||||
|
||||
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]
|
||||
|
||||
async def test_serialize_round_trip_messages(self, sample_messages: list[Message]) -> None:
|
||||
"""Test a roundtrip of the serialization."""
|
||||
store = ChatMessageStore(sample_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
new_thread = await AgentThread.deserialize(await thread.serialize())
|
||||
assert new_thread.message_store is not None
|
||||
new_messages = await new_thread.message_store.list_messages()
|
||||
assert len(new_messages) == len(sample_messages)
|
||||
assert {new.text for new in new_messages} == {orig.text for orig in sample_messages}
|
||||
|
||||
async def test_serialize_round_trip_thread_id(self) -> None:
|
||||
"""Test a roundtrip of the serialization."""
|
||||
thread = AgentThread(service_thread_id="test-1234")
|
||||
new_thread = await AgentThread.deserialize(await thread.serialize())
|
||||
assert new_thread.message_store is None
|
||||
assert new_thread.service_thread_id == "test-1234"
|
||||
|
||||
|
||||
class TestChatMessageList:
|
||||
"""Test cases for ChatMessageStore class."""
|
||||
|
||||
def test_init_empty(self) -> None:
|
||||
"""Test ChatMessageStore initialization with no messages."""
|
||||
store = ChatMessageStore()
|
||||
assert len(store.messages) == 0
|
||||
|
||||
def test_init_with_messages(self, sample_messages: list[Message]) -> None:
|
||||
"""Test ChatMessageStore initialization with messages."""
|
||||
store = ChatMessageStore(sample_messages)
|
||||
assert len(store.messages) == 3
|
||||
|
||||
async def test_add_messages(self, sample_messages: list[Message]) -> None:
|
||||
"""Test adding messages to the store."""
|
||||
store = ChatMessageStore()
|
||||
|
||||
await store.add_messages(sample_messages)
|
||||
|
||||
assert len(store.messages) == 3
|
||||
messages = await store.list_messages()
|
||||
assert messages[0].text == "Hello"
|
||||
|
||||
async def test_get_messages(self, sample_messages: list[Message]) -> None:
|
||||
"""Test getting messages from the store."""
|
||||
store = ChatMessageStore(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[Message]) -> None:
|
||||
"""Test serializing store state."""
|
||||
store = ChatMessageStore(sample_messages)
|
||||
|
||||
result = await store.serialize()
|
||||
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 3
|
||||
|
||||
async def test_serialize_state_empty(self) -> None:
|
||||
"""Test serializing empty store state."""
|
||||
store = ChatMessageStore()
|
||||
|
||||
result = await store.serialize()
|
||||
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 0
|
||||
|
||||
async def test_deserialize_state(self, sample_messages: list[Message]) -> None:
|
||||
"""Test deserializing store state."""
|
||||
store = ChatMessageStore()
|
||||
state_data = {"messages": sample_messages}
|
||||
|
||||
await store.update_from_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 = ChatMessageStore()
|
||||
|
||||
await store.update_from_state(None)
|
||||
|
||||
assert len(store.messages) == 0
|
||||
|
||||
async def test_deserialize_state_empty(self) -> None:
|
||||
"""Test deserializing empty state."""
|
||||
store = ChatMessageStore()
|
||||
|
||||
await store.update_from_state({})
|
||||
|
||||
assert len(store.messages) == 0
|
||||
|
||||
|
||||
class TestStoreState:
|
||||
"""Test cases for ChatMessageStoreState class."""
|
||||
|
||||
def test_init(self, sample_messages: list[Message]) -> None:
|
||||
"""Test ChatMessageStoreState initialization."""
|
||||
state = ChatMessageStoreState(messages=sample_messages)
|
||||
|
||||
assert len(state.messages) == 3
|
||||
assert state.messages[0].text == "Hello"
|
||||
|
||||
def test_init_empty(self) -> None:
|
||||
"""Test ChatMessageStoreState initialization with empty messages."""
|
||||
state = ChatMessageStoreState(messages=[])
|
||||
|
||||
assert len(state.messages) == 0
|
||||
|
||||
def test_init_none(self) -> None:
|
||||
"""Test ChatMessageStoreState initialization with None messages."""
|
||||
state = ChatMessageStoreState(messages=None)
|
||||
|
||||
assert len(state.messages) == 0
|
||||
|
||||
def test_init_no_messages_arg(self) -> None:
|
||||
"""Test ChatMessageStoreState initialization without messages argument."""
|
||||
state = ChatMessageStoreState()
|
||||
|
||||
assert len(state.messages) == 0
|
||||
|
||||
|
||||
class TestThreadState:
|
||||
"""Test cases for AgentThreadState class."""
|
||||
|
||||
def test_init_with_service_thread_id(self) -> None:
|
||||
"""Test AgentThreadState initialization with service_thread_id."""
|
||||
state = AgentThreadState(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 AgentThreadState initialization with chat_message_store_state."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
state = AgentThreadState.from_dict({"chat_message_store_state": store_data})
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state.messages == []
|
||||
|
||||
def test_init_with_both(self) -> None:
|
||||
"""Test AgentThreadState initialization with both parameters."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
with pytest.raises(AgentThreadException):
|
||||
AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data)
|
||||
|
||||
def test_init_defaults(self) -> None:
|
||||
"""Test AgentThreadState initialization with defaults."""
|
||||
state = AgentThreadState()
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state is None
|
||||
|
||||
def test_init_with_chat_message_store_state_no_messages(self) -> None:
|
||||
"""Test AgentThreadState initialization with chat_message_store_state without messages field.
|
||||
|
||||
This tests the scenario where a custom ChatMessageStore (like RedisChatMessageStore)
|
||||
serializes its state without a 'messages' field, containing only configuration data
|
||||
like thread_id, redis_url, etc.
|
||||
"""
|
||||
store_data: dict[str, Any] = {
|
||||
"type": "redis_store_state",
|
||||
"thread_id": "test_thread_123",
|
||||
"redis_url": "redis://localhost:6379",
|
||||
"key_prefix": "chat_messages",
|
||||
}
|
||||
state = AgentThreadState.from_dict({"chat_message_store_state": store_data})
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state is not None
|
||||
assert state.chat_message_store_state.messages == []
|
||||
|
||||
def test_init_with_chat_message_store_state_object(self) -> None:
|
||||
"""Test AgentThreadState initialization with ChatMessageStoreState object."""
|
||||
store_state = ChatMessageStoreState(messages=[Message(role="user", text="test")])
|
||||
state = AgentThreadState(chat_message_store_state=store_state)
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state is store_state
|
||||
assert len(state.chat_message_store_state.messages) == 1
|
||||
|
||||
def test_init_with_invalid_chat_message_store_state_type(self) -> None:
|
||||
"""Test AgentThreadState initialization with invalid chat_message_store_state type."""
|
||||
with pytest.raises(TypeError, match="Could not parse ChatMessageStoreState"):
|
||||
AgentThreadState(chat_message_store_state="invalid_type") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestChatMessageStoreStateEdgeCases:
|
||||
"""Additional edge case tests for ChatMessageStoreState."""
|
||||
|
||||
def test_init_with_invalid_messages_type(self) -> None:
|
||||
"""Test ChatMessageStoreState initialization with invalid messages type."""
|
||||
with pytest.raises(TypeError, match="Messages should be a list"):
|
||||
ChatMessageStoreState(messages="invalid") # type: ignore[arg-type]
|
||||
|
||||
def test_init_with_dict_messages(self) -> None:
|
||||
"""Test ChatMessageStoreState initialization with dict messages."""
|
||||
messages = [
|
||||
{"role": "user", "text": "Hello"},
|
||||
{"role": "assistant", "text": "Hi there!"},
|
||||
]
|
||||
state = ChatMessageStoreState(messages=messages)
|
||||
|
||||
assert len(state.messages) == 2
|
||||
assert isinstance(state.messages[0], Message)
|
||||
assert state.messages[0].text == "Hello"
|
||||
|
||||
|
||||
class TestChatMessageStoreEdgeCases:
|
||||
"""Additional edge case tests for ChatMessageStore."""
|
||||
|
||||
async def test_deserialize_class_method(self) -> None:
|
||||
"""Test ChatMessageStore.deserialize class method."""
|
||||
serialized_data = {
|
||||
"messages": [
|
||||
{"role": "user", "text": "Hello", "message_id": "msg1"},
|
||||
]
|
||||
}
|
||||
|
||||
store = await ChatMessageStore.deserialize(serialized_data)
|
||||
|
||||
assert isinstance(store, ChatMessageStore)
|
||||
messages = await store.list_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].text == "Hello"
|
||||
|
||||
async def test_deserialize_empty_state(self) -> None:
|
||||
"""Test ChatMessageStore.deserialize with empty state."""
|
||||
serialized_data: dict[str, Any] = {"messages": []}
|
||||
|
||||
store = await ChatMessageStore.deserialize(serialized_data)
|
||||
|
||||
assert isinstance(store, ChatMessageStore)
|
||||
messages = await store.list_messages()
|
||||
assert len(messages) == 0
|
||||
|
||||
|
||||
class TestAgentThreadEdgeCases:
|
||||
"""Additional edge case tests for AgentThread."""
|
||||
|
||||
def test_is_initialized_with_service_thread_id(self) -> None:
|
||||
"""Test is_initialized property when service_thread_id is set."""
|
||||
thread = AgentThread(service_thread_id="test-123")
|
||||
assert thread.is_initialized is True
|
||||
|
||||
def test_is_initialized_with_message_store(self) -> None:
|
||||
"""Test is_initialized property when message_store is set."""
|
||||
store = ChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
assert thread.is_initialized is True
|
||||
|
||||
def test_is_initialized_with_nothing(self) -> None:
|
||||
"""Test is_initialized property when nothing is set."""
|
||||
thread = AgentThread()
|
||||
assert thread.is_initialized is False
|
||||
|
||||
async def test_deserialize_with_custom_message_store(self) -> None:
|
||||
"""Test deserialize using a custom message store."""
|
||||
serialized_data = {
|
||||
"service_thread_id": None,
|
||||
"chat_message_store_state": {
|
||||
"messages": [{"role": "user", "text": "Hello"}],
|
||||
},
|
||||
}
|
||||
custom_store = MockChatMessageStore()
|
||||
|
||||
thread = await AgentThread.deserialize(serialized_data, message_store=custom_store)
|
||||
|
||||
assert thread.message_store is custom_store
|
||||
messages = await custom_store.list_messages()
|
||||
assert len(messages) == 1
|
||||
|
||||
async def test_deserialize_with_failing_message_store_raises(self) -> None:
|
||||
"""Test deserialize raises AgentThreadException when message store fails."""
|
||||
|
||||
class FailingStore:
|
||||
async def add_messages(self, messages: Sequence[Message], **kwargs: Any) -> None:
|
||||
raise RuntimeError("Store failed")
|
||||
|
||||
serialized_data = {
|
||||
"service_thread_id": None,
|
||||
"chat_message_store_state": {
|
||||
"messages": [{"role": "user", "text": "Hello"}],
|
||||
},
|
||||
}
|
||||
failing_store = FailingStore()
|
||||
|
||||
with pytest.raises(AgentThreadException, match="Failed to deserialize"):
|
||||
await AgentThread.deserialize(serialized_data, message_store=failing_store)
|
||||
|
||||
async def test_update_from_thread_state_with_service_thread_id(self) -> None:
|
||||
"""Test update_from_thread_state sets service_thread_id."""
|
||||
thread = AgentThread()
|
||||
serialized_data = {"service_thread_id": "new-thread-id"}
|
||||
|
||||
await thread.update_from_thread_state(serialized_data)
|
||||
|
||||
assert thread.service_thread_id == "new-thread-id"
|
||||
|
||||
async def test_update_from_thread_state_with_empty_chat_state(self) -> None:
|
||||
"""Test update_from_thread_state with empty chat_message_store_state."""
|
||||
thread = AgentThread()
|
||||
serialized_data = {"service_thread_id": None, "chat_message_store_state": None}
|
||||
|
||||
await thread.update_from_thread_state(serialized_data)
|
||||
|
||||
assert thread.message_store is None
|
||||
|
||||
async def test_update_from_thread_state_creates_message_store(self) -> None:
|
||||
"""Test update_from_thread_state creates message store if not existing."""
|
||||
thread = AgentThread()
|
||||
serialized_data = {
|
||||
"service_thread_id": None,
|
||||
"chat_message_store_state": {
|
||||
"messages": [{"role": "user", "text": "Hello"}],
|
||||
},
|
||||
}
|
||||
|
||||
await thread.update_from_thread_state(serialized_data)
|
||||
|
||||
assert thread.message_store is not None
|
||||
messages = await thread.message_store.list_messages()
|
||||
assert len(messages) == 1
|
||||
@@ -14,7 +14,7 @@ from agent_framework import (
|
||||
Agent,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
@@ -1264,70 +1264,70 @@ async def test_openai_assistants_agent_basic_run_streaming():
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_thread_persistence():
|
||||
"""Test Agent thread persistence across runs with OpenAIAssistantsClient."""
|
||||
async def test_openai_assistants_agent_session_persistence():
|
||||
"""Test Agent session persistence across runs with OpenAIAssistantsClient."""
|
||||
async with Agent(
|
||||
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent:
|
||||
# Create a new thread that will be reused
|
||||
thread = agent.get_new_thread()
|
||||
# Create a new session that will be reused
|
||||
session = agent.create_session()
|
||||
|
||||
# First message - establish context
|
||||
first_response = await agent.run(
|
||||
"Remember this number: 42. What number did I just tell you to remember?", thread=thread
|
||||
"Remember this number: 42. What number did I just tell you to remember?", session=session
|
||||
)
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert "42" in first_response.text
|
||||
|
||||
# Second message - test conversation memory
|
||||
second_response = await agent.run(
|
||||
"What number did I tell you to remember in my previous message?", thread=thread
|
||||
"What number did I tell you to remember in my previous message?", session=session
|
||||
)
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert "42" in second_response.text
|
||||
|
||||
# Verify thread has been populated with conversation ID
|
||||
assert thread.service_thread_id is not None
|
||||
# Verify session has been populated with conversation ID
|
||||
assert session.service_session_id is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_existing_thread_id():
|
||||
"""Test Agent with existing thread ID to continue conversations across agent instances."""
|
||||
# First, create a conversation and capture the thread ID
|
||||
existing_thread_id = None
|
||||
async def test_openai_assistants_agent_existing_session_id():
|
||||
"""Test Agent with existing session ID to continue conversations across agent instances."""
|
||||
# First, create a conversation and capture the session ID
|
||||
existing_session_id = None
|
||||
|
||||
async with Agent(
|
||||
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
) as agent:
|
||||
# Start a conversation and get the thread ID
|
||||
thread = agent.get_new_thread()
|
||||
response1 = await agent.run("What's the weather in Paris?", thread=thread)
|
||||
# Start a conversation and get the session ID
|
||||
session = agent.create_session()
|
||||
response1 = await agent.run("What's the weather in Paris?", session=session)
|
||||
|
||||
# Validate first response
|
||||
assert isinstance(response1, AgentResponse)
|
||||
assert response1.text is not None
|
||||
assert any(word in response1.text.lower() for word in ["weather", "paris"])
|
||||
|
||||
# The thread ID is set after the first response
|
||||
existing_thread_id = thread.service_thread_id
|
||||
assert existing_thread_id is not None
|
||||
# The session ID is set after the first response
|
||||
existing_session_id = session.service_session_id
|
||||
assert existing_session_id is not None
|
||||
|
||||
# Now continue with the same thread ID in a new agent instance
|
||||
# Now continue with the same session ID in a new agent instance
|
||||
|
||||
async with Agent(
|
||||
client=OpenAIAssistantsClient(thread_id=existing_thread_id),
|
||||
client=OpenAIAssistantsClient(thread_id=existing_session_id),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=[get_weather],
|
||||
) as agent:
|
||||
# Create a thread with the existing ID
|
||||
thread = AgentThread(service_thread_id=existing_thread_id)
|
||||
# Create a session with the existing ID
|
||||
session = AgentSession(service_session_id=existing_session_id)
|
||||
|
||||
# Ask about the previous conversation
|
||||
response2 = await agent.run("What was the last city I asked about?", thread=thread)
|
||||
response2 = await agent.run("What was the last city I asked about?", session=session)
|
||||
|
||||
# Validate that the agent remembers the previous conversation
|
||||
assert isinstance(response2, AgentResponse)
|
||||
|
||||
@@ -7,9 +7,8 @@ from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
ChatMessageStore,
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
@@ -21,7 +20,7 @@ from agent_framework.orchestrations import SequentialBuilder
|
||||
|
||||
|
||||
class _CountingAgent(BaseAgent):
|
||||
"""Agent that echoes messages with a counter to verify thread state persistence."""
|
||||
"""Agent that echoes messages with a counter to verify session state persistence."""
|
||||
|
||||
def __init__(self, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
@@ -32,7 +31,7 @@ class _CountingAgent(BaseAgent):
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
self.call_count += 1
|
||||
@@ -52,22 +51,22 @@ class _CountingAgent(BaseAgent):
|
||||
|
||||
|
||||
async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
"""Test that workflow checkpoint stores AgentExecutor's cache and thread states and restores them correctly."""
|
||||
"""Test that workflow checkpoint stores AgentExecutor's cache and session states and restores them correctly."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
# Create initial agent with a custom thread that has a message store
|
||||
# Create initial agent with a custom session
|
||||
initial_agent = _CountingAgent(id="test_agent", name="TestAgent")
|
||||
initial_thread = AgentThread(message_store=ChatMessageStore())
|
||||
initial_session = AgentSession()
|
||||
|
||||
# Add some initial messages to the thread to verify thread state persistence
|
||||
# Add some initial messages to the session state to verify session state persistence
|
||||
initial_messages = [
|
||||
Message(role="user", text="Initial message 1"),
|
||||
Message(role="assistant", text="Initial response 1"),
|
||||
]
|
||||
await initial_thread.on_new_messages(initial_messages)
|
||||
initial_session.state["history"] = {"messages": initial_messages}
|
||||
|
||||
# Create AgentExecutor with the thread
|
||||
executor = AgentExecutor(initial_agent, agent_thread=initial_thread)
|
||||
# Create AgentExecutor with the session
|
||||
executor = AgentExecutor(initial_agent, session=initial_session)
|
||||
|
||||
# Build workflow with checkpointing enabled
|
||||
wf = SequentialBuilder(participants=[executor], checkpoint_storage=storage).build()
|
||||
@@ -95,7 +94,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
restore_checkpoint = checkpoints[1]
|
||||
|
||||
# Verify checkpoint contains executor state with both cache and thread
|
||||
# Verify checkpoint contains executor state with both cache and session
|
||||
assert "_executor_state" in restore_checkpoint.state
|
||||
executor_states = restore_checkpoint.state["_executor_state"]
|
||||
assert isinstance(executor_states, dict)
|
||||
@@ -103,13 +102,12 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
|
||||
executor_state = executor_states[executor.id] # type: ignore[index]
|
||||
assert "cache" in executor_state, "Checkpoint should store executor cache state"
|
||||
assert "agent_thread" in executor_state, "Checkpoint should store executor thread state"
|
||||
assert "agent_session" in executor_state, "Checkpoint should store executor session state"
|
||||
|
||||
# Verify thread state includes message store
|
||||
thread_state = executor_state["agent_thread"] # type: ignore[index]
|
||||
assert "chat_message_store_state" in thread_state, "Thread state should include message store"
|
||||
chat_store_state = thread_state["chat_message_store_state"] # type: ignore[index]
|
||||
assert "messages" in chat_store_state, "Message store state should include messages"
|
||||
# Verify session state structure
|
||||
session_state = executor_state["agent_session"] # type: ignore[index]
|
||||
assert "session_id" in session_state, "Session state should include session_id"
|
||||
assert "state" in session_state, "Session state should include state dict"
|
||||
|
||||
# Verify checkpoint contains pending requests from agents and responses to be sent
|
||||
assert "pending_agent_requests" in executor_state
|
||||
@@ -118,8 +116,8 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
# Create a new agent and executor for restoration
|
||||
# This simulates starting from a fresh state and restoring from checkpoint
|
||||
restored_agent = _CountingAgent(id="test_agent", name="TestAgent")
|
||||
restored_thread = AgentThread(message_store=ChatMessageStore())
|
||||
restored_executor = AgentExecutor(restored_agent, agent_thread=restored_thread)
|
||||
restored_session = AgentSession()
|
||||
restored_executor = AgentExecutor(restored_agent, session=restored_session)
|
||||
|
||||
# Verify the restored agent starts with a fresh state
|
||||
assert restored_agent.call_count == 0
|
||||
@@ -140,39 +138,27 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
|
||||
|
||||
assert resumed_output is not None
|
||||
|
||||
# Verify the restored executor's state matches the original
|
||||
# The cache should be restored (though it may be cleared after processing)
|
||||
# The thread should have all messages including those from the initial state
|
||||
message_store = restored_executor._agent_thread.message_store # type: ignore[reportPrivateUsage]
|
||||
assert message_store is not None
|
||||
thread_messages = await message_store.list_messages()
|
||||
|
||||
# Thread should contain:
|
||||
# 1. Initial messages from before the checkpoint (2 messages)
|
||||
# 2. User message from first run (1 message)
|
||||
# 3. Assistant response from first run (1 message)
|
||||
assert len(thread_messages) >= 2, "Thread should preserve initial messages from before checkpoint"
|
||||
|
||||
# Verify initial messages are preserved
|
||||
assert thread_messages[0].text == "Initial message 1"
|
||||
assert thread_messages[1].text == "Initial response 1"
|
||||
# Verify the restored executor's session state was restored
|
||||
restored_session_obj = restored_executor._session # type: ignore[reportPrivateUsage]
|
||||
assert restored_session_obj is not None
|
||||
assert restored_session_obj.session_id == initial_session.session_id
|
||||
|
||||
|
||||
async def test_agent_executor_save_and_restore_state_directly() -> None:
|
||||
"""Test AgentExecutor's on_checkpoint_save and on_checkpoint_restore methods directly."""
|
||||
# Create agent with thread containing messages
|
||||
# Create agent with session containing state
|
||||
agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent")
|
||||
thread = AgentThread(message_store=ChatMessageStore())
|
||||
session = AgentSession()
|
||||
|
||||
# Add messages to thread
|
||||
thread_messages = [
|
||||
Message(role="user", text="Message in thread 1"),
|
||||
Message(role="assistant", text="Thread response 1"),
|
||||
Message(role="user", text="Message in thread 2"),
|
||||
# Add messages to session state
|
||||
session_messages = [
|
||||
Message(role="user", text="Message in session 1"),
|
||||
Message(role="assistant", text="Session response 1"),
|
||||
Message(role="user", text="Message in session 2"),
|
||||
]
|
||||
await thread.on_new_messages(thread_messages)
|
||||
session.state["history"] = {"messages": session_messages}
|
||||
|
||||
executor = AgentExecutor(agent, agent_thread=thread)
|
||||
executor = AgentExecutor(agent, session=session)
|
||||
|
||||
# Add messages to executor cache
|
||||
cache_messages = [
|
||||
@@ -184,26 +170,23 @@ async def test_agent_executor_save_and_restore_state_directly() -> None:
|
||||
# Snapshot the state
|
||||
state = await executor.on_checkpoint_save()
|
||||
|
||||
# Verify snapshot contains both cache and thread
|
||||
# Verify snapshot contains both cache and session
|
||||
assert "cache" in state
|
||||
assert "agent_thread" in state
|
||||
assert "agent_session" in state
|
||||
|
||||
# Verify thread state structure
|
||||
thread_state = state["agent_thread"] # type: ignore[index]
|
||||
assert "chat_message_store_state" in thread_state
|
||||
assert "messages" in thread_state["chat_message_store_state"]
|
||||
# Verify session state structure
|
||||
session_state = state["agent_session"] # type: ignore[index]
|
||||
assert "session_id" in session_state
|
||||
assert "state" in session_state
|
||||
|
||||
# Create new executor to restore into
|
||||
new_agent = _CountingAgent(id="direct_test_agent", name="DirectTestAgent")
|
||||
new_thread = AgentThread(message_store=ChatMessageStore())
|
||||
new_executor = AgentExecutor(new_agent, agent_thread=new_thread)
|
||||
new_session = AgentSession()
|
||||
new_executor = AgentExecutor(new_agent, session=new_session)
|
||||
|
||||
# Verify new executor starts empty
|
||||
assert len(new_executor._cache) == 0 # type: ignore[reportPrivateUsage]
|
||||
initial_message_store = new_thread.message_store
|
||||
assert initial_message_store is not None
|
||||
initial_thread_msgs = await initial_message_store.list_messages()
|
||||
assert len(initial_thread_msgs) == 0
|
||||
assert len(new_session.state) == 0
|
||||
|
||||
# Restore state
|
||||
await new_executor.on_checkpoint_restore(state)
|
||||
@@ -214,11 +197,6 @@ async def test_agent_executor_save_and_restore_state_directly() -> None:
|
||||
assert restored_cache[0].text == "Cached user message"
|
||||
assert restored_cache[1].text == "Cached assistant response"
|
||||
|
||||
# Verify thread messages are restored
|
||||
restored_message_store = new_executor._agent_thread.message_store # type: ignore[reportPrivateUsage]
|
||||
assert restored_message_store is not None
|
||||
restored_thread_msgs = await restored_message_store.list_messages()
|
||||
assert len(restored_thread_msgs) == len(thread_messages)
|
||||
assert restored_thread_msgs[0].text == "Message in thread 1"
|
||||
assert restored_thread_msgs[1].text == "Thread response 1"
|
||||
assert restored_thread_msgs[2].text == "Message in thread 2"
|
||||
# Verify session was restored with correct session_id
|
||||
restored_session = new_executor._session # type: ignore[reportPrivateUsage]
|
||||
assert restored_session.session_id == session.session_id
|
||||
|
||||
@@ -13,7 +13,7 @@ from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
@@ -42,7 +42,7 @@ class _ToolCallingAgent(BaseAgent):
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, AgentThread, Message
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, AgentSession, Message
|
||||
from agent_framework._workflows._agent_utils import resolve_agent_id
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ class MockAgent:
|
||||
messages: str | Message | list[str] | list[Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse | AsyncIterable[AgentResponseUpdate]: ...
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Creates a new conversation thread for the agent."""
|
||||
def create_session(self, **kwargs: Any) -> AgentSession:
|
||||
"""Creates a new conversation session for the agent."""
|
||||
...
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
Content,
|
||||
Executor,
|
||||
@@ -38,7 +38,7 @@ class _SimpleAgent(BaseAgent):
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
@@ -108,7 +108,7 @@ class _CaptureAgent(BaseAgent):
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
# Normalize and record messages for verification
|
||||
|
||||
@@ -13,7 +13,7 @@ from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
Content,
|
||||
Executor,
|
||||
@@ -838,7 +838,7 @@ class _StreamingTestAgent(BaseAgent):
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
|
||||
@@ -11,8 +11,7 @@ from agent_framework import (
|
||||
AgentExecutorRequest,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
ChatMessageStore,
|
||||
AgentSession,
|
||||
Content,
|
||||
Executor,
|
||||
Message,
|
||||
@@ -511,80 +510,53 @@ class TestWorkflowAgent:
|
||||
texts = [message.text for message in result.messages]
|
||||
assert texts == ["first message", "second message", "third fourth"]
|
||||
|
||||
async def test_thread_conversation_history_included_in_workflow_run(self) -> None:
|
||||
"""Test that conversation history from thread is included when running WorkflowAgent.
|
||||
|
||||
This verifies that when a thread with existing messages is provided to agent.run(),
|
||||
the workflow receives the complete conversation history (thread history + new messages).
|
||||
"""
|
||||
async def test_session_conversation_history_included_in_workflow_run(self) -> None:
|
||||
"""Test that messages provided to agent.run() are passed through to the workflow."""
|
||||
# Create an executor that captures all received messages
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing", streaming=False)
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Thread History Test Agent")
|
||||
agent = WorkflowAgent(workflow=workflow, name="Session History Test Agent")
|
||||
|
||||
# Create a thread with existing conversation history
|
||||
history_messages = [
|
||||
Message(role="user", text="Previous user message"),
|
||||
Message(role="assistant", text="Previous assistant response"),
|
||||
]
|
||||
message_store = ChatMessageStore(messages=history_messages)
|
||||
thread = AgentThread(message_store=message_store)
|
||||
# Create a session
|
||||
session = AgentSession()
|
||||
|
||||
# Run the agent with the thread and a new message
|
||||
# Run the agent with the session and a new message
|
||||
new_message = "New user question"
|
||||
await agent.run(new_message, thread=thread)
|
||||
await agent.run(new_message, session=session)
|
||||
|
||||
# Verify the executor received both history AND new message
|
||||
assert len(capturing_executor.received_messages) == 3
|
||||
# Verify the executor received the message
|
||||
assert len(capturing_executor.received_messages) == 1
|
||||
assert capturing_executor.received_messages[0].text == "New user question"
|
||||
|
||||
# Verify the order: history first, then new message
|
||||
assert capturing_executor.received_messages[0].text == "Previous user message"
|
||||
assert capturing_executor.received_messages[1].text == "Previous assistant response"
|
||||
assert capturing_executor.received_messages[2].text == "New user question"
|
||||
|
||||
async def test_thread_conversation_history_included_in_workflow_stream(self) -> None:
|
||||
"""Test that conversation history from thread is included when streaming WorkflowAgent.
|
||||
|
||||
This verifies that stream=True also includes thread history.
|
||||
"""
|
||||
async def test_session_conversation_history_included_in_workflow_stream(self) -> None:
|
||||
"""Test that messages provided to agent.run() are passed through when streaming WorkflowAgent."""
|
||||
# Create an executor that captures all received messages
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="capturing_stream")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Thread Stream Test Agent")
|
||||
agent = WorkflowAgent(workflow=workflow, name="Session Stream Test Agent")
|
||||
|
||||
# Create a thread with existing conversation history
|
||||
history_messages = [
|
||||
Message(role="system", text="You are a helpful assistant"),
|
||||
Message(role="user", text="Hello"),
|
||||
Message("assistant", ["Hi there!"]),
|
||||
]
|
||||
message_store = ChatMessageStore(messages=history_messages)
|
||||
thread = AgentThread(message_store=message_store)
|
||||
# Create a session
|
||||
session = AgentSession()
|
||||
|
||||
# Stream from the agent with the thread and a new message
|
||||
async for _ in agent.run("How are you?", stream=True, thread=thread):
|
||||
# Stream from the agent with the session and a new message
|
||||
async for _ in agent.run("How are you?", stream=True, session=session):
|
||||
pass
|
||||
|
||||
# Verify the executor received all messages (3 from history + 1 new)
|
||||
assert len(capturing_executor.received_messages) == 4
|
||||
# Verify the executor received the message
|
||||
assert len(capturing_executor.received_messages) == 1
|
||||
assert capturing_executor.received_messages[0].text == "How are you?"
|
||||
|
||||
# Verify the order
|
||||
assert capturing_executor.received_messages[0].text == "You are a helpful assistant"
|
||||
assert capturing_executor.received_messages[1].text == "Hello"
|
||||
assert capturing_executor.received_messages[2].text == "Hi there!"
|
||||
assert capturing_executor.received_messages[3].text == "How are you?"
|
||||
|
||||
async def test_empty_thread_works_correctly(self) -> None:
|
||||
"""Test that an empty thread (no message store) works correctly."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="empty_thread_test")
|
||||
async def test_empty_session_works_correctly(self) -> None:
|
||||
"""Test that an empty session (no message store) works correctly."""
|
||||
capturing_executor = ConversationHistoryCapturingExecutor(id="empty_session_test")
|
||||
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
|
||||
agent = WorkflowAgent(workflow=workflow, name="Empty Thread Test Agent")
|
||||
agent = WorkflowAgent(workflow=workflow, name="Empty Session Test Agent")
|
||||
|
||||
# Create an empty thread
|
||||
thread = AgentThread()
|
||||
# Create an empty session
|
||||
session = AgentSession()
|
||||
|
||||
# Run with the empty thread
|
||||
await agent.run("Just a new message", thread=thread)
|
||||
# Run with the empty session
|
||||
await agent.run("Just a new message", session=session)
|
||||
|
||||
# Should only receive the new message
|
||||
assert len(capturing_executor.received_messages) == 1
|
||||
@@ -622,27 +594,27 @@ class TestWorkflowAgent:
|
||||
self.description: str | None = None
|
||||
self._response_text = response_text
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
return AgentThread()
|
||||
def create_session(self, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
return self._run_stream(messages=messages, thread=thread, **kwargs)
|
||||
return self._run(messages=messages, thread=thread, **kwargs)
|
||||
return self._run_stream(messages=messages, session=session, **kwargs)
|
||||
return self._run(messages=messages, session=session, **kwargs)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
|
||||
@@ -654,7 +626,7 @@ class TestWorkflowAgent:
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
async def _iter():
|
||||
@@ -710,27 +682,27 @@ class TestWorkflowAgent:
|
||||
self.description: str | None = None
|
||||
self._response_text = response_text
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
return AgentThread()
|
||||
def create_session(self, **kwargs: Any) -> AgentSession:
|
||||
return AgentSession()
|
||||
|
||||
def run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
if stream:
|
||||
return self._run_stream(messages=messages, thread=thread, **kwargs)
|
||||
return self._run(messages=messages, thread=thread, **kwargs)
|
||||
return self._run_stream(messages=messages, session=session, **kwargs)
|
||||
return self._run(messages=messages, session=session, **kwargs)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse:
|
||||
|
||||
@@ -742,7 +714,7 @@ class TestWorkflowAgent:
|
||||
self,
|
||||
messages: str | Content | Message | Sequence[str | Content | Message] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
async def _iter():
|
||||
@@ -1037,7 +1009,7 @@ class TestWorkflowAgentMergeUpdates:
|
||||
def test_merge_updates_function_result_ordering_github_2977(self):
|
||||
"""Test that FunctionResultContent updates are placed after their FunctionCallContent.
|
||||
|
||||
This test reproduces GitHub issue #2977: When using a thread with WorkflowAgent,
|
||||
This test reproduces GitHub issue #2977: When using a session with WorkflowAgent,
|
||||
FunctionResultContent updates without response_id were being added to global_dangling
|
||||
and placed at the end of messages. This caused OpenAI to reject the conversation because
|
||||
"An assistant message with 'tool_calls' must be followed by tool messages responding
|
||||
|
||||
@@ -9,7 +9,7 @@ from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
Executor,
|
||||
Message,
|
||||
@@ -21,7 +21,7 @@ from agent_framework import (
|
||||
|
||||
|
||||
class DummyAgent(BaseAgent):
|
||||
def run(self, messages=None, *, stream: bool = False, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
|
||||
def run(self, messages=None, *, stream: bool = False, session: AgentSession | None = None, **kwargs): # type: ignore[override]
|
||||
if stream:
|
||||
return self._run_stream_impl()
|
||||
return self._run_impl(messages)
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
Content,
|
||||
Message,
|
||||
@@ -55,7 +55,7 @@ class _KwargsCapturingAgent(BaseAgent):
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
self.captured_kwargs.append(dict(kwargs))
|
||||
@@ -88,7 +88,7 @@ class _OptionsAwareAgent(BaseAgent):
|
||||
messages: str | Message | Sequence[str | Message] | None = None,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
session: AgentSession | None = None,
|
||||
options: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
|
||||
|
||||
Reference in New Issue
Block a user