mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] cleanup of thread API and serialization (#893)
* cleanup of threads and serialization * fix for sliding window * fix redis test * updated from comments * updated context provider and threads * updated lock * add asyncio default * fix redis tests * fix tests * fix tests * renamed to invoking * fixed tests * fix for instructions
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, MutableMapping, MutableSequence
|
||||
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
|
||||
from typing import Any, ClassVar, TypeVar
|
||||
|
||||
from agent_framework import (
|
||||
@@ -269,7 +269,8 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
return await ChatResponse.from_chat_response_generator(
|
||||
updates=self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs)
|
||||
updates=self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs),
|
||||
output_format_type=chat_options.response_format,
|
||||
)
|
||||
|
||||
async def _inner_get_streaming_response(
|
||||
@@ -660,7 +661,7 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
)
|
||||
)
|
||||
|
||||
instructions: list[str] = []
|
||||
instructions: list[str] = [chat_options.instructions] if chat_options and chat_options.instructions else []
|
||||
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None = None
|
||||
|
||||
additional_messages: list[ThreadMessageOptions] | None = None
|
||||
@@ -708,7 +709,7 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
return run_options, required_action_results
|
||||
|
||||
async def _prep_tools(
|
||||
self, tools: list["ToolProtocol | MutableMapping[str, Any]"]
|
||||
self, tools: Sequence["ToolProtocol | MutableMapping[str, Any]"]
|
||||
) -> list[ToolDefinition | dict[str, Any]]:
|
||||
"""Prepare tool definitions for the run options."""
|
||||
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
|
||||
|
||||
@@ -4,11 +4,14 @@ from collections.abc import AsyncIterable
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from agent_framework import (
|
||||
AgentMiddlewares,
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
AggregateContextProvider,
|
||||
BaseAgent,
|
||||
ChatMessage,
|
||||
ContextProvider,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
@@ -53,20 +56,16 @@ class CopilotStudioSettings(AFBaseSettings):
|
||||
class CopilotStudioAgent(BaseAgent):
|
||||
"""A Copilot Studio Agent."""
|
||||
|
||||
client: CopilotClient
|
||||
settings: ConnectionSettings | None
|
||||
token: str | None
|
||||
cloud: PowerPlatformCloud | None
|
||||
agent_type: AgentType | None
|
||||
custom_power_platform_cloud: str | None
|
||||
username: str | None
|
||||
token_cache: Any | None
|
||||
scopes: list[str] | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: CopilotClient | None = None,
|
||||
settings: ConnectionSettings | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
|
||||
middleware: AgentMiddlewares | list[AgentMiddlewares] | None = None,
|
||||
environment_id: str | None = None,
|
||||
agent_identifier: str | None = None,
|
||||
client_id: str | None = None,
|
||||
@@ -88,6 +87,11 @@ class CopilotStudioAgent(BaseAgent):
|
||||
a new client will be created using the other parameters.
|
||||
settings: Optional pre-configured ConnectionSettings. If not provided,
|
||||
settings will be created from the other parameters.
|
||||
id: id of the CopilotAgent
|
||||
name: Name of the CopilotAgent
|
||||
description: Description of the CopilotAgent
|
||||
context_providers: Context Providers, to be used by the copilot agent.
|
||||
middleware: Agent middlewares used by the agent.
|
||||
environment_id: Environment ID of the Power Platform environment containing
|
||||
the Copilot Studio app. Can also be set via COPILOTSTUDIOAGENT__ENVIRONMENTID
|
||||
environment variable.
|
||||
@@ -113,6 +117,13 @@ class CopilotStudioAgent(BaseAgent):
|
||||
Raises:
|
||||
ServiceInitializationError: If required configuration is missing or invalid.
|
||||
"""
|
||||
super().__init__(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
context_providers=context_providers,
|
||||
middleware=middleware,
|
||||
)
|
||||
if not client:
|
||||
try:
|
||||
copilot_studio_settings = CopilotStudioSettings(
|
||||
@@ -169,17 +180,13 @@ class CopilotStudioAgent(BaseAgent):
|
||||
|
||||
client = CopilotClient(settings=settings, token=token)
|
||||
|
||||
super().__init__(
|
||||
client=client, # type: ignore[reportCallIssue]
|
||||
settings=settings, # type: ignore[reportCallIssue]
|
||||
token=token, # type: ignore[reportCallIssue]
|
||||
cloud=cloud, # type: ignore[reportCallIssue]
|
||||
agent_type=agent_type, # type: ignore[reportCallIssue]
|
||||
custom_power_platform_cloud=custom_power_platform_cloud, # type: ignore[reportCallIssue]
|
||||
username=username, # type: ignore[reportCallIssue]
|
||||
token_cache=token_cache, # type: ignore[reportCallIssue]
|
||||
scopes=scopes, # type: ignore[reportCallIssue]
|
||||
)
|
||||
self.client = client
|
||||
self.cloud = cloud
|
||||
self.agent_type = agent_type
|
||||
self.custom_power_platform_cloud = custom_power_platform_cloud
|
||||
self.username = username
|
||||
self.token_cache = token_cache
|
||||
self.scopes = scopes
|
||||
|
||||
async def run(
|
||||
self,
|
||||
|
||||
-10
@@ -121,7 +121,6 @@ class TestCopilotStudioAgent:
|
||||
with pytest.raises(ServiceInitializationError, match="agent identifier"):
|
||||
CopilotStudioAgent()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_string_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with string message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -141,7 +140,6 @@ class TestCopilotStudioAgent:
|
||||
assert content.text == "Test response"
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_chat_message(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with ChatMessage."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -162,7 +160,6 @@ class TestCopilotStudioAgent:
|
||||
assert content.text == "Test response"
|
||||
assert response.messages[0].role == Role.ASSISTANT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_with_thread(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with existing thread."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -180,7 +177,6 @@ class TestCopilotStudioAgent:
|
||||
assert len(response.messages) == 1
|
||||
assert thread.service_thread_id == "test-conversation-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run method when conversation start fails."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -190,7 +186,6 @@ class TestCopilotStudioAgent:
|
||||
with pytest.raises(ServiceException, match="Failed to start a new conversation"):
|
||||
await agent.run("test message")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_with_string_message(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method with string message."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -217,7 +212,6 @@ class TestCopilotStudioAgent:
|
||||
|
||||
assert response_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_with_thread(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method with existing thread."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -246,7 +240,6 @@ class TestCopilotStudioAgent:
|
||||
assert response_count == 1
|
||||
assert thread.service_thread_id == "test-conversation-id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_no_typing_activity(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method with non-typing activity."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -268,7 +261,6 @@ class TestCopilotStudioAgent:
|
||||
|
||||
assert response_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_multiple_activities(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run method with multiple message activities."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -296,7 +288,6 @@ class TestCopilotStudioAgent:
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_list_of_messages(self, mock_copilot_client: MagicMock, mock_activity: MagicMock) -> None:
|
||||
"""Test run method with list of messages."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -313,7 +304,6 @@ class TestCopilotStudioAgent:
|
||||
assert isinstance(response, AgentRunResponse)
|
||||
assert len(response.messages) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_stream_start_conversation_failure(self, mock_copilot_client: MagicMock) -> None:
|
||||
"""Test run_stream method when conversation start fails."""
|
||||
agent = CopilotStudioAgent(client=mock_copilot_client)
|
||||
@@ -227,17 +227,9 @@ class AgentFrameworkExecutor:
|
||||
async def deserialize_thread(self, thread_id: str, agent_id: str, serialized_state: dict[str, Any]) -> bool:
|
||||
"""Deserialize thread state from persistence."""
|
||||
try:
|
||||
# Create new thread
|
||||
thread = AgentThread()
|
||||
|
||||
# Use AgentThread's built-in deserialization
|
||||
from agent_framework._threads import deserialize_thread_state
|
||||
|
||||
await deserialize_thread_state(thread, serialized_state)
|
||||
|
||||
thread = await AgentThread.deserialize(serialized_state)
|
||||
# Store the restored thread
|
||||
self.thread_storage[thread_id] = thread
|
||||
|
||||
if agent_id not in self.agent_threads:
|
||||
self.agent_threads[agent_id] = []
|
||||
self.agent_threads[agent_id].append(thread_id)
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_entities_dir():
|
||||
return str(samples_dir.resolve())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip("Skipping while we fix discovery")
|
||||
async def test_discover_agents(test_entities_dir):
|
||||
"""Test that agent discovery works and returns valid agent entities."""
|
||||
discovery = EntityDiscovery(test_entities_dir)
|
||||
@@ -39,7 +39,6 @@ async def test_discover_agents(test_entities_dir):
|
||||
assert hasattr(agent, "description"), "Agent should have description attribute"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_workflows(test_entities_dir):
|
||||
"""Test that workflow discovery works and returns valid workflow entities."""
|
||||
discovery = EntityDiscovery(test_entities_dir)
|
||||
@@ -58,7 +57,6 @@ async def test_discover_workflows(test_entities_dir):
|
||||
assert hasattr(workflow, "description"), "Workflow should have description attribute"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_directory():
|
||||
"""Test discovery with empty directory."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
|
||||
@@ -36,7 +36,6 @@ async def executor(test_entities_dir):
|
||||
return executor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_entity_discovery(executor):
|
||||
"""Test executor entity discovery."""
|
||||
entities = await executor.discover_entities()
|
||||
@@ -55,7 +54,6 @@ async def test_executor_entity_discovery(executor):
|
||||
assert entity.type in ["agent", "workflow"], "Entity should have valid type"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_get_entity_info(executor):
|
||||
"""Test getting entity info by ID."""
|
||||
entities = await executor.discover_entities()
|
||||
@@ -68,7 +66,6 @@ async def test_executor_get_entity_info(executor):
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="requires OpenAI API key")
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_sync_execution(executor):
|
||||
"""Test synchronous execution."""
|
||||
entities = await executor.discover_entities()
|
||||
@@ -90,7 +87,7 @@ async def test_executor_sync_execution(executor):
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="requires OpenAI API key")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip("Skipping while we fix discovery")
|
||||
async def test_executor_streaming_execution(executor):
|
||||
"""Test streaming execution."""
|
||||
entities = await executor.discover_entities()
|
||||
@@ -121,14 +118,12 @@ async def test_executor_streaming_execution(executor):
|
||||
assert len(text_events) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_invalid_entity_id(executor):
|
||||
"""Test execution with invalid entity ID."""
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
executor.get_entity_info("nonexistent_agent")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_missing_entity_id(executor):
|
||||
"""Test execution without entity ID."""
|
||||
request = AgentFrameworkRequest(
|
||||
|
||||
@@ -56,7 +56,6 @@ def test_request() -> AgentFrameworkRequest:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_isinstance_bug_detection(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""CRITICAL: Test that would have caught the isinstance vs hasattr bug."""
|
||||
|
||||
@@ -79,7 +78,6 @@ async def test_critical_isinstance_bug_detection(mapper: MessageMapper, test_req
|
||||
assert all(event.type != "unknown" for event in events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_content_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test TextContent mapping."""
|
||||
content = create_test_content("text", text="Hello, clean test!")
|
||||
@@ -92,7 +90,6 @@ async def test_text_content_mapping(mapper: MessageMapper, test_request: AgentFr
|
||||
assert events[0].delta == "Hello, clean test!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test FunctionCallContent mapping."""
|
||||
content = create_test_content("function_call", name="test_func", arguments={"location": "TestCity"})
|
||||
@@ -108,7 +105,6 @@ async def test_function_call_mapping(mapper: MessageMapper, test_request: AgentF
|
||||
assert "TestCity" in full_json
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_content_mapping(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test ErrorContent mapping."""
|
||||
content = create_test_content("error", message="Test error", code="test_code")
|
||||
@@ -122,7 +118,6 @@ async def test_error_content_mapping(mapper: MessageMapper, test_request: AgentF
|
||||
assert events[0].code == "test_code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_content_types(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test multiple content types together."""
|
||||
contents = [
|
||||
@@ -142,7 +137,6 @@ async def test_mixed_content_types(mapper: MessageMapper, test_request: AgentFra
|
||||
assert "response.function_call_arguments.delta" in event_types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_content_fallback(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
|
||||
"""Test graceful handling of unknown content types."""
|
||||
# Test the fallback path directly since we can't create invalid AgentRunResponseUpdate
|
||||
|
||||
@@ -20,7 +20,6 @@ def test_entities_dir():
|
||||
return str(samples_dir.resolve())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_health_endpoint(test_entities_dir):
|
||||
"""Test /health endpoint."""
|
||||
server = DevServer(entities_dir=test_entities_dir)
|
||||
@@ -32,7 +31,7 @@ async def test_server_health_endpoint(test_entities_dir):
|
||||
# Framework name is now hardcoded since we simplified to single framework
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip("Skipping while we fix discovery")
|
||||
async def test_server_entities_endpoint(test_entities_dir):
|
||||
"""Test /v1/entities endpoint."""
|
||||
server = DevServer(entities_dir=test_entities_dir)
|
||||
@@ -47,7 +46,6 @@ async def test_server_entities_endpoint(test_entities_dir):
|
||||
assert "WeatherAgent" in agent_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_execution_sync(test_entities_dir):
|
||||
"""Test sync execution endpoint."""
|
||||
server = DevServer(entities_dir=test_entities_dir)
|
||||
@@ -68,7 +66,6 @@ async def test_server_execution_sync(test_entities_dir):
|
||||
assert len(response.output) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_execution_streaming(test_entities_dir):
|
||||
"""Test streaming execution endpoint."""
|
||||
server = DevServer(entities_dir=test_entities_dir)
|
||||
|
||||
@@ -130,7 +130,9 @@ test-tau2 = "pytest tau2/tests --cov=agent_framework_lab_tau2 --cov-report=term-
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
addopts = "--strict-markers --strict-config"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
markers = [
|
||||
"unit: marks tests as unit tests",
|
||||
"integration: marks tests as integration tests",
|
||||
]
|
||||
]
|
||||
|
||||
@@ -5,13 +5,12 @@ from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import tiktoken
|
||||
from agent_framework._threads import ChatMessageList
|
||||
from agent_framework._types import ChatMessage, Role
|
||||
from agent_framework import ChatMessage, ChatMessageStore, Role
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class SlidingWindowChatMessageList(ChatMessageList):
|
||||
"""A token-aware sliding window implementation of ChatMessageList.
|
||||
class SlidingWindowChatMessageStore(ChatMessageStore):
|
||||
"""A token-aware sliding window implementation of ChatMessageStore.
|
||||
|
||||
Maintains two message lists: complete history and truncated window.
|
||||
Automatically removes oldest messages when token limit is exceeded.
|
||||
@@ -25,8 +24,8 @@ class SlidingWindowChatMessageList(ChatMessageList):
|
||||
system_message: str | None = None,
|
||||
tool_definitions: Any | None = None,
|
||||
):
|
||||
super().__init__(messages)
|
||||
self._truncated_messages = self._messages.copy() # Separate truncated view
|
||||
super().__init__(messages=messages)
|
||||
self.truncated_messages = self.messages.copy()
|
||||
self.max_tokens = max_tokens
|
||||
self.system_message = system_message # Included in token count
|
||||
self.tool_definitions = tool_definitions
|
||||
@@ -36,25 +35,25 @@ class SlidingWindowChatMessageList(ChatMessageList):
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
await super().add_messages(messages)
|
||||
|
||||
self._truncated_messages = self._messages.copy()
|
||||
self.truncated_messages = self.messages.copy()
|
||||
self.truncate_messages()
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Get the current list of messages, which may be truncated."""
|
||||
return self._truncated_messages
|
||||
return self.truncated_messages
|
||||
|
||||
async def list_all_messages(self) -> list[ChatMessage]:
|
||||
"""Get all messages from the store including the truncated ones."""
|
||||
return self._messages
|
||||
return self.messages
|
||||
|
||||
def truncate_messages(self) -> None:
|
||||
while len(self._truncated_messages) > 0 and self.get_token_count() > self.max_tokens:
|
||||
while len(self.truncated_messages) > 0 and self.get_token_count() > self.max_tokens:
|
||||
logger.warning("Messages exceed max tokens. Truncating oldest message.")
|
||||
self._truncated_messages.pop(0)
|
||||
self.truncated_messages.pop(0)
|
||||
# Remove leading tool messages
|
||||
while len(self._truncated_messages) > 0 and self._truncated_messages[0].role == Role.TOOL:
|
||||
while len(self.truncated_messages) > 0 and self.truncated_messages[0].role == Role.TOOL:
|
||||
logger.warning("Removing leading tool message because tool result cannot be the first message.")
|
||||
self._truncated_messages.pop(0)
|
||||
self.truncated_messages.pop(0)
|
||||
|
||||
def get_token_count(self) -> int:
|
||||
"""Estimate token count for a list of messages using tiktoken.
|
||||
@@ -72,7 +71,7 @@ class SlidingWindowChatMessageList(ChatMessageList):
|
||||
total_tokens += len(self.encoding.encode(self.system_message))
|
||||
total_tokens += 4 # Extra tokens for system message formatting
|
||||
|
||||
for msg in self._truncated_messages:
|
||||
for msg in self.truncated_messages:
|
||||
# Add 4 tokens per message for role, formatting, etc.
|
||||
total_tokens += 4
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from tau2.user.user_simulator import ( # type: ignore[import-untyped]
|
||||
from tau2.utils.utils import get_now # type: ignore[import-untyped]
|
||||
|
||||
from ._message_utils import flip_messages, log_messages
|
||||
from ._sliding_window import SlidingWindowChatMessageList
|
||||
from ._sliding_window import SlidingWindowChatMessageStore
|
||||
from ._tau2_utils import convert_agent_framework_messages_to_tau2_messages, convert_tau2_tool_to_ai_function
|
||||
|
||||
# Agent instructions matching tau2's LLMAgent
|
||||
@@ -196,7 +196,7 @@ class TaskRunner:
|
||||
instructions=assistant_system_prompt,
|
||||
tools=ai_functions, # type: ignore
|
||||
temperature=self.assistant_sampling_temperature,
|
||||
chat_message_store_factory=lambda: SlidingWindowChatMessageList(
|
||||
chat_message_store_factory=lambda: SlidingWindowChatMessageStore(
|
||||
system_message=assistant_system_prompt,
|
||||
tool_definitions=[tool.openai_schema for tool in tools],
|
||||
max_tokens=self.assistant_window_size,
|
||||
@@ -352,7 +352,7 @@ class TaskRunner:
|
||||
# 2. The assistant's message store (not just the truncated window)
|
||||
# 3. The final user message (if any)
|
||||
assistant_executor = cast(AgentExecutor, self._assistant_executor)
|
||||
message_store = cast(SlidingWindowChatMessageList, assistant_executor._agent_thread.message_store)
|
||||
message_store = cast(SlidingWindowChatMessageStore, assistant_executor._agent_thread.message_store)
|
||||
full_conversation = [first_message] + await message_store.list_all_messages()
|
||||
if self._final_user_message is not None:
|
||||
full_conversation.extend(self._final_user_message)
|
||||
|
||||
@@ -4,20 +4,19 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from agent_framework._types import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
|
||||
from agent_framework_lab_tau2._sliding_window import SlidingWindowChatMessageList
|
||||
from agent_framework_lab_tau2._sliding_window import SlidingWindowChatMessageStore
|
||||
|
||||
|
||||
def test_initialization_empty():
|
||||
"""Test initializing with no messages."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
|
||||
assert sliding_window.max_tokens == 1000
|
||||
assert sliding_window.system_message is None
|
||||
assert sliding_window.tool_definitions is None
|
||||
assert len(sliding_window._messages) == 0
|
||||
assert len(sliding_window._truncated_messages) == 0
|
||||
assert len(sliding_window.messages) == 0
|
||||
assert len(sliding_window.truncated_messages) == 0
|
||||
|
||||
|
||||
def test_initialization_with_parameters():
|
||||
@@ -25,7 +24,7 @@ def test_initialization_with_parameters():
|
||||
system_msg = "You are a helpful assistant"
|
||||
tool_defs = [{"name": "test_tool", "description": "A test tool"}]
|
||||
|
||||
sliding_window = SlidingWindowChatMessageList(
|
||||
sliding_window = SlidingWindowChatMessageStore(
|
||||
max_tokens=2000, system_message=system_msg, tool_definitions=tool_defs
|
||||
)
|
||||
|
||||
@@ -41,16 +40,15 @@ def test_initialization_with_messages():
|
||||
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Hi there!")]),
|
||||
]
|
||||
|
||||
sliding_window = SlidingWindowChatMessageList(messages=messages, max_tokens=1000)
|
||||
sliding_window = SlidingWindowChatMessageStore(messages=messages, max_tokens=1000)
|
||||
|
||||
assert len(sliding_window._messages) == 2
|
||||
assert len(sliding_window._truncated_messages) == 2
|
||||
assert len(sliding_window.messages) == 2
|
||||
assert len(sliding_window.truncated_messages) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_messages_simple():
|
||||
"""Test adding messages without truncation."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=10000) # Large limit
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=10000) # Large limit
|
||||
|
||||
new_messages = [
|
||||
ChatMessage(role=Role.USER, contents=[TextContent(text="What's the weather?")]),
|
||||
@@ -65,10 +63,9 @@ async def test_add_messages_simple():
|
||||
assert messages[1].text == "I can help with that."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_messages_vs_list_messages():
|
||||
"""Test difference between list_all_messages and list_messages."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=50) # Small limit to force truncation
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=50) # Small limit to force truncation
|
||||
|
||||
# Add many messages to trigger truncation
|
||||
messages = [
|
||||
@@ -89,8 +86,8 @@ async def test_list_all_messages_vs_list_messages():
|
||||
|
||||
def test_get_token_count_basic():
|
||||
"""Test basic token counting."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window._truncated_messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
sliding_window.truncated_messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
|
||||
token_count = sliding_window.get_token_count()
|
||||
|
||||
@@ -101,13 +98,13 @@ def test_get_token_count_basic():
|
||||
def test_get_token_count_with_system_message():
|
||||
"""Test token counting includes system message."""
|
||||
system_msg = "You are a helpful assistant"
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000, system_message=system_msg)
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000, system_message=system_msg)
|
||||
|
||||
# Without messages
|
||||
token_count_empty = sliding_window.get_token_count()
|
||||
|
||||
# Add a message
|
||||
sliding_window._truncated_messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
sliding_window.truncated_messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
|
||||
token_count_with_message = sliding_window.get_token_count()
|
||||
|
||||
# With message should be more tokens
|
||||
@@ -119,8 +116,8 @@ def test_get_token_count_function_call():
|
||||
"""Test token counting with function calls."""
|
||||
function_call = FunctionCallContent(call_id="call_123", name="test_function", arguments={"param": "value"})
|
||||
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window._truncated_messages = [ChatMessage(role=Role.ASSISTANT, contents=[function_call])]
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
sliding_window.truncated_messages = [ChatMessage(role=Role.ASSISTANT, contents=[function_call])]
|
||||
|
||||
token_count = sliding_window.get_token_count()
|
||||
assert token_count > 0
|
||||
@@ -130,8 +127,8 @@ def test_get_token_count_function_result():
|
||||
"""Test token counting with function results."""
|
||||
function_result = FunctionResultContent(call_id="call_123", result={"success": True, "data": "result"})
|
||||
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window._truncated_messages = [ChatMessage(role=Role.TOOL, contents=[function_result])]
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
sliding_window.truncated_messages = [ChatMessage(role=Role.TOOL, contents=[function_result])]
|
||||
|
||||
token_count = sliding_window.get_token_count()
|
||||
assert token_count > 0
|
||||
@@ -140,7 +137,7 @@ def test_get_token_count_function_result():
|
||||
@patch("agent_framework_lab_tau2._sliding_window.logger")
|
||||
def test_truncate_messages_removes_old_messages(mock_logger):
|
||||
"""Test that truncation removes old messages when token limit exceeded."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=20) # Very small limit
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=20) # Very small limit
|
||||
|
||||
# Create messages that will exceed the limit
|
||||
messages = [
|
||||
@@ -155,11 +152,11 @@ def test_truncate_messages_removes_old_messages(mock_logger):
|
||||
ChatMessage(role=Role.USER, contents=[TextContent(text="Short msg")]),
|
||||
]
|
||||
|
||||
sliding_window._truncated_messages = messages.copy()
|
||||
sliding_window.truncated_messages = messages.copy()
|
||||
sliding_window.truncate_messages()
|
||||
|
||||
# Should have fewer messages after truncation
|
||||
assert len(sliding_window._truncated_messages) < len(messages)
|
||||
assert len(sliding_window.truncated_messages) < len(messages)
|
||||
|
||||
# Should have logged warnings
|
||||
assert mock_logger.warning.called
|
||||
@@ -168,18 +165,18 @@ def test_truncate_messages_removes_old_messages(mock_logger):
|
||||
@patch("agent_framework_lab_tau2._sliding_window.logger")
|
||||
def test_truncate_messages_removes_leading_tool_messages(mock_logger):
|
||||
"""Test that truncation removes leading tool messages."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=10000) # Large limit
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=10000) # Large limit
|
||||
|
||||
# Create messages starting with tool message
|
||||
tool_message = ChatMessage(role=Role.TOOL, contents=[FunctionResultContent(call_id="call_123", result="result")])
|
||||
user_message = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])
|
||||
|
||||
sliding_window._truncated_messages = [tool_message, user_message]
|
||||
sliding_window.truncated_messages = [tool_message, user_message]
|
||||
sliding_window.truncate_messages()
|
||||
|
||||
# Tool message should be removed from the beginning
|
||||
assert len(sliding_window._truncated_messages) == 1
|
||||
assert sliding_window._truncated_messages[0].role == Role.USER
|
||||
assert len(sliding_window.truncated_messages) == 1
|
||||
assert sliding_window.truncated_messages[0].role == Role.USER
|
||||
|
||||
# Should have logged warning about removing tool message
|
||||
mock_logger.warning.assert_called()
|
||||
@@ -187,7 +184,7 @@ def test_truncate_messages_removes_leading_tool_messages(mock_logger):
|
||||
|
||||
def test_estimate_any_object_token_count_dict():
|
||||
"""Test token counting for dictionary objects."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
|
||||
test_dict = {"key": "value", "number": 42}
|
||||
token_count = sliding_window.estimate_any_object_token_count(test_dict)
|
||||
@@ -197,7 +194,7 @@ def test_estimate_any_object_token_count_dict():
|
||||
|
||||
def test_estimate_any_object_token_count_string():
|
||||
"""Test token counting for string objects."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
|
||||
test_string = "This is a test string"
|
||||
token_count = sliding_window.estimate_any_object_token_count(test_string)
|
||||
@@ -207,7 +204,7 @@ def test_estimate_any_object_token_count_string():
|
||||
|
||||
def test_estimate_any_object_token_count_non_serializable():
|
||||
"""Test token counting for non-JSON-serializable objects."""
|
||||
sliding_window = SlidingWindowChatMessageList(max_tokens=1000)
|
||||
sliding_window = SlidingWindowChatMessageStore(max_tokens=1000)
|
||||
|
||||
# Create an object that can't be JSON serialized
|
||||
class CustomObject:
|
||||
@@ -221,10 +218,9 @@ def test_estimate_any_object_token_count_non_serializable():
|
||||
assert token_count > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_world_scenario():
|
||||
"""Test a realistic conversation scenario."""
|
||||
sliding_window = SlidingWindowChatMessageList(
|
||||
sliding_window = SlidingWindowChatMessageStore(
|
||||
max_tokens=30,
|
||||
system_message="You are a helpful assistant", # Moderate limit
|
||||
)
|
||||
|
||||
@@ -4,18 +4,19 @@ import inspect
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack
|
||||
from typing import Any, ClassVar, Literal, Protocol, TypeVar, runtime_checkable
|
||||
from copy import copy
|
||||
from itertools import chain
|
||||
from typing import Any, ClassVar, Literal, Protocol, TypeVar, cast, runtime_checkable
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field, PrivateAttr, create_model
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
from ._clients import BaseChatClient, ChatClientProtocol
|
||||
from ._logging import get_logger
|
||||
from ._mcp import MCPTool
|
||||
from ._memory import AggregateContextProvider, Context, ContextProvider
|
||||
from ._middleware import Middleware, use_agent_middleware
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._threads import AgentThread, ChatMessageStore, deserialize_thread_state, thread_on_new_messages
|
||||
from ._threads import AgentThread, ChatMessageStoreProtocol
|
||||
from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, AIFunction, ToolProtocol
|
||||
from ._types import (
|
||||
AgentRunResponse,
|
||||
@@ -30,6 +31,10 @@ from ._types import (
|
||||
from .exceptions import AgentExecutionException
|
||||
from .observability import use_agent_observability
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
@@ -122,7 +127,7 @@ class AgentProtocol(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Creates a new conversation thread for the agent."""
|
||||
...
|
||||
|
||||
@@ -130,7 +135,7 @@ class AgentProtocol(Protocol):
|
||||
# region BaseAgent
|
||||
|
||||
|
||||
class BaseAgent(AFBaseModel):
|
||||
class BaseAgent:
|
||||
"""Base class for all Agent Framework agents.
|
||||
|
||||
Attributes:
|
||||
@@ -143,18 +148,55 @@ class BaseAgent(AFBaseModel):
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
"""
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
context_providers: AggregateContextProvider | None = None
|
||||
middleware: Middleware | list[Middleware] | None = None
|
||||
def __init__(
|
||||
self,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
context_providers: ContextProvider | Sequence[ContextProvider] | None = None,
|
||||
middleware: Middleware | Sequence[Middleware] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Base class for all Agent Framework agents.
|
||||
|
||||
Args:
|
||||
id: The unique identifier of the agent If no id is provided,
|
||||
a new UUID will be generated.
|
||||
name: The name of the agent, can be None.
|
||||
description: The description of the agent.
|
||||
display_name: The display name of the agent, which is either the name or id.
|
||||
context_providers: The collection of multiple context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
kwargs: will be stored in `additional_properties`
|
||||
"""
|
||||
if id is None:
|
||||
id = str(uuid4())
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.context_provider = self._prepare_context_providers(context_providers)
|
||||
if middleware is None or isinstance(middleware, Sequence):
|
||||
self.middleware: list[Middleware] | None = cast(list[Middleware], middleware) if middleware else None
|
||||
else:
|
||||
self.middleware = [middleware]
|
||||
self.additional_properties = kwargs
|
||||
|
||||
async def _notify_thread_of_new_messages(
|
||||
self, thread: AgentThread, new_messages: ChatMessage | Sequence[ChatMessage]
|
||||
self,
|
||||
thread: AgentThread,
|
||||
input_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage],
|
||||
) -> None:
|
||||
"""Notify the thread of new messages."""
|
||||
if isinstance(new_messages, ChatMessage) or len(new_messages) > 0:
|
||||
await thread_on_new_messages(thread, new_messages)
|
||||
"""Notify the thread of new messages.
|
||||
|
||||
This also calls the invoked method of a potential context provider on the thread.
|
||||
"""
|
||||
if isinstance(input_messages, ChatMessage) or len(input_messages) > 0:
|
||||
await thread.on_new_messages(input_messages)
|
||||
if isinstance(response_messages, ChatMessage) or len(response_messages) > 0:
|
||||
await thread.on_new_messages(response_messages)
|
||||
if thread.context_provider:
|
||||
await thread.context_provider.invoked(input_messages, response_messages)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -164,14 +206,14 @@ class BaseAgent(AFBaseModel):
|
||||
"""
|
||||
return self.name or self.id
|
||||
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Returns AgentThread instance that is compatible with the agent."""
|
||||
return AgentThread()
|
||||
return AgentThread(**kwargs, context_provider=self.context_provider)
|
||||
|
||||
async def deserialize_thread(self, serialized_thread: Any, **kwargs: Any) -> AgentThread:
|
||||
"""Deserializes the thread."""
|
||||
thread: AgentThread = self.get_new_thread()
|
||||
await deserialize_thread_state(thread, serialized_thread, **kwargs)
|
||||
await thread.deserialize(serialized_thread, **kwargs)
|
||||
return thread
|
||||
|
||||
def as_tool(
|
||||
@@ -236,7 +278,12 @@ class BaseAgent(AFBaseModel):
|
||||
# Create final text from accumulated updates
|
||||
return AgentRunResponse.from_agent_run_response_updates(response_updates).text
|
||||
|
||||
return AIFunction(name=tool_name, description=tool_description, func=agent_wrapper, input_model=input_model)
|
||||
return AIFunction(
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
func=agent_wrapper,
|
||||
input_model=input_model,
|
||||
)
|
||||
|
||||
def _normalize_messages(
|
||||
self,
|
||||
@@ -253,6 +300,18 @@ class BaseAgent(AFBaseModel):
|
||||
|
||||
return [ChatMessage(role=Role.USER, text=msg) if isinstance(msg, str) else msg for msg in messages]
|
||||
|
||||
def _prepare_context_providers(
|
||||
self,
|
||||
context_providers: ContextProvider | Sequence[ContextProvider] | None = None,
|
||||
) -> AggregateContextProvider | None:
|
||||
if not context_providers:
|
||||
return None
|
||||
|
||||
if isinstance(context_providers, AggregateContextProvider):
|
||||
return context_providers
|
||||
|
||||
return AggregateContextProvider(context_providers)
|
||||
|
||||
|
||||
# region ChatAgent
|
||||
|
||||
@@ -263,12 +322,6 @@ class ChatAgent(BaseAgent):
|
||||
"""A Chat Client Agent."""
|
||||
|
||||
AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework"
|
||||
chat_client: ChatClientProtocol
|
||||
instructions: str | None = None
|
||||
chat_options: ChatOptions
|
||||
chat_message_store_factory: Callable[[], ChatMessageStore] | None = None
|
||||
_local_mcp_tools: list[MCPTool] = PrivateAttr(default_factory=list) # type: ignore[reportUnknownVariableType]
|
||||
_async_exit_stack: AsyncExitStack = PrivateAttr(default_factory=AsyncExitStack)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -278,6 +331,9 @@ class ChatAgent(BaseAgent):
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
|
||||
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
|
||||
middleware: Middleware | list[Middleware] | None = None,
|
||||
frequency_penalty: float | None = None,
|
||||
logit_bias: dict[str | int, float] | None = None,
|
||||
max_tokens: int | None = None,
|
||||
@@ -297,10 +353,7 @@ class ChatAgent(BaseAgent):
|
||||
| None = None,
|
||||
top_p: float | None = None,
|
||||
user: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
chat_message_store_factory: Callable[[], ChatMessageStore] | None = None,
|
||||
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
|
||||
middleware: Middleware | list[Middleware] | None = None,
|
||||
request_kwargs: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Create a ChatAgent.
|
||||
@@ -317,6 +370,10 @@ class ChatAgent(BaseAgent):
|
||||
id: The unique identifier for the agent, will be created automatically if not provided.
|
||||
name: The name of the agent.
|
||||
description: A brief description of the agent's purpose.
|
||||
chat_message_store_factory: factory function to create an instance of ChatMessageStoreProtocol.
|
||||
If not provided, the default in-memory store will be used.
|
||||
context_providers: The collection of multiple context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
frequency_penalty: the frequency penalty to use.
|
||||
logit_bias: the logit bias to use.
|
||||
max_tokens: The maximum number of tokens to generate.
|
||||
@@ -332,64 +389,54 @@ class ChatAgent(BaseAgent):
|
||||
tools: the tools to use for the request.
|
||||
top_p: the nucleus sampling probability to use.
|
||||
user: the user to associate with the request.
|
||||
additional_properties: additional properties to include in the request.
|
||||
chat_message_store_factory: factory function to create an instance of ChatMessageStore. If not provided,
|
||||
the default in-memory store will be used.
|
||||
context_providers: The collection of multiple context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
kwargs: any additional keyword arguments.
|
||||
Unused, can be used by subclasses of this Agent.
|
||||
request_kwargs: a dictionary of other values that will be passed through
|
||||
to the chat_client `get_response` and `get_streaming_response` methods.
|
||||
kwargs: any additional keyword arguments. Will be stored as `additional_properties`
|
||||
"""
|
||||
if not hasattr(chat_client, FUNCTION_INVOKING_CHAT_CLIENT_MARKER) and isinstance(chat_client, BaseChatClient):
|
||||
logger.warning(
|
||||
"The provided chat client does not support function invoking, this might limit agent capabilities."
|
||||
)
|
||||
|
||||
kwargs.update(additional_properties or {})
|
||||
|
||||
aggregate_context_providers = self._prepare_context_providers(context_providers)
|
||||
super().__init__(
|
||||
id=id,
|
||||
name=name,
|
||||
description=description,
|
||||
context_providers=context_providers,
|
||||
middleware=middleware,
|
||||
**kwargs,
|
||||
)
|
||||
self.chat_client = chat_client
|
||||
self.chat_message_store_factory = chat_message_store_factory
|
||||
|
||||
# We ignore the MCP Servers here and store them separately,
|
||||
# we add their functions to the tools list at runtime
|
||||
normalized_tools = [] if tools is None else tools if isinstance(tools, list) else [tools]
|
||||
local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
|
||||
final_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
|
||||
args: dict[str, Any] = {
|
||||
"chat_client": chat_client,
|
||||
"chat_message_store_factory": chat_message_store_factory,
|
||||
"context_providers": aggregate_context_providers,
|
||||
"middleware": middleware,
|
||||
"chat_options": ChatOptions(
|
||||
ai_model_id=model,
|
||||
frequency_penalty=frequency_penalty,
|
||||
logit_bias=logit_bias,
|
||||
max_tokens=max_tokens,
|
||||
metadata=metadata,
|
||||
presence_penalty=presence_penalty,
|
||||
response_format=response_format,
|
||||
seed=seed,
|
||||
stop=stop,
|
||||
store=store,
|
||||
temperature=temperature,
|
||||
tool_choice=tool_choice,
|
||||
tools=final_tools, # type: ignore[reportArgumentType]
|
||||
top_p=top_p,
|
||||
user=user,
|
||||
additional_properties=kwargs,
|
||||
),
|
||||
}
|
||||
if instructions is not None:
|
||||
args["instructions"] = instructions
|
||||
if name is not None:
|
||||
args["name"] = name
|
||||
if description is not None:
|
||||
args["description"] = description
|
||||
if id is not None:
|
||||
args["id"] = id
|
||||
|
||||
super().__init__(**args)
|
||||
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
|
||||
[] if tools is None else tools if isinstance(tools, list) else [tools]
|
||||
)
|
||||
self._local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
|
||||
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
|
||||
self.chat_options = ChatOptions(
|
||||
ai_model_id=model,
|
||||
frequency_penalty=frequency_penalty,
|
||||
instructions=instructions,
|
||||
logit_bias=logit_bias,
|
||||
max_tokens=max_tokens,
|
||||
metadata=metadata,
|
||||
presence_penalty=presence_penalty,
|
||||
response_format=response_format,
|
||||
seed=seed,
|
||||
stop=stop,
|
||||
store=store,
|
||||
temperature=temperature,
|
||||
tool_choice=tool_choice,
|
||||
tools=agent_tools, # type: ignore[reportArgumentType]
|
||||
top_p=top_p,
|
||||
user=user,
|
||||
additional_properties=request_kwargs or {}, # type: ignore
|
||||
)
|
||||
self._async_exit_stack = AsyncExitStack()
|
||||
self._update_agent_name()
|
||||
self._local_mcp_tools = local_mcp_tools # type: ignore[assignment]
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry.
|
||||
@@ -399,16 +446,17 @@ class ChatAgent(BaseAgent):
|
||||
|
||||
This list might be extended in the future.
|
||||
"""
|
||||
context_managers = [self.chat_client, *self._local_mcp_tools]
|
||||
if self.context_providers:
|
||||
context_managers.append(self.context_providers)
|
||||
|
||||
for context_manager in context_managers:
|
||||
for context_manager in chain([self.chat_client], self._local_mcp_tools):
|
||||
if isinstance(context_manager, AbstractAsyncContextManager):
|
||||
await self._async_exit_stack.enter_async_context(context_manager)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: Any,
|
||||
) -> None:
|
||||
"""Async context manager exit.
|
||||
|
||||
Close the async exit stack to ensure all context managers are exited properly.
|
||||
@@ -443,11 +491,9 @@ class ChatAgent(BaseAgent):
|
||||
temperature: float | None = None,
|
||||
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
|
||||
tools: ToolProtocol
|
||||
| list[ToolProtocol]
|
||||
| Callable[..., Any]
|
||||
| list[Callable[..., Any]]
|
||||
| MutableMapping[str, Any]
|
||||
| list[MutableMapping[str, Any]]
|
||||
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
|
||||
| None = None,
|
||||
top_p: float | None = None,
|
||||
user: str | None = None,
|
||||
@@ -485,28 +531,32 @@ class ChatAgent(BaseAgent):
|
||||
will only be passed to functions that are called.
|
||||
"""
|
||||
input_messages = self._normalize_messages(messages)
|
||||
context = await self.context_providers.model_invoking(input_messages) if self.context_providers else None
|
||||
thread, thread_messages = await self._prepare_thread_and_messages(
|
||||
thread=thread, context=context, input_messages=input_messages
|
||||
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
|
||||
thread=thread, input_messages=input_messages
|
||||
)
|
||||
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
|
||||
[] if tools is None else tools if isinstance(tools, list) else [tools]
|
||||
)
|
||||
agent_name = self._get_agent_name()
|
||||
|
||||
# Resolve final tool list (runtime provided tools + local MCP server tools)
|
||||
final_tools: list[ToolProtocol | Callable[..., Any] | dict[str, Any]] = []
|
||||
# Normalize tools argument to a list without mutating the original parameter
|
||||
normalized_tools = [] if tools is None else tools if isinstance(tools, list) else [tools]
|
||||
for tool in normalized_tools:
|
||||
if isinstance(tool, MCPTool):
|
||||
if not tool.is_connected:
|
||||
await self._async_exit_stack.enter_async_context(tool)
|
||||
final_tools.extend(tool.functions) # type: ignore
|
||||
else:
|
||||
final_tools.append(tool) # type: ignore
|
||||
|
||||
for mcp_server in self._local_mcp_tools:
|
||||
if not mcp_server.is_connected:
|
||||
await self._async_exit_stack.enter_async_context(mcp_server)
|
||||
final_tools.extend(mcp_server.functions)
|
||||
|
||||
response = await self.chat_client.get_response(
|
||||
messages=thread_messages,
|
||||
chat_options=self.chat_options
|
||||
chat_options=run_chat_options
|
||||
& ChatOptions(
|
||||
ai_model_id=model,
|
||||
conversation_id=thread.service_thread_id,
|
||||
@@ -529,7 +579,7 @@ class ChatAgent(BaseAgent):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
|
||||
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
|
||||
|
||||
# Ensure that the author name is set for each message in the response.
|
||||
for message in response.messages:
|
||||
@@ -538,13 +588,7 @@ class ChatAgent(BaseAgent):
|
||||
|
||||
# Only notify the thread of new messages if the chatResponse was successful
|
||||
# to avoid inconsistent messages state in the thread.
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
|
||||
if self.context_providers:
|
||||
await self.context_providers.thread_created(response.conversation_id)
|
||||
await self.context_providers.messages_adding(thread.service_thread_id, input_messages + response.messages)
|
||||
|
||||
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
|
||||
return AgentRunResponse(
|
||||
messages=response.messages,
|
||||
response_id=response.response_id,
|
||||
@@ -614,29 +658,34 @@ class ChatAgent(BaseAgent):
|
||||
|
||||
"""
|
||||
input_messages = self._normalize_messages(messages)
|
||||
context = await self.context_providers.model_invoking(input_messages) if self.context_providers else None
|
||||
thread, thread_messages = await self._prepare_thread_and_messages(
|
||||
thread=thread, context=context, input_messages=input_messages
|
||||
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
|
||||
thread=thread, input_messages=input_messages
|
||||
)
|
||||
agent_name = self._get_agent_name()
|
||||
response_updates: list[ChatResponseUpdate] = []
|
||||
|
||||
# Resolve final tool list (runtime provided tools + local MCP server tools)
|
||||
final_tools: list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]] = []
|
||||
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type: ignore[reportUnknownVariableType]
|
||||
[] if tools is None else tools if isinstance(tools, list) else [tools]
|
||||
)
|
||||
# Normalize tools argument to a list without mutating the original parameter
|
||||
normalized_tools = [] if tools is None else tools if isinstance(tools, list) else [tools]
|
||||
for tool in normalized_tools:
|
||||
if isinstance(tool, MCPTool):
|
||||
if not tool.is_connected:
|
||||
await self._async_exit_stack.enter_async_context(tool)
|
||||
final_tools.extend(tool.functions) # type: ignore
|
||||
else:
|
||||
final_tools.append(tool)
|
||||
|
||||
for mcp_server in self._local_mcp_tools:
|
||||
if not mcp_server.is_connected:
|
||||
await self._async_exit_stack.enter_async_context(mcp_server)
|
||||
final_tools.extend(mcp_server.functions)
|
||||
|
||||
async for update in self.chat_client.get_streaming_response(
|
||||
messages=thread_messages,
|
||||
chat_options=self.chat_options
|
||||
chat_options=run_chat_options
|
||||
& ChatOptions(
|
||||
conversation_id=thread.service_thread_id,
|
||||
frequency_penalty=frequency_penalty,
|
||||
@@ -675,27 +724,46 @@ class ChatAgent(BaseAgent):
|
||||
)
|
||||
|
||||
response = ChatResponse.from_chat_response_updates(response_updates)
|
||||
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
|
||||
|
||||
self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
|
||||
@override
|
||||
def get_new_thread(
|
||||
self,
|
||||
*,
|
||||
service_thread_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentThread:
|
||||
"""Get a new conversation thread for the agent.
|
||||
|
||||
# Only notify the thread of new messages if the chatResponse was successful
|
||||
# to avoid inconsistent messages state in the thread.
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
If you supply a service_thread_id, the thread will be marked as service managed.
|
||||
|
||||
if self.context_providers:
|
||||
await self.context_providers.thread_created(response.conversation_id)
|
||||
await self.context_providers.messages_adding(thread.service_thread_id, input_messages + response.messages)
|
||||
If you don't supply a service_thread_id but have a chat_message_store_factory configured on the agent,
|
||||
that factory will be used to create a message store for the thread and the thread will be
|
||||
managed locally.
|
||||
|
||||
def get_new_thread(self) -> AgentThread:
|
||||
message_store: ChatMessageStore | None = None
|
||||
When neither is present, the thread will be created without a service ID or message store,
|
||||
this will be updated based on usage, when you run the agent with this thread.
|
||||
If you run with store=True, the response will respond with a thread_id and that will be set.
|
||||
Otherwise a messages store is created from the default factory.
|
||||
|
||||
if self.chat_message_store_factory:
|
||||
message_store = self.chat_message_store_factory()
|
||||
Args:
|
||||
service_thread_id: Optional service managed thread ID.
|
||||
kwargs: not used at present.
|
||||
"""
|
||||
if service_thread_id is not None:
|
||||
return AgentThread(
|
||||
service_thread_id=service_thread_id,
|
||||
context_provider=self.context_provider,
|
||||
)
|
||||
if self.chat_message_store_factory is not None:
|
||||
return AgentThread(
|
||||
message_store=self.chat_message_store_factory(),
|
||||
context_provider=self.context_provider,
|
||||
)
|
||||
return AgentThread(context_provider=self.context_provider)
|
||||
|
||||
return AgentThread() if message_store is None else AgentThread(message_store=message_store)
|
||||
|
||||
def _update_thread_with_type_and_conversation_id(
|
||||
async def _update_thread_with_type_and_conversation_id(
|
||||
self, thread: AgentThread, response_conversation_id: str | None
|
||||
) -> None:
|
||||
"""Update thread with storage type and conversation ID.
|
||||
@@ -719,6 +787,8 @@ class ChatAgent(BaseAgent):
|
||||
# If we got a conversation id back from the chat client, it means that the service
|
||||
# supports server side thread storage so we should update the thread with the new id.
|
||||
thread.service_thread_id = response_conversation_id
|
||||
if thread.context_provider:
|
||||
await thread.context_provider.thread_created(thread.service_thread_id)
|
||||
elif thread.message_store is None and self.chat_message_store_factory is not None:
|
||||
# If the service doesn't use service side thread storage (i.e. we got no id back from invocation), and
|
||||
# the thread has no message_store yet, and we have a custom messages store, we should update the thread
|
||||
@@ -729,14 +799,14 @@ class ChatAgent(BaseAgent):
|
||||
self,
|
||||
*,
|
||||
thread: AgentThread | None,
|
||||
context: Context | None,
|
||||
input_messages: list[ChatMessage] | None = None,
|
||||
) -> tuple[AgentThread, list[ChatMessage]]:
|
||||
) -> tuple[AgentThread, ChatOptions, list[ChatMessage]]:
|
||||
"""Prepare the messages for agent execution.
|
||||
|
||||
Also updates the chat_options of the agent, with
|
||||
|
||||
Args:
|
||||
thread: The conversation thread.
|
||||
context: Context to include in messages.
|
||||
input_messages: Messages to process.
|
||||
|
||||
Returns:
|
||||
@@ -745,32 +815,42 @@ class ChatAgent(BaseAgent):
|
||||
Raises:
|
||||
AgentExecutionException: If the thread is not of the expected type.
|
||||
"""
|
||||
chat_options = copy(self.chat_options) if self.chat_options else ChatOptions()
|
||||
thread = thread or self.get_new_thread()
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
if self.instructions:
|
||||
messages.append(ChatMessage(role=Role.SYSTEM, text=self.instructions))
|
||||
if context and context.contents:
|
||||
messages.append(ChatMessage(role=Role.SYSTEM, contents=context.contents))
|
||||
if thread.service_thread_id and thread.context_provider:
|
||||
await thread.context_provider.thread_created(thread.service_thread_id)
|
||||
thread_messages: list[ChatMessage] = []
|
||||
if thread.message_store:
|
||||
messages.extend(await thread.message_store.list_messages() or [])
|
||||
messages.extend(input_messages or [])
|
||||
return thread, messages
|
||||
thread_messages.extend(await thread.message_store.list_messages() or [])
|
||||
context: Context | None = None
|
||||
if self.context_provider:
|
||||
async with self.context_provider:
|
||||
context = await self.context_provider.invoking(input_messages or [])
|
||||
if context:
|
||||
if context.messages:
|
||||
thread_messages.extend(context.messages)
|
||||
if context.tools:
|
||||
if chat_options.tools is not None:
|
||||
chat_options.tools.extend(context.tools)
|
||||
else:
|
||||
chat_options.tools = list(context.tools)
|
||||
if context.instructions:
|
||||
chat_options.instructions = (
|
||||
context.instructions
|
||||
if not chat_options.instructions
|
||||
else f"{chat_options.instructions}\n{context.instructions}"
|
||||
)
|
||||
thread_messages.extend(input_messages or [])
|
||||
if (
|
||||
thread.service_thread_id
|
||||
and chat_options.conversation_id
|
||||
and thread.service_thread_id != chat_options.conversation_id
|
||||
):
|
||||
raise AgentExecutionException(
|
||||
"The conversation_id set on the agent is different from the one set on the thread, "
|
||||
"only one ID can be used for a run."
|
||||
)
|
||||
return thread, chat_options, thread_messages
|
||||
|
||||
def _get_agent_name(self) -> str:
|
||||
return self.name or "UnnamedAgent"
|
||||
|
||||
def _prepare_context_providers(
|
||||
self,
|
||||
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
|
||||
) -> AggregateContextProvider | None:
|
||||
if not context_providers:
|
||||
return None
|
||||
|
||||
if isinstance(context_providers, AggregateContextProvider):
|
||||
return context_providers
|
||||
|
||||
if isinstance(context_providers, ContextProvider):
|
||||
return AggregateContextProvider([context_providers])
|
||||
|
||||
return AggregateContextProvider(context_providers)
|
||||
|
||||
@@ -18,7 +18,7 @@ from ._middleware import (
|
||||
Middleware,
|
||||
)
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._threads import ChatMessageStore
|
||||
from ._threads import ChatMessageStoreProtocol
|
||||
from ._tools import ToolProtocol
|
||||
from ._types import (
|
||||
ChatMessage,
|
||||
@@ -207,9 +207,12 @@ class BaseChatClient(AFBaseModel, ABC):
|
||||
# This is used for OTel setup, should be overridden in subclasses
|
||||
|
||||
def prepare_messages(
|
||||
self, messages: str | ChatMessage | list[str] | list[ChatMessage]
|
||||
self, messages: str | ChatMessage | list[str] | list[ChatMessage], chat_options: ChatOptions
|
||||
) -> MutableSequence[ChatMessage]:
|
||||
"""Turn the allowed input into a list of chat messages."""
|
||||
if chat_options.instructions:
|
||||
system_msg = ChatMessage(role="system", text=chat_options.instructions)
|
||||
return [system_msg, *prepare_messages(messages)]
|
||||
return prepare_messages(messages)
|
||||
|
||||
def _filter_internal_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -368,7 +371,7 @@ class BaseChatClient(AFBaseModel, ABC):
|
||||
user=user,
|
||||
additional_properties=additional_properties or {},
|
||||
)
|
||||
prepped_messages = self.prepare_messages(messages)
|
||||
prepped_messages = self.prepare_messages(messages, chat_options)
|
||||
self._prepare_tool_choice(chat_options=chat_options)
|
||||
|
||||
filtered_kwargs = self._filter_internal_kwargs(kwargs)
|
||||
@@ -449,7 +452,7 @@ class BaseChatClient(AFBaseModel, ABC):
|
||||
user=user,
|
||||
additional_properties=additional_properties or {},
|
||||
)
|
||||
prepped_messages = self.prepare_messages(messages)
|
||||
prepped_messages = self.prepare_messages(messages, chat_options)
|
||||
self._prepare_tool_choice(chat_options=chat_options)
|
||||
|
||||
filtered_kwargs = self._filter_internal_kwargs(kwargs)
|
||||
@@ -492,7 +495,7 @@ class BaseChatClient(AFBaseModel, ABC):
|
||||
| MutableMapping[str, Any]
|
||||
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
|
||||
| None = None,
|
||||
chat_message_store_factory: Callable[[], ChatMessageStore] | None = None,
|
||||
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
|
||||
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
|
||||
middleware: Middleware | list[Middleware] | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -503,8 +506,8 @@ class BaseChatClient(AFBaseModel, ABC):
|
||||
name: The name of the agent.
|
||||
instructions: The instructions for the agent.
|
||||
tools: Optional list of tools to associate with the agent.
|
||||
chat_message_store_factory: Factory function to create an instance of ChatMessageStore. If not provided,
|
||||
the default in-memory store will be used.
|
||||
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
|
||||
If not provided, the default in-memory store will be used.
|
||||
context_providers: Context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
**kwargs: Additional keyword arguments to pass to the agent.
|
||||
|
||||
@@ -246,6 +246,7 @@ class MCPTool:
|
||||
self.request_timeout = request_timeout
|
||||
self.chat_client = chat_client
|
||||
self.functions: list[AIFunction[Any, Any]] = []
|
||||
self.is_connected: bool = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"MCPTool(name={self.name}, description={self.description})"
|
||||
@@ -282,6 +283,7 @@ class MCPTool:
|
||||
# If the session is not initialized, we need to reinitialize it
|
||||
await self.session.initialize()
|
||||
logger.debug("Connected to MCP server: %s", self.session)
|
||||
self.is_connected = True
|
||||
if self.load_tools_flag:
|
||||
await self.load_tools()
|
||||
if self.load_prompts_flag:
|
||||
@@ -434,6 +436,7 @@ class MCPTool:
|
||||
"""Disconnect from the MCP server."""
|
||||
await self._exit_stack.aclose()
|
||||
self.session = None
|
||||
self.is_connected = False
|
||||
|
||||
@abstractmethod
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
|
||||
@@ -6,11 +6,15 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from contextlib import AsyncExitStack
|
||||
from types import TracebackType
|
||||
from typing import ClassVar
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._types import ChatMessage, Contents
|
||||
from ._tools import ToolProtocol
|
||||
from ._types import ChatMessage
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
@@ -21,7 +25,7 @@ else:
|
||||
__all__ = ["AggregateContextProvider", "Context", "ContextProvider"]
|
||||
|
||||
|
||||
class Context(AFBaseModel):
|
||||
class Context:
|
||||
"""A class containing any context that should be provided to the AI model as supplied by an ContextProvider.
|
||||
|
||||
Each ContextProvider has the ability to provide its own context for each invocation.
|
||||
@@ -30,28 +34,39 @@ class Context(AFBaseModel):
|
||||
This context is per invocation, and will not be stored as part of the chat history.
|
||||
"""
|
||||
|
||||
contents: list[Contents] | None = None
|
||||
"""
|
||||
Any content to pass to the AI model in addition to any other prompts
|
||||
that it may already have (in the case of an agent), or chat history that may already exist.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
instructions: str | None = None,
|
||||
messages: Sequence[ChatMessage] | None = None,
|
||||
tools: Sequence[ToolProtocol] | None = None,
|
||||
):
|
||||
"""Create a new Context object.
|
||||
|
||||
Args:
|
||||
instructions: Instructions to provide to the AI model.
|
||||
messages: a list of messages.
|
||||
tools: a list of tools to provide to this run.
|
||||
"""
|
||||
self.instructions = instructions
|
||||
self.messages: Sequence[ChatMessage] = messages or []
|
||||
self.tools: Sequence[ToolProtocol] = tools or []
|
||||
|
||||
|
||||
# region ContextProvider
|
||||
|
||||
|
||||
class ContextProvider(AFBaseModel, ABC):
|
||||
class ContextProvider(ABC):
|
||||
"""Base class for all context providers.
|
||||
|
||||
A context provider is a component that can be used to enhance the AI's context management.
|
||||
It can listen to changes in the conversation and provide additional context to the AI model
|
||||
just before invocation.
|
||||
|
||||
It also has a default memory prompt that can be used by all providers.
|
||||
"""
|
||||
|
||||
# Default prompt to be used by all context providers when assembling memories/instructions
|
||||
DEFAULT_CONTEXT_PROMPT: ClassVar[str] = (
|
||||
"## Memories\nConsider the following memories when answering user questions:"
|
||||
)
|
||||
DEFAULT_CONTEXT_PROMPT: Final[str] = "## Memories\nConsider the following memories when answering user questions:"
|
||||
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
"""Called just after a new thread is created.
|
||||
@@ -65,19 +80,27 @@ class ContextProvider(AFBaseModel, ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Called just before messages are added to the chat by any participant.
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
|
||||
invoke_exception: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Called after the agent has received a response from the underlying inference service.
|
||||
|
||||
Inheritors can use this method to update their context based on new messages.
|
||||
You can inspect the request and response messages, and update the state of the context provider
|
||||
|
||||
Args:
|
||||
thread_id: The ID of the thread for the new message.
|
||||
new_messages: New messages to add.
|
||||
request_messages: messages that were sent to the model/agent
|
||||
response_messages: messages that were returned by the model/agent
|
||||
invoke_exception: exception that was thrown, if any.
|
||||
kwargs: not used at present.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
"""Called just before the Model/Agent/etc. is invoked.
|
||||
|
||||
Implementers can load any additional context required at this time,
|
||||
@@ -85,6 +108,7 @@ class ContextProvider(AFBaseModel, ABC):
|
||||
|
||||
Args:
|
||||
messages: The most recent messages that the agent is being invoked with.
|
||||
kwargs: not used at present.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -125,16 +149,16 @@ class AggregateContextProvider(ContextProvider):
|
||||
It delegates events to multiple context providers and aggregates responses from those events before returning.
|
||||
"""
|
||||
|
||||
providers: list[ContextProvider]
|
||||
"""List of registered context providers."""
|
||||
|
||||
def __init__(self, context_providers: Sequence[ContextProvider] | None = None) -> None:
|
||||
"""Initialize AggregateContextProvider with context providers.
|
||||
def __init__(self, context_providers: ContextProvider | Sequence[ContextProvider] | None = None) -> None:
|
||||
"""Initialize the AggregateContextProvider with context providers.
|
||||
|
||||
Args:
|
||||
context_providers: Context providers to add.
|
||||
"""
|
||||
super().__init__(providers=list(context_providers or [])) # type: ignore
|
||||
if isinstance(context_providers, ContextProvider):
|
||||
self.providers = [context_providers]
|
||||
else:
|
||||
self.providers = cast(list[ContextProvider], context_providers) or []
|
||||
self._exit_stack: AsyncExitStack | None = None
|
||||
|
||||
def add(self, context_provider: ContextProvider) -> None:
|
||||
@@ -145,24 +169,44 @@ class AggregateContextProvider(ContextProvider):
|
||||
"""
|
||||
self.providers.append(context_provider)
|
||||
|
||||
@override
|
||||
async def thread_created(self, thread_id: str | None = None) -> None:
|
||||
await asyncio.gather(*[x.thread_created(thread_id) for x in self.providers])
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
await asyncio.gather(*[x.messages_adding(thread_id, new_messages) for x in self.providers])
|
||||
@override
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
contexts = await asyncio.gather(*[provider.invoking(messages, **kwargs) for provider in self.providers])
|
||||
instructions: str = ""
|
||||
return_messages: list[ChatMessage] = []
|
||||
tools: list[ToolProtocol] = []
|
||||
for ctx in contexts:
|
||||
if ctx.instructions:
|
||||
instructions += ctx.instructions
|
||||
if ctx.messages:
|
||||
return_messages.extend(ctx.messages)
|
||||
if ctx.tools:
|
||||
tools.extend(ctx.tools)
|
||||
return Context(instructions=instructions, messages=return_messages, tools=tools)
|
||||
|
||||
async def model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
sub_contexts = await asyncio.gather(*[x.model_invoking(messages) for x in self.providers])
|
||||
combined_context = Context()
|
||||
# Flatten the list of lists and filter out None values
|
||||
all_contents = []
|
||||
for ctx in sub_contexts:
|
||||
if ctx.contents:
|
||||
all_contents.extend(ctx.contents)
|
||||
|
||||
combined_context.contents = all_contents if all_contents else None
|
||||
return combined_context
|
||||
@override
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
|
||||
invoke_exception: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
await asyncio.gather(*[
|
||||
x.invoked(
|
||||
request_messages=request_messages,
|
||||
response_messages=response_messages,
|
||||
invoke_exception=invoke_exception,
|
||||
**kwargs,
|
||||
)
|
||||
for x in self.providers
|
||||
])
|
||||
|
||||
@override
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Enter async context manager and set up all providers.
|
||||
|
||||
@@ -178,6 +222,7 @@ class AggregateContextProvider(ContextProvider):
|
||||
|
||||
return self
|
||||
|
||||
@override
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
|
||||
@@ -35,6 +35,7 @@ class MiddlewareType(Enum):
|
||||
|
||||
__all__ = [
|
||||
"AgentMiddleware",
|
||||
"AgentMiddlewares",
|
||||
"AgentRunContext",
|
||||
"ChatContext",
|
||||
"ChatMiddleware",
|
||||
@@ -230,6 +231,7 @@ Middleware: TypeAlias = (
|
||||
| ChatMiddleware
|
||||
| ChatMiddlewareCallable
|
||||
)
|
||||
AgentMiddlewares: TypeAlias = AgentMiddleware | AgentMiddlewareCallable
|
||||
|
||||
|
||||
# Middleware type markers for decorators
|
||||
@@ -1009,7 +1011,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
|
||||
pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type]
|
||||
context = ChatContext(
|
||||
chat_client=self,
|
||||
messages=self.prepare_messages(messages),
|
||||
messages=self.prepare_messages(messages, chat_options),
|
||||
chat_options=chat_options,
|
||||
is_streaming=False,
|
||||
kwargs=kwargs,
|
||||
@@ -1059,7 +1061,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
|
||||
pipeline = ChatMiddlewarePipeline(all_middleware) # type: ignore[arg-type]
|
||||
context = ChatContext(
|
||||
chat_client=self,
|
||||
messages=self.prepare_messages(messages),
|
||||
messages=self.prepare_messages(messages, chat_options),
|
||||
chat_options=chat_options,
|
||||
is_streaming=True,
|
||||
kwargs=kwargs,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, overload
|
||||
from typing import Any, Protocol, TypeVar
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from ._memory import AggregateContextProvider
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._types import ChatMessage
|
||||
from .exceptions import AgentThreadException
|
||||
|
||||
__all__ = ["AgentThread", "ChatMessageList", "ChatMessageStore"]
|
||||
__all__ = ["AgentThread", "ChatMessageStore", "ChatMessageStoreProtocol"]
|
||||
|
||||
|
||||
class ChatMessageStore(Protocol):
|
||||
class ChatMessageStoreProtocol(Protocol):
|
||||
"""Defines methods for storing and retrieving chat messages associated with a specific thread.
|
||||
|
||||
Implementations of this protocol are responsible for managing the storage of chat messages,
|
||||
@@ -24,7 +28,7 @@ class ChatMessageStore(Protocol):
|
||||
If the messages stored in the store become very large, it is up to the store to
|
||||
truncate, summarize or otherwise limit the number of messages returned.
|
||||
|
||||
When using implementations of ChatMessageStore, a new one should be created for each thread
|
||||
When using implementations of ChatMessageStoreProtocol, a new one should be created for each thread
|
||||
since they may contain state that is specific to a thread.
|
||||
"""
|
||||
...
|
||||
@@ -33,66 +37,187 @@ class ChatMessageStore(Protocol):
|
||||
"""Adds messages to the store."""
|
||||
...
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserializes the state into the properties on this store.
|
||||
@classmethod
|
||||
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "ChatMessageStoreProtocol":
|
||||
"""Creates a new instance of the store from previously serialized state.
|
||||
|
||||
This method, together with serialize_state can be used to save and load messages from a persistent store
|
||||
if this store only has messages in memory.
|
||||
"""
|
||||
...
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Update the current ChatMessageStore instance from serialized state data.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data containing messages.
|
||||
**kwargs: Additional arguments for deserialization.
|
||||
"""
|
||||
...
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> Any:
|
||||
"""Serializes the current object's state.
|
||||
|
||||
This method, together with deserialize_state can be used to save and load messages from a persistent store
|
||||
This method, together with deserialize can be used to save and load messages from a persistent store
|
||||
if this store only has messages in memory.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class AgentThread(AFBaseModel):
|
||||
"""Base class for agent threads."""
|
||||
class AgentThreadState(AFBaseModel):
|
||||
"""State model for serializing and deserializing thread information.
|
||||
|
||||
_service_thread_id: str | None = None
|
||||
_message_store: ChatMessageStore | None = None
|
||||
Attributes:
|
||||
service_thread_id: Optional ID of the thread managed by the agent service.
|
||||
chat_message_store_state: Optional serialized state of the chat message store.
|
||||
"""
|
||||
|
||||
@overload
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an empty AgentThread with no service thread ID or message store."""
|
||||
...
|
||||
service_thread_id: str | None = None
|
||||
chat_message_store_state: Any | None = None
|
||||
|
||||
@overload
|
||||
def __init__(self, service_thread_id: str) -> None:
|
||||
"""Initialize an AgentThread with a service thread ID.
|
||||
@model_validator(mode="before")
|
||||
def validate_only_one(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
if (
|
||||
isinstance(values, dict)
|
||||
and values.get("service_thread_id") is not None
|
||||
and values.get("chat_message_store_state") is not None
|
||||
):
|
||||
raise AgentThreadException("Only one of service_thread_id or chat_message_store_state may be set.")
|
||||
return values
|
||||
|
||||
|
||||
class ChatMessageStoreState(AFBaseModel):
|
||||
"""State model for serializing and deserializing chat message store data.
|
||||
|
||||
Attributes:
|
||||
messages: List of chat messages stored in the message store.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
TChatMessageStore = TypeVar("TChatMessageStore", bound="ChatMessageStore")
|
||||
|
||||
|
||||
class ChatMessageStore:
|
||||
"""An in-memory implementation of ChatMessageStoreProtocol that stores messages in a list.
|
||||
|
||||
This implementation provides a simple, list-based storage for chat messages
|
||||
with support for serialization and deserialization. It implements all the
|
||||
required methods of the ChatMessageStoreProtocol protocol.
|
||||
|
||||
The store maintains messages in memory and provides methods to serialize
|
||||
and deserialize the state for persistence purposes.
|
||||
|
||||
Args:
|
||||
messages: Optional initial list of ChatMessage objects to populate the store.
|
||||
"""
|
||||
|
||||
def __init__(self, messages: Sequence[ChatMessage] | None = None):
|
||||
"""Create a ChatMessageStore for use in a thread.
|
||||
|
||||
Args:
|
||||
service_thread_id: The ID of the thread managed by the agent service.
|
||||
messages: The messages to store.
|
||||
"""
|
||||
...
|
||||
self.messages = list(messages) if messages else []
|
||||
|
||||
@overload
|
||||
def __init__(self, *, message_store: ChatMessageStore) -> None:
|
||||
"""Initialize an AgentThread with a custom message store.
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Add messages to the store.
|
||||
|
||||
Args:
|
||||
message_store: The ChatMessageStore implementation for managing chat messages.
|
||||
messages: Sequence of ChatMessage objects to add to the store.
|
||||
"""
|
||||
...
|
||||
self.messages.extend(messages)
|
||||
|
||||
def __init__(self, service_thread_id: str | None = None, *, message_store: ChatMessageStore | None = None) -> None:
|
||||
"""Initialize an AgentThread.
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Get all messages from the store in chronological order.
|
||||
|
||||
Returns:
|
||||
List of ChatMessage objects, ordered from oldest to newest.
|
||||
"""
|
||||
return self.messages
|
||||
|
||||
@classmethod
|
||||
async def deserialize(
|
||||
cls: type[TChatMessageStore], serialized_store_state: Any, **kwargs: Any
|
||||
) -> TChatMessageStore:
|
||||
"""Create a new ChatMessageStore instance from serialized state data.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data containing messages.
|
||||
**kwargs: Additional arguments for deserialization.
|
||||
|
||||
Returns:
|
||||
A new ChatMessageStore instance populated with messages from the serialized state.
|
||||
"""
|
||||
state = ChatMessageStoreState.model_validate(serialized_store_state, **kwargs)
|
||||
if state.messages:
|
||||
return cls(messages=state.messages)
|
||||
return cls()
|
||||
|
||||
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Update the current ChatMessageStore instance from serialized state data.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data containing messages.
|
||||
**kwargs: Additional arguments for deserialization.
|
||||
"""
|
||||
if not serialized_store_state:
|
||||
return
|
||||
state = ChatMessageStoreState.model_validate(serialized_store_state, **kwargs)
|
||||
if state.messages:
|
||||
self.messages = state.messages
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> Any:
|
||||
"""Serialize the current store state for persistence.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments for serialization.
|
||||
|
||||
Returns:
|
||||
Serialized state data that can be used with deserialize_state.
|
||||
"""
|
||||
state = ChatMessageStoreState(messages=self.messages)
|
||||
return state.model_dump(**kwargs)
|
||||
|
||||
|
||||
TAgentThread = TypeVar("TAgentThread", bound="AgentThread")
|
||||
|
||||
|
||||
class AgentThread:
|
||||
"""The Agent thread class, this can represent both a locally managed thread or a thread managed by the service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service_thread_id: str | None = None,
|
||||
message_store: ChatMessageStoreProtocol | None = None,
|
||||
context_provider: AggregateContextProvider | None = None,
|
||||
) -> None:
|
||||
"""Initialize an AgentThread, do not use this method manually, always use: agent.get_new_thread().
|
||||
|
||||
Args:
|
||||
service_thread_id: Optional ID of the thread managed by the agent service.
|
||||
message_store: Optional ChatMessageStore implementation for managing chat messages.
|
||||
context_provider: Optional ContextProvider for the thread.
|
||||
|
||||
Note:
|
||||
Either service_thread_id or message_store may be set, but not both.
|
||||
"""
|
||||
super().__init__()
|
||||
if service_thread_id is not None and message_store is not None:
|
||||
raise AgentThreadException("Only the service_thread_id or message_store may be set, but not both.")
|
||||
|
||||
self.service_thread_id = service_thread_id
|
||||
self.message_store = message_store
|
||||
self._service_thread_id = service_thread_id
|
||||
self._message_store = message_store
|
||||
self.context_provider = context_provider
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""Indicates if the thread is initialized.
|
||||
|
||||
This means either the service_thread_id or the message_store is set.
|
||||
"""
|
||||
return self._service_thread_id is not None or self._message_store is not None
|
||||
|
||||
@property
|
||||
def service_thread_id(self) -> str | None:
|
||||
@@ -105,39 +230,53 @@ class AgentThread(AFBaseModel):
|
||||
|
||||
Note that either service_thread_id or message_store may be set, but not both.
|
||||
"""
|
||||
if not self._service_thread_id and not service_thread_id:
|
||||
if service_thread_id is None:
|
||||
return
|
||||
|
||||
if self._message_store is not None:
|
||||
raise ValueError(
|
||||
raise AgentThreadException(
|
||||
"Only the service_thread_id or message_store may be set, "
|
||||
"but not both and switching from one to another is not supported."
|
||||
)
|
||||
|
||||
self._service_thread_id = service_thread_id
|
||||
|
||||
@property
|
||||
def message_store(self) -> ChatMessageStore | None:
|
||||
"""Gets the ChatMessageStore used by this thread, when messages should be stored in a custom location."""
|
||||
def message_store(self) -> ChatMessageStoreProtocol | None:
|
||||
"""Gets the ChatMessageStoreProtocol used by this thread."""
|
||||
return self._message_store
|
||||
|
||||
@message_store.setter
|
||||
def message_store(self, message_store: ChatMessageStore | None) -> None:
|
||||
"""Sets the ChatMessageStore used by this thread, when messages should be stored in a custom location.
|
||||
def message_store(self, message_store: ChatMessageStoreProtocol | None) -> None:
|
||||
"""Sets the ChatMessageStoreProtocol used by this thread.
|
||||
|
||||
Note that either service_thread_id or message_store may be set, but not both.
|
||||
"""
|
||||
if self._message_store is None and message_store is None:
|
||||
if message_store is None:
|
||||
return
|
||||
|
||||
if self._service_thread_id:
|
||||
raise ValueError(
|
||||
if self._service_thread_id is not None:
|
||||
raise AgentThreadException(
|
||||
"Only the service_thread_id or message_store may be set, "
|
||||
"but not both and switching from one to another is not supported."
|
||||
)
|
||||
|
||||
self._message_store = message_store
|
||||
|
||||
async def on_new_messages(self, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Invoked when a new message has been contributed to the chat by any participant."""
|
||||
if self._service_thread_id is not None:
|
||||
# If the thread messages are stored in the service there is nothing to do here,
|
||||
# since invoking the service should already update the thread.
|
||||
return
|
||||
if self._message_store is None:
|
||||
# If there is no conversation id, and no store we can
|
||||
# create a default in memory store.
|
||||
self._message_store = ChatMessageStore()
|
||||
# If a store has been provided, we need to add the messages to the store.
|
||||
if isinstance(new_messages, ChatMessage):
|
||||
new_messages = [new_messages]
|
||||
await self._message_store.add_messages(new_messages)
|
||||
|
||||
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Serializes the current object's state.
|
||||
|
||||
@@ -146,226 +285,71 @@ class AgentThread(AFBaseModel):
|
||||
"""
|
||||
chat_message_store_state = None
|
||||
if self._message_store is not None:
|
||||
chat_message_store_state = await self._message_store.serialize_state(**kwargs)
|
||||
chat_message_store_state = await self._message_store.serialize(**kwargs)
|
||||
|
||||
state = ThreadState(
|
||||
state = AgentThreadState(
|
||||
service_thread_id=self._service_thread_id, chat_message_store_state=chat_message_store_state
|
||||
)
|
||||
|
||||
return state.model_dump()
|
||||
|
||||
|
||||
async def thread_on_new_messages(thread: AgentThread, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Invoked when a new message has been contributed to the chat by any participant."""
|
||||
if thread.service_thread_id is not None:
|
||||
# If the thread messages are stored in the service there is nothing to do here,
|
||||
# since invoking the service should already update the thread.
|
||||
return
|
||||
|
||||
if thread.message_store is None:
|
||||
# If there is no conversation id, and no store we can
|
||||
# create a default in memory store.
|
||||
thread.message_store = ChatMessageList()
|
||||
|
||||
# If a store has been provided, we need to add the messages to the store.
|
||||
if isinstance(new_messages, ChatMessage):
|
||||
new_messages = [new_messages]
|
||||
|
||||
await thread.message_store.add_messages(new_messages)
|
||||
|
||||
|
||||
async def deserialize_thread_state(
|
||||
thread: AgentThread,
|
||||
serialized_thread: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Deserializes the state from a dictionary into the thread properties."""
|
||||
state = ThreadState.model_validate(serialized_thread)
|
||||
|
||||
if state.service_thread_id:
|
||||
thread.service_thread_id = state.service_thread_id
|
||||
# Since we have an ID, we should not have a chat message store and we can return here.
|
||||
return
|
||||
|
||||
# If we don't have any ChatMessageStore state return here.
|
||||
if state.chat_message_store_state is None:
|
||||
return
|
||||
|
||||
if thread.message_store is None:
|
||||
# If we don't have a chat message store yet, create an in-memory one.
|
||||
thread.message_store = ChatMessageList()
|
||||
|
||||
await thread.message_store.deserialize_state(state.chat_message_store_state, **kwargs)
|
||||
|
||||
|
||||
class ThreadState(AFBaseModel):
|
||||
"""State model for serializing and deserializing thread information.
|
||||
|
||||
Attributes:
|
||||
service_thread_id: Optional ID of the thread managed by the agent service.
|
||||
chat_message_store_state: Optional serialized state of the chat message store.
|
||||
"""
|
||||
|
||||
service_thread_id: str | None = None
|
||||
chat_message_store_state: Any | None = None
|
||||
|
||||
|
||||
class StoreState(AFBaseModel):
|
||||
"""State model for serializing and deserializing chat message store data.
|
||||
|
||||
Attributes:
|
||||
messages: List of chat messages stored in the message store.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class ChatMessageList:
|
||||
"""An in-memory implementation of ChatMessageStore that stores messages in a list.
|
||||
|
||||
This implementation provides a simple, list-based storage for chat messages
|
||||
with support for serialization and deserialization. It implements all the
|
||||
required methods of the ChatMessageStore protocol and provides additional
|
||||
list-like operations for direct message manipulation.
|
||||
|
||||
The store maintains messages in memory and provides methods to serialize
|
||||
and deserialize the state for persistence purposes.
|
||||
"""
|
||||
|
||||
def __init__(self, messages: Sequence[ChatMessage] | None = None) -> None:
|
||||
"""Initialize the message store with optional initial messages.
|
||||
@classmethod
|
||||
async def deserialize(
|
||||
cls: type[TAgentThread],
|
||||
serialized_thread_state: dict[str, Any],
|
||||
*,
|
||||
message_store: ChatMessageStoreProtocol | None = None,
|
||||
**kwargs: Any,
|
||||
) -> TAgentThread:
|
||||
"""Deserializes the state from a dictionary into a new AgentThread instance.
|
||||
|
||||
Args:
|
||||
messages: Optional collection of initial ChatMessage objects to store.
|
||||
"""
|
||||
self._messages: list[ChatMessage] = []
|
||||
if messages:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Add messages to the store.
|
||||
|
||||
Args:
|
||||
messages: Sequence of ChatMessage objects to add to the store.
|
||||
"""
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Get all messages from the store in chronological order.
|
||||
|
||||
Returns:
|
||||
List of ChatMessage objects, ordered from oldest to newest.
|
||||
"""
|
||||
return self._messages
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserialize state data into this store instance.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data containing messages.
|
||||
serialized_thread_state: The serialized thread state as a dictionary.
|
||||
message_store: Optional ChatMessageStoreProtocol to use for managing messages.
|
||||
If not provided, a new ChatMessageStore will be created if needed.
|
||||
**kwargs: Additional arguments for deserialization.
|
||||
"""
|
||||
if serialized_store_state:
|
||||
state = StoreState.model_validate(obj=serialized_store_state, **kwargs)
|
||||
if state.messages:
|
||||
self._messages.extend(state.messages)
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
"""Serialize the current store state for persistence.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional arguments for serialization.
|
||||
|
||||
Returns:
|
||||
Serialized state data that can be used with deserialize_state.
|
||||
A new AgentThread instance with properties set from the serialized state.
|
||||
"""
|
||||
state = StoreState(messages=self._messages)
|
||||
return state.model_dump(**kwargs)
|
||||
state = AgentThreadState.model_validate(serialized_thread_state)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of messages in the store.
|
||||
if state.service_thread_id is not None:
|
||||
return cls(service_thread_id=state.service_thread_id)
|
||||
|
||||
Returns:
|
||||
The count of messages currently stored.
|
||||
"""
|
||||
return len(self._messages)
|
||||
# If we don't have any ChatMessageStoreProtocol state return here.
|
||||
if state.chat_message_store_state is None:
|
||||
return cls()
|
||||
|
||||
def __getitem__(self, index: int) -> ChatMessage:
|
||||
"""Get a message by index.
|
||||
if message_store is not None:
|
||||
try:
|
||||
await message_store.update_from_state(state.chat_message_store_state, **kwargs)
|
||||
except Exception as ex:
|
||||
raise AgentThreadException("Failed to deserialize the provided message store.") from ex
|
||||
return cls(message_store=message_store)
|
||||
try:
|
||||
message_store = await ChatMessageStore.deserialize(state.chat_message_store_state, **kwargs)
|
||||
except Exception as ex:
|
||||
raise AgentThreadException("Failed to deserialize the message store.") from ex
|
||||
return cls(message_store=message_store)
|
||||
|
||||
Args:
|
||||
index: The index of the message to retrieve.
|
||||
async def update_from_thread_state(
|
||||
self,
|
||||
serialized_thread_state: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Deserializes the state from a dictionary into the thread properties."""
|
||||
state = AgentThreadState.model_validate(serialized_thread_state)
|
||||
|
||||
Returns:
|
||||
The ChatMessage at the specified index.
|
||||
"""
|
||||
return self._messages[index]
|
||||
|
||||
def __setitem__(self, index: int, item: ChatMessage) -> None:
|
||||
"""Set a message at the specified index.
|
||||
|
||||
Args:
|
||||
index: The index at which to set the message.
|
||||
item: The ChatMessage to set at the specified index.
|
||||
"""
|
||||
self._messages[index] = item
|
||||
|
||||
def append(self, item: ChatMessage) -> None:
|
||||
"""Append a message to the end of the store.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to append.
|
||||
"""
|
||||
self._messages.append(item)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all messages from the store."""
|
||||
self._messages.clear()
|
||||
|
||||
def index(self, item: ChatMessage) -> int:
|
||||
"""Return the index of the first occurrence of the specified message.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to find.
|
||||
|
||||
Returns:
|
||||
The index of the first occurrence of the message.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message is not found in the store.
|
||||
"""
|
||||
return self._messages.index(item)
|
||||
|
||||
def insert(self, index: int, item: ChatMessage) -> None:
|
||||
"""Insert a message at the specified index.
|
||||
|
||||
Args:
|
||||
index: The index at which to insert the message.
|
||||
item: The ChatMessage to insert.
|
||||
"""
|
||||
self._messages.insert(index, item)
|
||||
|
||||
def remove(self, item: ChatMessage) -> None:
|
||||
"""Remove the first occurrence of the specified message from the store.
|
||||
|
||||
Args:
|
||||
item: The ChatMessage to remove.
|
||||
|
||||
Raises:
|
||||
ValueError: If the message is not found in the store.
|
||||
"""
|
||||
self._messages.remove(item)
|
||||
|
||||
def pop(self, index: int = -1) -> ChatMessage:
|
||||
"""Remove and return a message at the specified index.
|
||||
|
||||
Args:
|
||||
index: The index of the message to remove and return. Defaults to -1 (last item).
|
||||
|
||||
Returns:
|
||||
The ChatMessage that was removed.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of range.
|
||||
"""
|
||||
return self._messages.pop(index)
|
||||
if state.service_thread_id is not None:
|
||||
self.service_thread_id = state.service_thread_id
|
||||
# Since we have an ID, we should not have a chat message store and we can return here.
|
||||
return
|
||||
# If we don't have any ChatMessageStoreProtocol state return here.
|
||||
if state.chat_message_store_state is None:
|
||||
return
|
||||
if self.message_store is not None:
|
||||
await self.message_store.update_from_state(state.chat_message_store_state, **kwargs)
|
||||
# If we don't have a chat message store yet, create an in-memory one.
|
||||
return
|
||||
# Create the message store from the default.
|
||||
self.message_store = await ChatMessageStore.deserialize(state.chat_message_store_state, **kwargs) # type: ignore
|
||||
|
||||
@@ -1799,6 +1799,7 @@ class ChatOptions(AFBaseModel):
|
||||
allow_multiple_tool_calls: bool | None = None
|
||||
conversation_id: str | None = None
|
||||
frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
|
||||
instructions: str | None = None
|
||||
logit_bias: MutableMapping[str | int, float] | None = None
|
||||
max_tokens: Annotated[int | None, Field(gt=0)] = None
|
||||
metadata: MutableMapping[str, str] | None = None
|
||||
@@ -1811,7 +1812,7 @@ class ChatOptions(AFBaseModel):
|
||||
store: bool | None = None
|
||||
temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None
|
||||
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None = None
|
||||
tools: list[ToolProtocol | MutableMapping[str, Any]] | None = None
|
||||
tools: MutableSequence[ToolProtocol | MutableMapping[str, Any]] | None = None
|
||||
top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
|
||||
user: str | None = None
|
||||
|
||||
@@ -1902,11 +1903,13 @@ class ChatOptions(AFBaseModel):
|
||||
# tool_choice has a specialized serialize method. Save it here so we can fix it later.
|
||||
tool_choice = other.tool_choice or self.tool_choice
|
||||
updated_values = other.model_dump(exclude_none=True, exclude={"tools"})
|
||||
|
||||
logit_bias = updated_values.pop("logit_bias", {})
|
||||
metadata = updated_values.pop("metadata", {})
|
||||
additional_properties = updated_values.pop("additional_properties", {})
|
||||
combined = self.model_copy(update=updated_values)
|
||||
combined.tool_choice = tool_choice
|
||||
combined.instructions = " ".join([combined.instructions or "", other.instructions or ""])
|
||||
combined.logit_bias = {**(combined.logit_bias or {}), **logit_bias}
|
||||
combined.metadata = {**(combined.metadata or {}), **metadata}
|
||||
combined.additional_properties = {**(combined.additional_properties or {}), **additional_properties}
|
||||
|
||||
@@ -187,5 +187,3 @@ __all__ = [
|
||||
with contextlib.suppress(AttributeError, TypeError, ValueError):
|
||||
# Rebuild WorkflowExecutor to resolve Workflow forward reference
|
||||
WorkflowExecutor.model_rebuild()
|
||||
# Rebuild WorkflowAgent to resolve Workflow forward reference
|
||||
WorkflowAgent.model_rebuild()
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import AsyncIterable, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
@@ -21,7 +21,6 @@ from agent_framework import (
|
||||
UsageDetails,
|
||||
)
|
||||
|
||||
from .._pydantic import AFBaseModel
|
||||
from ..exceptions import AgentExecutionException
|
||||
from ._events import (
|
||||
AgentRunUpdateEvent,
|
||||
@@ -41,15 +40,10 @@ class WorkflowAgent(BaseAgent):
|
||||
# Class variable for the request info function name
|
||||
REQUEST_INFO_FUNCTION_NAME: ClassVar[str] = "request_info"
|
||||
|
||||
class RequestInfoFunctionArgs(AFBaseModel):
|
||||
class RequestInfoFunctionArgs(BaseModel):
|
||||
request_id: str
|
||||
data: Any
|
||||
|
||||
workflow: "Workflow" = Field(description="The workflow wrapped as an agent")
|
||||
pending_requests: dict[str, RequestInfoEvent] = Field(
|
||||
default_factory=dict, description="Pending request info events"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow: "Workflow",
|
||||
@@ -70,9 +64,6 @@ class WorkflowAgent(BaseAgent):
|
||||
"""
|
||||
if id is None:
|
||||
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
|
||||
# Initialize with standard BaseAgent parameters first
|
||||
kwargs["workflow"] = workflow
|
||||
|
||||
# Validate the workflow's start executor can handle agent-facing message inputs
|
||||
try:
|
||||
start_executor = workflow.get_start_executor()
|
||||
@@ -84,6 +75,9 @@ class WorkflowAgent(BaseAgent):
|
||||
|
||||
super().__init__(id=id, name=name, description=description, **kwargs)
|
||||
|
||||
self.workflow: "Workflow" = workflow
|
||||
self.pending_requests: dict[str, RequestInfoEvent] = {}
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
@@ -116,8 +110,7 @@ class WorkflowAgent(BaseAgent):
|
||||
response = self.merge_updates(response_updates, response_id)
|
||||
|
||||
# Notify thread of new messages (both input and response messages)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
|
||||
|
||||
return response
|
||||
|
||||
@@ -151,8 +144,7 @@ class WorkflowAgent(BaseAgent):
|
||||
response = self.merge_updates(response_updates, response_id)
|
||||
|
||||
# Notify thread of new messages (both input and response messages)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response.messages)
|
||||
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
|
||||
|
||||
async def _run_stream_impl(
|
||||
self,
|
||||
|
||||
@@ -49,6 +49,12 @@ class AgentInitializationError(AgentException):
|
||||
pass
|
||||
|
||||
|
||||
class AgentThreadException(AgentException):
|
||||
"""An error occurred while managing the agent thread."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ChatClientException(AgentFrameworkException):
|
||||
"""An error occurred while dealing with a chat client."""
|
||||
|
||||
|
||||
@@ -407,7 +407,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
"json_schema": chat_options.response_format.model_json_schema(),
|
||||
}
|
||||
|
||||
instructions: list[str] = []
|
||||
instructions: list[str] = [chat_options.instructions] if chat_options and chat_options.instructions else []
|
||||
tool_results: list[FunctionResultContent] | None = None
|
||||
|
||||
additional_messages: list[AdditionalMessage] | None = None
|
||||
|
||||
@@ -119,7 +119,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
|
||||
# region content creation
|
||||
|
||||
def _chat_to_tool_spec(self, tools: list[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _chat_to_tool_spec(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
chat_tools: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, ToolProtocol):
|
||||
@@ -132,7 +132,9 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
chat_tools.append(tool if isinstance(tool, dict) else dict(tool))
|
||||
return chat_tools
|
||||
|
||||
def _process_web_search_tool(self, tools: list[ToolProtocol | MutableMapping[str, Any]]) -> dict[str, Any] | None:
|
||||
def _process_web_search_tool(
|
||||
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
for tool in tools:
|
||||
if isinstance(tool, HostedWebSearchTool):
|
||||
# Web search tool requires special handling
|
||||
@@ -152,6 +154,9 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
|
||||
# Preprocess web search tool if it exists
|
||||
options_dict = chat_options.to_provider_settings()
|
||||
instructions = options_dict.pop("instructions", None)
|
||||
if instructions:
|
||||
messages = [ChatMessage(role="system", text=instructions), *messages]
|
||||
if messages and "messages" not in options_dict:
|
||||
options_dict["messages"] = self._prepare_chat_history_for_request(messages)
|
||||
if "messages" not in options_dict:
|
||||
|
||||
@@ -172,7 +172,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
# region Prep methods
|
||||
|
||||
def _tools_to_response_tools(
|
||||
self, tools: list[ToolProtocol | MutableMapping[str, Any]]
|
||||
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
|
||||
) -> list[ToolParam | dict[str, Any]]:
|
||||
response_tools: list[ToolParam | dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
@@ -314,6 +314,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
options_dict["user"] = chat_options.user
|
||||
|
||||
# messages
|
||||
if instructions := options_dict.pop("instructions", None):
|
||||
messages = [ChatMessage(role="system", text=instructions), *messages]
|
||||
request_input = self._prepare_chat_messages_for_request(messages)
|
||||
if not request_input:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
|
||||
@@ -141,7 +141,7 @@ class MockBaseChatClient(BaseChatClient):
|
||||
logger.debug(f"Running base chat client inner, with: {messages=}, {chat_options=}, {kwargs=}")
|
||||
self.call_count += 1
|
||||
if not self.run_responses:
|
||||
return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[0].text}"))
|
||||
return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[-1].text}"))
|
||||
|
||||
response = self.run_responses.pop(0)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable, MutableSequence, Sequence
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pytest import raises
|
||||
@@ -10,17 +11,18 @@ from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
AggregateContextProvider,
|
||||
ChatAgent,
|
||||
ChatClientProtocol,
|
||||
ChatMessage,
|
||||
ChatMessageList,
|
||||
ChatMessageStore,
|
||||
ChatResponse,
|
||||
Contents,
|
||||
Context,
|
||||
ContextProvider,
|
||||
HostedCodeInterpreterTool,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework._memory import AggregateContextProvider, Context, ContextProvider
|
||||
from agent_framework.exceptions import AgentExecutionException
|
||||
|
||||
|
||||
@@ -98,11 +100,10 @@ async def test_chat_client_agent_get_new_thread(chat_client: ChatClientProtocol)
|
||||
async def test_chat_client_agent_prepare_thread_and_messages(chat_client: ChatClientProtocol) -> None:
|
||||
agent = ChatAgent(chat_client=chat_client)
|
||||
message = ChatMessage(role=Role.USER, text="Hello")
|
||||
thread = AgentThread(message_store=ChatMessageList(messages=[message]))
|
||||
thread = AgentThread(message_store=ChatMessageStore(messages=[message]))
|
||||
|
||||
_, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
_, _, result_messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=thread,
|
||||
context=Context(),
|
||||
input_messages=[ChatMessage(role=Role.USER, text="Test")],
|
||||
)
|
||||
|
||||
@@ -152,7 +153,7 @@ async def test_chat_client_agent_update_thread_conversation_id_missing(chat_clie
|
||||
thread = AgentThread(service_thread_id="123")
|
||||
|
||||
with raises(AgentExecutionException, match="Service did not return a valid conversation id"):
|
||||
agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
|
||||
await agent._update_thread_with_type_and_conversation_id(thread, None) # type: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_chat_client_agent_default_author_name(chat_client: ChatClientProtocol) -> None:
|
||||
@@ -191,49 +192,49 @@ async def test_chat_client_agent_author_name_is_used_from_response(chat_client_b
|
||||
|
||||
# Mock context provider for testing
|
||||
class MockContextProvider(ContextProvider):
|
||||
context_contents: list[Contents] | None = None
|
||||
thread_created_called: bool = False
|
||||
messages_adding_called: bool = False
|
||||
model_invoking_called: bool = False
|
||||
thread_created_thread_id: str | None = None
|
||||
messages_adding_thread_id: str | None = None
|
||||
new_messages: list[ChatMessage] = []
|
||||
|
||||
def __init__(self, contents: list[Contents] | None = None) -> None:
|
||||
super().__init__()
|
||||
self.context_contents = contents
|
||||
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
|
||||
self.context_messages = messages
|
||||
self.thread_created_called = False
|
||||
self.messages_adding_called = False
|
||||
self.model_invoking_called = False
|
||||
self.invoked_called = False
|
||||
self.invoking_called = False
|
||||
self.thread_created_thread_id = None
|
||||
self.messages_adding_thread_id = None
|
||||
self.new_messages = []
|
||||
self.invoked_thread_id = None
|
||||
self.new_messages: list[ChatMessage] = []
|
||||
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
self.thread_created_called = True
|
||||
self.thread_created_thread_id = thread_id
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
self.messages_adding_called = True
|
||||
self.messages_adding_thread_id = thread_id
|
||||
if isinstance(new_messages, ChatMessage):
|
||||
self.new_messages.append(new_messages)
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
|
||||
invoke_exception: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.invoked_called = True
|
||||
if isinstance(request_messages, ChatMessage):
|
||||
self.new_messages.append(request_messages)
|
||||
else:
|
||||
self.new_messages.extend(new_messages)
|
||||
self.new_messages.extend(request_messages)
|
||||
if isinstance(response_messages, ChatMessage):
|
||||
self.new_messages.append(response_messages)
|
||||
else:
|
||||
self.new_messages.extend(response_messages)
|
||||
|
||||
async def model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
self.model_invoking_called = True
|
||||
return Context(contents=self.context_contents)
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
self.invoking_called = True
|
||||
return Context(messages=self.context_messages)
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_model_invoking(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that context providers' model_invoking is called during agent run."""
|
||||
mock_provider = MockContextProvider(contents=[TextContent("Test context instructions")])
|
||||
"""Test that context providers' invoking is called during agent run."""
|
||||
mock_provider = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Test context instructions")])
|
||||
agent = ChatAgent(chat_client=chat_client, context_providers=mock_provider)
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
assert mock_provider.model_invoking_called
|
||||
assert mock_provider.invoking_called
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_thread_created(chat_client_base: ChatClientProtocol) -> None:
|
||||
@@ -255,75 +256,54 @@ async def test_chat_agent_context_providers_thread_created(chat_client_base: Cha
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_messages_adding(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that context providers' messages_adding is called during agent run."""
|
||||
"""Test that context providers' invoked is called during agent run."""
|
||||
mock_provider = MockContextProvider()
|
||||
agent = ChatAgent(chat_client=chat_client, context_providers=mock_provider)
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
assert mock_provider.messages_adding_called
|
||||
assert mock_provider.invoked_called
|
||||
# Should be called with both input and response messages
|
||||
assert len(mock_provider.new_messages) >= 2
|
||||
|
||||
|
||||
async def test_chat_agent_context_instructions_in_messages(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that AI context instructions are included in messages."""
|
||||
mock_provider = MockContextProvider(contents=[TextContent("Context-specific instructions")])
|
||||
mock_provider = MockContextProvider(messages=[ChatMessage(role="system", text="Context-specific instructions")])
|
||||
agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions", context_providers=mock_provider)
|
||||
|
||||
# We need to test the _prepare_thread_and_messages method directly
|
||||
context = Context(contents=[TextContent("Context-specific instructions")])
|
||||
_, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, context=context, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
|
||||
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
|
||||
)
|
||||
|
||||
# Should have agent instructions, context instructions, and user message
|
||||
assert len(messages) == 3
|
||||
assert messages[0].role == Role.SYSTEM
|
||||
assert messages[0].text == "Agent instructions"
|
||||
assert messages[1].role == Role.SYSTEM
|
||||
assert messages[1].text == "Context-specific instructions"
|
||||
assert messages[2].role == Role.USER
|
||||
assert messages[2].text == "Hello"
|
||||
|
||||
|
||||
async def test_chat_agent_context_instructions_without_agent_instructions(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that AI context instructions work when agent has no instructions."""
|
||||
agent = ChatAgent(chat_client=chat_client) # No instructions
|
||||
context = Context(contents=[TextContent("Context-only instructions")])
|
||||
|
||||
_, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, context=context, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
|
||||
)
|
||||
|
||||
# Should have context instructions and user message only
|
||||
# Should have context instructions, and user message
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == Role.SYSTEM
|
||||
assert messages[0].text == "Context-only instructions"
|
||||
assert messages[0].text == "Context-specific instructions"
|
||||
assert messages[1].role == Role.USER
|
||||
assert messages[1].text == "Hello"
|
||||
# instructions system message is added by a chat_client
|
||||
|
||||
|
||||
async def test_chat_agent_no_context_instructions(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test behavior when AI context has no instructions."""
|
||||
agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions")
|
||||
context = Context() # No instructions
|
||||
mock_provider = MockContextProvider()
|
||||
agent = ChatAgent(chat_client=chat_client, instructions="Agent instructions", context_providers=mock_provider)
|
||||
|
||||
_, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, context=context, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
|
||||
_, _, messages = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
|
||||
thread=None, input_messages=[ChatMessage(role=Role.USER, text="Hello")]
|
||||
)
|
||||
|
||||
# Should have agent instructions and user message only
|
||||
assert len(messages) == 2
|
||||
assert messages[0].role == Role.SYSTEM
|
||||
assert messages[0].text == "Agent instructions"
|
||||
assert messages[1].role == Role.USER
|
||||
assert messages[1].text == "Hello"
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == Role.USER
|
||||
assert messages[0].text == "Hello"
|
||||
|
||||
|
||||
async def test_chat_agent_run_stream_context_providers(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that context providers work with run_stream method."""
|
||||
mock_provider = MockContextProvider(contents=[TextContent("Stream context instructions")])
|
||||
mock_provider = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Stream context instructions")])
|
||||
agent = ChatAgent(chat_client=chat_client, context_providers=mock_provider)
|
||||
|
||||
# Collect all stream updates
|
||||
@@ -332,47 +312,48 @@ async def test_chat_agent_run_stream_context_providers(chat_client: ChatClientPr
|
||||
updates.append(update)
|
||||
|
||||
# Verify context provider was called
|
||||
assert mock_provider.model_invoking_called
|
||||
assert mock_provider.thread_created_called
|
||||
assert mock_provider.messages_adding_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
|
||||
|
||||
|
||||
async def test_chat_agent_multiple_context_providers(chat_client: ChatClientProtocol) -> None:
|
||||
"""Test that multiple context providers work together."""
|
||||
provider1 = MockContextProvider(contents=[TextContent("First provider instructions")])
|
||||
provider2 = MockContextProvider(contents=[TextContent("Second provider instructions")])
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="First provider instructions")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Second provider instructions")])
|
||||
|
||||
agent = ChatAgent(chat_client=chat_client, context_providers=[provider1, provider2])
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
# Both providers should be called
|
||||
assert provider1.model_invoking_called
|
||||
assert provider1.thread_created_called
|
||||
assert provider1.messages_adding_called
|
||||
assert provider1.invoking_called
|
||||
assert not provider1.thread_created_called
|
||||
assert provider1.invoked_called
|
||||
|
||||
assert provider2.model_invoking_called
|
||||
assert provider2.thread_created_called
|
||||
assert provider2.messages_adding_called
|
||||
assert provider2.invoking_called
|
||||
assert not provider2.thread_created_called
|
||||
assert provider2.invoked_called
|
||||
|
||||
|
||||
async def test_chat_agent_aggregate_context_provider_combines_instructions() -> None:
|
||||
"""Test that AggregateContextProvider combines instructions from multiple providers."""
|
||||
provider1 = MockContextProvider(contents=[TextContent("First instruction")])
|
||||
provider2 = MockContextProvider(contents=[TextContent("Second instruction")])
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="First instruction")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role=Role.SYSTEM, text="Second instruction")])
|
||||
|
||||
aggregate = AggregateContextProvider()
|
||||
aggregate.providers.append(provider1)
|
||||
aggregate.providers.append(provider2)
|
||||
|
||||
# Test model_invoking combines instructions
|
||||
result = await aggregate.model_invoking([ChatMessage(role=Role.USER, text="Test")])
|
||||
# Test invoking combines instructions
|
||||
result = await aggregate.invoking([ChatMessage(role=Role.USER, text="Test")])
|
||||
|
||||
assert result.contents
|
||||
assert isinstance(result.contents[0], TextContent)
|
||||
assert isinstance(result.contents[1], TextContent)
|
||||
assert result.contents[0].text == "First instruction"
|
||||
assert result.contents[1].text == "Second instruction"
|
||||
assert result.messages
|
||||
assert isinstance(result.messages[0], ChatMessage)
|
||||
assert isinstance(result.messages[1], ChatMessage)
|
||||
assert result.messages[0].text == "First instruction"
|
||||
assert result.messages[1].text == "Second instruction"
|
||||
|
||||
|
||||
async def test_chat_agent_context_providers_with_thread_service_id(chat_client_base: ChatClientProtocol) -> None:
|
||||
@@ -388,12 +369,11 @@ async def test_chat_agent_context_providers_with_thread_service_id(chat_client_b
|
||||
agent = ChatAgent(chat_client=chat_client_base, context_providers=mock_provider)
|
||||
|
||||
# Use existing service-managed thread
|
||||
thread = AgentThread(service_thread_id="existing-thread-id")
|
||||
thread = agent.get_new_thread(service_thread_id="existing-thread-id")
|
||||
await agent.run("Hello", thread=thread)
|
||||
|
||||
# messages_adding should be called with the service thread ID from response
|
||||
assert mock_provider.messages_adding_called
|
||||
assert mock_provider.messages_adding_thread_id == "service-thread-123" # Updated thread ID from response
|
||||
# invoked should be called with the service thread ID from response
|
||||
assert mock_provider.invoked_called
|
||||
|
||||
|
||||
# Tests for as_tool method
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from collections.abc import MutableSequence
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from agent_framework import ChatMessage, Contents, Role, TextContent
|
||||
from agent_framework import ChatMessage, Role, TextContent
|
||||
from agent_framework._memory import AggregateContextProvider, Context, ContextProvider
|
||||
|
||||
|
||||
class MockContextProvider(ContextProvider):
|
||||
"""Mock ContextProvider for testing."""
|
||||
|
||||
context_contents: list[Contents] | None = None
|
||||
thread_created_called: bool = False
|
||||
messages_adding_called: bool = False
|
||||
model_invoking_called: bool = False
|
||||
thread_created_thread_id: str | None = None
|
||||
messages_adding_thread_id: str | None = None
|
||||
messages_adding_new_messages: ChatMessage | Sequence[ChatMessage] | None = None
|
||||
model_invoking_messages: ChatMessage | MutableSequence[ChatMessage] | None = None
|
||||
|
||||
def __init__(self, context_contents: list[Contents] | None = None) -> None:
|
||||
super().__init__()
|
||||
self.context_contents = context_contents
|
||||
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
|
||||
self.context_messages = messages
|
||||
self.thread_created_called = False
|
||||
self.messages_adding_called = False
|
||||
self.model_invoking_called = False
|
||||
self.invoked_called = False
|
||||
self.invoking_called = False
|
||||
self.thread_created_thread_id = None
|
||||
self.messages_adding_thread_id = None
|
||||
self.messages_adding_new_messages = None
|
||||
self.new_messages = None
|
||||
self.model_invoking_messages = None
|
||||
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
@@ -35,18 +25,23 @@ class MockContextProvider(ContextProvider):
|
||||
self.thread_created_called = True
|
||||
self.thread_created_thread_id = thread_id
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Track messages_adding calls."""
|
||||
self.messages_adding_called = True
|
||||
self.messages_adding_thread_id = thread_id
|
||||
self.messages_adding_new_messages = new_messages
|
||||
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 model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
"""Track model_invoking calls and return context."""
|
||||
self.model_invoking_called = True
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
"""Track invoking calls and return context."""
|
||||
self.invoking_called = True
|
||||
self.model_invoking_messages = messages
|
||||
context = Context()
|
||||
context.contents = self.context_contents
|
||||
context.messages = self.context_messages
|
||||
return context
|
||||
|
||||
|
||||
@@ -65,19 +60,21 @@ class TestAggregateContextProvider:
|
||||
|
||||
def test_init_with_providers(self) -> None:
|
||||
"""Test initialization with providers."""
|
||||
provider1 = MockContextProvider([TextContent("instructions1")])
|
||||
provider2 = MockContextProvider([TextContent("instructions1")])
|
||||
providers = [provider1, provider2]
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
|
||||
provider3 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 3")])
|
||||
providers = [provider1, provider2, provider3]
|
||||
|
||||
aggregate = AggregateContextProvider(providers)
|
||||
assert len(aggregate.providers) == 2
|
||||
assert len(aggregate.providers) == 3
|
||||
assert aggregate.providers[0] is provider1
|
||||
assert aggregate.providers[1] is provider2
|
||||
assert aggregate.providers[2] is provider3
|
||||
|
||||
def test_add_provider(self) -> None:
|
||||
"""Test adding a provider."""
|
||||
aggregate = AggregateContextProvider()
|
||||
provider = MockContextProvider([TextContent("instructions")])
|
||||
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions")])
|
||||
|
||||
aggregate.add(provider)
|
||||
assert len(aggregate.providers) == 1
|
||||
@@ -86,8 +83,8 @@ class TestAggregateContextProvider:
|
||||
def test_add_multiple_providers(self) -> None:
|
||||
"""Test adding multiple providers."""
|
||||
aggregate = AggregateContextProvider()
|
||||
provider1 = MockContextProvider([TextContent("instructions1")])
|
||||
provider2 = MockContextProvider([TextContent("instructions2")])
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
|
||||
|
||||
aggregate.add(provider1)
|
||||
aggregate.add(provider2)
|
||||
@@ -105,8 +102,8 @@ class TestAggregateContextProvider:
|
||||
|
||||
async def test_thread_created_with_providers(self) -> None:
|
||||
"""Test thread_created calls all providers."""
|
||||
provider1 = MockContextProvider([TextContent("instructions1")])
|
||||
provider2 = MockContextProvider([TextContent("instructions2")])
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
|
||||
aggregate = AggregateContextProvider([provider1, provider2])
|
||||
|
||||
thread_id = "thread-123"
|
||||
@@ -119,7 +116,7 @@ class TestAggregateContextProvider:
|
||||
|
||||
async def test_thread_created_with_none_thread_id(self) -> None:
|
||||
"""Test thread_created with None thread_id."""
|
||||
provider = MockContextProvider([TextContent("instructions")])
|
||||
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions")])
|
||||
aggregate = AggregateContextProvider([provider])
|
||||
|
||||
await aggregate.thread_created(None)
|
||||
@@ -128,155 +125,150 @@ class TestAggregateContextProvider:
|
||||
assert provider.thread_created_thread_id is None
|
||||
|
||||
async def test_messages_adding_with_no_providers(self) -> None:
|
||||
"""Test messages_adding with no providers."""
|
||||
"""Test invoked with no providers."""
|
||||
aggregate = AggregateContextProvider()
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
|
||||
# Should not raise an exception
|
||||
await aggregate.messages_adding("thread-123", message)
|
||||
await aggregate.invoked(message)
|
||||
|
||||
async def test_messages_adding_with_single_message(self) -> None:
|
||||
"""Test messages_adding with a single message."""
|
||||
provider1 = MockContextProvider([TextContent("instructions1")])
|
||||
provider2 = MockContextProvider([TextContent("instructions2")])
|
||||
"""Test invoked with a single message."""
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
|
||||
aggregate = AggregateContextProvider([provider1, provider2])
|
||||
|
||||
thread_id = "thread-123"
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
await aggregate.messages_adding(thread_id, message)
|
||||
await aggregate.invoked(message)
|
||||
|
||||
assert provider1.messages_adding_called
|
||||
assert provider1.messages_adding_thread_id == thread_id
|
||||
assert provider1.messages_adding_new_messages == message
|
||||
assert provider2.messages_adding_called
|
||||
assert provider2.messages_adding_thread_id == thread_id
|
||||
assert provider2.messages_adding_new_messages == message
|
||||
assert provider1.invoked_called
|
||||
assert provider1.new_messages == message
|
||||
assert provider2.invoked_called
|
||||
assert provider2.new_messages == message
|
||||
|
||||
async def test_messages_adding_with_message_sequence(self) -> None:
|
||||
"""Test messages_adding with a sequence of messages."""
|
||||
provider = MockContextProvider([TextContent("instructions")])
|
||||
"""Test invoked with a sequence of messages."""
|
||||
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions")])
|
||||
aggregate = AggregateContextProvider([provider])
|
||||
|
||||
thread_id = "thread-123"
|
||||
messages = [
|
||||
ChatMessage(text="Hello", role=Role.USER),
|
||||
ChatMessage(text="Hi there", role=Role.ASSISTANT),
|
||||
]
|
||||
await aggregate.messages_adding(thread_id, messages)
|
||||
await aggregate.invoked(messages)
|
||||
|
||||
assert provider.messages_adding_called
|
||||
assert provider.messages_adding_thread_id == thread_id
|
||||
assert provider.messages_adding_new_messages == messages
|
||||
assert provider.invoked_called
|
||||
assert provider.new_messages == messages
|
||||
|
||||
async def test_model_invoking_with_no_providers(self) -> None:
|
||||
"""Test model_invoking with no providers."""
|
||||
"""Test invoking with no providers."""
|
||||
aggregate = AggregateContextProvider()
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
|
||||
context = await aggregate.model_invoking(message)
|
||||
context = await aggregate.invoking(message)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert not context.contents
|
||||
assert not context.messages
|
||||
|
||||
async def test_model_invoking_with_single_provider(self) -> None:
|
||||
"""Test model_invoking with a single provider."""
|
||||
provider = MockContextProvider([TextContent("Test instructions")])
|
||||
"""Test invoking with a single provider."""
|
||||
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Test instructions")])
|
||||
aggregate = AggregateContextProvider([provider])
|
||||
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
context = await aggregate.model_invoking(message)
|
||||
message = [ChatMessage(text="Hello", role=Role.USER)]
|
||||
context = await aggregate.invoking(message)
|
||||
|
||||
assert provider.model_invoking_called
|
||||
assert provider.invoking_called
|
||||
assert provider.model_invoking_messages == message
|
||||
assert isinstance(context, Context)
|
||||
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert context.contents[0].text == "Test instructions"
|
||||
assert context.messages
|
||||
assert isinstance(context.messages[0].contents[0], TextContent)
|
||||
assert context.messages[0].text == "Test instructions"
|
||||
|
||||
async def test_model_invoking_with_multiple_providers(self) -> None:
|
||||
"""Test model_invoking combines contexts from multiple providers."""
|
||||
provider1 = MockContextProvider([TextContent("Instructions 1")])
|
||||
provider2 = MockContextProvider([TextContent("Instructions 2")])
|
||||
provider3 = MockContextProvider([TextContent("Instructions 3")])
|
||||
"""Test invoking combines contexts from multiple providers."""
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
|
||||
provider2 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 2")])
|
||||
provider3 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 3")])
|
||||
aggregate = AggregateContextProvider([provider1, provider2, provider3])
|
||||
|
||||
messages = [ChatMessage(text="Hello", role=Role.USER)]
|
||||
context = await aggregate.model_invoking(messages)
|
||||
context = await aggregate.invoking(messages)
|
||||
|
||||
assert provider1.model_invoking_called
|
||||
assert provider1.invoking_called
|
||||
assert provider1.model_invoking_messages == messages
|
||||
assert provider2.model_invoking_called
|
||||
assert provider2.invoking_called
|
||||
assert provider2.model_invoking_messages == messages
|
||||
assert provider3.model_invoking_called
|
||||
assert provider3.invoking_called
|
||||
assert provider3.model_invoking_messages == messages
|
||||
|
||||
assert isinstance(context, Context)
|
||||
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert isinstance(context.contents[1], TextContent)
|
||||
assert isinstance(context.contents[2], TextContent)
|
||||
assert context.contents[0].text == "Instructions 1"
|
||||
assert context.contents[1].text == "Instructions 2"
|
||||
assert context.contents[2].text == "Instructions 3"
|
||||
assert context.messages
|
||||
assert isinstance(context.messages[0].contents[0], TextContent)
|
||||
assert isinstance(context.messages[1].contents[0], TextContent)
|
||||
assert isinstance(context.messages[2].contents[0], TextContent)
|
||||
assert context.messages[0].text == "Instructions 1"
|
||||
assert context.messages[1].text == "Instructions 2"
|
||||
assert context.messages[2].text == "Instructions 3"
|
||||
|
||||
async def test_model_invoking_with_none_instructions(self) -> None:
|
||||
"""Test model_invoking filters out None instructions."""
|
||||
provider1 = MockContextProvider([TextContent("Instructions 1")])
|
||||
provider2 = MockContextProvider(None) # None instructions
|
||||
provider3 = MockContextProvider([TextContent("Instructions 3")])
|
||||
"""Test invoking filters out None instructions."""
|
||||
provider1 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 1")])
|
||||
provider2 = MockContextProvider(messages=None) # None instructions
|
||||
provider3 = MockContextProvider(messages=[ChatMessage(role="user", text="Instructions 3")])
|
||||
aggregate = AggregateContextProvider([provider1, provider2, provider3])
|
||||
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
context = await aggregate.model_invoking(message)
|
||||
context = await aggregate.invoking(message)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert isinstance(context.contents[1], TextContent)
|
||||
assert context.contents[0].text == "Instructions 1"
|
||||
assert context.contents[1].text == "Instructions 3"
|
||||
assert context.messages
|
||||
assert isinstance(context.messages[0].contents[0], TextContent)
|
||||
assert isinstance(context.messages[1].contents[0], TextContent)
|
||||
assert context.messages[0].text == "Instructions 1"
|
||||
assert context.messages[1].text == "Instructions 3"
|
||||
|
||||
async def test_model_invoking_with_all_none_instructions(self) -> None:
|
||||
"""Test model_invoking when all providers return None instructions."""
|
||||
"""Test invoking when all providers return None instructions."""
|
||||
provider1 = MockContextProvider(None)
|
||||
provider2 = MockContextProvider(None)
|
||||
aggregate = AggregateContextProvider([provider1, provider2])
|
||||
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
context = await aggregate.model_invoking(message)
|
||||
context = await aggregate.invoking(message)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert not context.contents
|
||||
assert not context.messages
|
||||
|
||||
async def test_model_invoking_with_mutable_sequence(self) -> None:
|
||||
"""Test model_invoking with MutableSequence of messages."""
|
||||
provider = MockContextProvider([TextContent("Test instructions")])
|
||||
"""Test invoking with MutableSequence of messages."""
|
||||
provider = MockContextProvider(messages=[ChatMessage(role="user", text="Test instructions")])
|
||||
aggregate = AggregateContextProvider([provider])
|
||||
|
||||
messages = [ChatMessage(text="Hello", role=Role.USER)]
|
||||
context = await aggregate.model_invoking(messages)
|
||||
context = await aggregate.invoking(messages)
|
||||
|
||||
assert provider.model_invoking_called
|
||||
assert provider.invoking_called
|
||||
assert provider.model_invoking_messages == messages
|
||||
assert isinstance(context, Context)
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert context.contents[0].text == "Test instructions"
|
||||
assert context.messages
|
||||
assert isinstance(context.messages[0].contents[0], TextContent)
|
||||
assert context.messages[0].text == "Test instructions"
|
||||
|
||||
async def test_async_methods_concurrent_execution(self) -> None:
|
||||
"""Test that async methods execute providers concurrently."""
|
||||
# Use AsyncMock to verify concurrent execution
|
||||
provider1 = Mock(spec=ContextProvider)
|
||||
provider1.thread_created = AsyncMock()
|
||||
provider1.messages_adding = AsyncMock()
|
||||
provider1.model_invoking = AsyncMock(return_value=Context(contents=[TextContent("Test 1")]))
|
||||
provider1.invoked = AsyncMock()
|
||||
provider1.invoking = AsyncMock(return_value=Context(messages=[ChatMessage(role="user", text="Test 1")]))
|
||||
|
||||
provider2 = Mock(spec=ContextProvider)
|
||||
provider2.thread_created = AsyncMock()
|
||||
provider2.messages_adding = AsyncMock()
|
||||
provider2.model_invoking = AsyncMock(return_value=Context(contents=[TextContent("Test 2")]))
|
||||
provider2.invoked = AsyncMock()
|
||||
provider2.invoking = AsyncMock(return_value=Context(messages=[ChatMessage(role="user", text="Test 2")]))
|
||||
|
||||
aggregate = AggregateContextProvider([provider1, provider2])
|
||||
|
||||
@@ -285,18 +277,20 @@ class TestAggregateContextProvider:
|
||||
provider1.thread_created.assert_called_once_with("thread-123")
|
||||
provider2.thread_created.assert_called_once_with("thread-123")
|
||||
|
||||
# Test messages_adding
|
||||
# Test invoked
|
||||
message = ChatMessage(text="Hello", role=Role.USER)
|
||||
await aggregate.messages_adding("thread-123", message)
|
||||
provider1.messages_adding.assert_called_once_with("thread-123", message)
|
||||
provider2.messages_adding.assert_called_once_with("thread-123", message)
|
||||
await aggregate.invoked(message)
|
||||
provider1.invoked.assert_called_once_with(
|
||||
request_messages=message, response_messages=None, invoke_exception=None
|
||||
)
|
||||
provider2.invoked.assert_called_once_with(
|
||||
request_messages=message, response_messages=None, invoke_exception=None
|
||||
)
|
||||
|
||||
# Test model_invoking
|
||||
context = await aggregate.model_invoking(message)
|
||||
provider1.model_invoking.assert_called_once_with(message)
|
||||
provider2.model_invoking.assert_called_once_with(message)
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert isinstance(context.contents[1], TextContent)
|
||||
assert context.contents[0].text == "Test 1"
|
||||
assert context.contents[1].text == "Test 2"
|
||||
# Test invoking
|
||||
context = await aggregate.invoking(message)
|
||||
provider1.invoking.assert_called_once_with(message)
|
||||
provider2.invoking.assert_called_once_with(message)
|
||||
assert context.messages
|
||||
assert context.messages[0].text == "Test 1"
|
||||
assert context.messages[1].text == "Test 2"
|
||||
|
||||
@@ -1505,9 +1505,13 @@ class TestChatAgentChatMiddleware:
|
||||
context: ChatContext, next: Callable[[ChatContext], Awaitable[None]]
|
||||
) -> None:
|
||||
# Modify the first message by adding a prefix
|
||||
if context.messages and len(context.messages) > 0:
|
||||
original_text = context.messages[0].text or ""
|
||||
context.messages[0] = ChatMessage(role=context.messages[0].role, text=f"MODIFIED: {original_text}")
|
||||
if context.messages:
|
||||
for idx, msg in enumerate(context.messages):
|
||||
if msg.role.value == "system":
|
||||
continue
|
||||
original_text = msg.text or ""
|
||||
context.messages[idx] = ChatMessage(role=msg.role, text=f"MODIFIED: {original_text}")
|
||||
break
|
||||
await next(context)
|
||||
|
||||
# Create ChatAgent with message-modifying middleware
|
||||
@@ -1519,8 +1523,7 @@ class TestChatAgentChatMiddleware:
|
||||
response = await agent.run(messages)
|
||||
|
||||
# Verify that the message was modified (MockBaseChatClient echoes back the input)
|
||||
assert response is not None
|
||||
assert len(response.messages) > 0
|
||||
assert response and response.messages
|
||||
assert "MODIFIED: test message" in response.messages[0].text
|
||||
|
||||
async def test_chat_middleware_can_override_response(self) -> None:
|
||||
|
||||
@@ -5,12 +5,13 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import AgentThread, ChatMessage, ChatMessageList, Role
|
||||
from agent_framework._threads import StoreState, ThreadState, deserialize_thread_state, thread_on_new_messages
|
||||
from agent_framework import AgentThread, ChatMessage, ChatMessageStore, Role
|
||||
from agent_framework._threads import AgentThreadState, ChatMessageStoreState
|
||||
from agent_framework.exceptions import AgentThreadException
|
||||
|
||||
|
||||
class MockChatMessageStore:
|
||||
"""Mock implementation of ChatMessageStore for testing."""
|
||||
"""Mock implementation of ChatMessageStoreProtocol for testing."""
|
||||
|
||||
def __init__(self, messages: list[ChatMessage] | None = None) -> None:
|
||||
self._messages = messages or []
|
||||
@@ -23,15 +24,21 @@ class MockChatMessageStore:
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
self._messages.extend(messages)
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
async def serialize(self, **kwargs: Any) -> Any:
|
||||
self._serialize_calls += 1
|
||||
return {"messages": [msg.__dict__ for msg in self._messages], "kwargs": kwargs}
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
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[ChatMessage]:
|
||||
@@ -67,7 +74,7 @@ class TestAgentThread:
|
||||
|
||||
def test_init_with_message_store(self) -> None:
|
||||
"""Test AgentThread initialization with message_store."""
|
||||
store = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is store
|
||||
@@ -81,11 +88,11 @@ class TestAgentThread:
|
||||
assert thread.service_thread_id == service_thread_id
|
||||
|
||||
def test_service_thread_id_setter_with_existing_message_store_raises_error(self) -> None:
|
||||
"""Test that setting service_thread_id when message_store exists raises ValueError."""
|
||||
store = ChatMessageList()
|
||||
"""Test that setting service_thread_id when message_store exists raises AgentThreadException."""
|
||||
store = ChatMessageStore()
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
with pytest.raises(ValueError, match="Only the service_thread_id or message_store may be set"):
|
||||
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:
|
||||
@@ -97,18 +104,18 @@ class TestAgentThread:
|
||||
def test_message_store_property_setter(self) -> None:
|
||||
"""Test message_store property setter."""
|
||||
thread = AgentThread()
|
||||
store = ChatMessageList()
|
||||
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 ValueError."""
|
||||
"""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 = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
|
||||
with pytest.raises(ValueError, match="Only the service_thread_id or message_store may be set"):
|
||||
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:
|
||||
@@ -119,7 +126,7 @@ class TestAgentThread:
|
||||
|
||||
async def test_get_messages_with_message_store(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test get_messages when message_store is set."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
store = ChatMessageStore(sample_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
assert thread.message_store is not None
|
||||
@@ -142,19 +149,19 @@ class TestAgentThread:
|
||||
"""Test _on_new_messages when service_thread_id is set (should do nothing)."""
|
||||
thread = AgentThread(service_thread_id="test-conv")
|
||||
|
||||
await thread_on_new_messages(thread, sample_message)
|
||||
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: ChatMessage) -> None:
|
||||
"""Test _on_new_messages with single message creates ChatMessageList."""
|
||||
"""Test _on_new_messages with single message creates ChatMessageStore."""
|
||||
thread = AgentThread()
|
||||
|
||||
await thread_on_new_messages(thread, sample_message)
|
||||
await thread.on_new_messages(sample_message)
|
||||
|
||||
assert thread.message_store is not None
|
||||
assert isinstance(thread.message_store, ChatMessageList)
|
||||
assert isinstance(thread.message_store, ChatMessageStore)
|
||||
messages = await thread.message_store.list_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].text == "Test message"
|
||||
@@ -163,7 +170,7 @@ class TestAgentThread:
|
||||
"""Test _on_new_messages with multiple messages."""
|
||||
thread = AgentThread()
|
||||
|
||||
await thread_on_new_messages(thread, sample_messages)
|
||||
await thread.on_new_messages(sample_messages)
|
||||
|
||||
assert thread.message_store is not None
|
||||
messages = await thread.message_store.list_messages()
|
||||
@@ -172,10 +179,10 @@ class TestAgentThread:
|
||||
async def test_on_new_messages_with_existing_store(self, sample_message: ChatMessage) -> None:
|
||||
"""Test _on_new_messages adds to existing message store."""
|
||||
initial_messages = [ChatMessage(role=Role.USER, text="Initial", message_id="init1")]
|
||||
store = ChatMessageList(initial_messages)
|
||||
store = ChatMessageStore(initial_messages)
|
||||
thread = AgentThread(message_store=store)
|
||||
|
||||
await thread_on_new_messages(thread, sample_message)
|
||||
await thread.on_new_messages(sample_message)
|
||||
|
||||
assert thread.message_store is not None
|
||||
messages = await thread.message_store.list_messages()
|
||||
@@ -185,32 +192,30 @@ class TestAgentThread:
|
||||
|
||||
async def test_deserialize_with_service_thread_id(self) -> None:
|
||||
"""Test _deserialize with service_thread_id."""
|
||||
thread = AgentThread()
|
||||
serialized_data = {"service_thread_id": "test-conv-123", "chat_message_store_state": None}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
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[ChatMessage]) -> None:
|
||||
"""Test _deserialize with chat_message_store_state."""
|
||||
thread = AgentThread()
|
||||
store_state = {"messages": sample_messages}
|
||||
serialized_data = {"service_thread_id": None, "chat_message_store_state": store_state}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
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, ChatMessageList)
|
||||
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 deserialize_thread_state(thread, serialized_data)
|
||||
await thread.deserialize(serialized_data)
|
||||
|
||||
assert thread.service_thread_id is None
|
||||
assert thread.message_store is None
|
||||
@@ -221,7 +226,7 @@ class TestAgentThread:
|
||||
thread = AgentThread(message_store=store)
|
||||
serialized_data: dict[str, Any] = {"service_thread_id": None, "chat_message_store_state": {"messages": []}}
|
||||
|
||||
await deserialize_thread_state(thread, serialized_data)
|
||||
await thread.update_from_thread_state(serialized_data)
|
||||
|
||||
assert store._deserialize_calls == 1 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@@ -265,31 +270,31 @@ class TestAgentThread:
|
||||
|
||||
|
||||
class TestChatMessageList:
|
||||
"""Test cases for ChatMessageList class."""
|
||||
"""Test cases for ChatMessageStore class."""
|
||||
|
||||
def test_init_empty(self) -> None:
|
||||
"""Test ChatMessageList initialization with no messages."""
|
||||
store = ChatMessageList()
|
||||
assert len(store) == 0
|
||||
"""Test ChatMessageStore initialization with no messages."""
|
||||
store = ChatMessageStore()
|
||||
assert len(store.messages) == 0
|
||||
|
||||
def test_init_with_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test ChatMessageList initialization with messages."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
assert len(store) == 3
|
||||
"""Test ChatMessageStore initialization with messages."""
|
||||
store = ChatMessageStore(sample_messages)
|
||||
assert len(store.messages) == 3
|
||||
|
||||
async def test_add_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test adding messages to the store."""
|
||||
store = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
|
||||
await store.add_messages(sample_messages)
|
||||
|
||||
assert len(store) == 3
|
||||
assert len(store.messages) == 3
|
||||
messages = await store.list_messages()
|
||||
assert messages[0].text == "Hello"
|
||||
|
||||
async def test_get_messages(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test getting messages from the store."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
store = ChatMessageStore(sample_messages)
|
||||
|
||||
messages = await store.list_messages()
|
||||
|
||||
@@ -298,28 +303,28 @@ class TestChatMessageList:
|
||||
|
||||
async def test_serialize_state(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test serializing store state."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
store = ChatMessageStore(sample_messages)
|
||||
|
||||
result = await store.serialize_state()
|
||||
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 = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
|
||||
result = await store.serialize_state()
|
||||
result = await store.serialize()
|
||||
|
||||
assert "messages" in result
|
||||
assert len(result["messages"]) == 0
|
||||
|
||||
async def test_deserialize_state(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test deserializing store state."""
|
||||
store = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
state_data = {"messages": sample_messages}
|
||||
|
||||
await store.deserialize_state(state_data)
|
||||
await store.update_from_state(state_data)
|
||||
|
||||
messages = await store.list_messages()
|
||||
assert len(messages) == 3
|
||||
@@ -327,156 +332,67 @@ class TestChatMessageList:
|
||||
|
||||
async def test_deserialize_state_none(self) -> None:
|
||||
"""Test deserializing None state."""
|
||||
store = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
|
||||
await store.deserialize_state(None)
|
||||
await store.update_from_state(None)
|
||||
|
||||
assert len(store) == 0
|
||||
assert len(store.messages) == 0
|
||||
|
||||
async def test_deserialize_state_empty(self) -> None:
|
||||
"""Test deserializing empty state."""
|
||||
store = ChatMessageList()
|
||||
store = ChatMessageStore()
|
||||
|
||||
await store.deserialize_state({})
|
||||
await store.update_from_state({})
|
||||
|
||||
assert len(store) == 0
|
||||
|
||||
def test_len(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test __len__ method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
assert len(store) == 3
|
||||
|
||||
empty_store = ChatMessageList()
|
||||
assert len(empty_store) == 0
|
||||
|
||||
def test_getitem(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test __getitem__ method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
assert store[0].text == "Hello"
|
||||
assert store[1].text == "Hi there!"
|
||||
assert store[2].text == "How are you?"
|
||||
|
||||
def test_setitem(self, sample_messages: list[ChatMessage], sample_message: ChatMessage) -> None:
|
||||
"""Test __setitem__ method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
store[1] = sample_message
|
||||
assert store[1].text == "Test message"
|
||||
assert store[1].message_id == "test1"
|
||||
|
||||
def test_append(self, sample_message: ChatMessage) -> None:
|
||||
"""Test append method."""
|
||||
store = ChatMessageList()
|
||||
|
||||
store.append(sample_message)
|
||||
|
||||
assert len(store) == 1
|
||||
assert store[0].text == "Test message"
|
||||
|
||||
def test_clear(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test clear method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
assert len(store) == 3
|
||||
|
||||
store.clear()
|
||||
assert len(store) == 0
|
||||
|
||||
def test_index(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test index method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
index = store.index(sample_messages[1])
|
||||
assert index == 1
|
||||
|
||||
def test_insert(self, sample_messages: list[ChatMessage], sample_message: ChatMessage) -> None:
|
||||
"""Test insert method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
store.insert(1, sample_message)
|
||||
|
||||
assert len(store) == 4
|
||||
assert store[1].text == "Test message"
|
||||
assert store[2].text == "Hi there!" # Original message at index 1 is now at index 2
|
||||
|
||||
def test_remove(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test remove method."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
message_to_remove = sample_messages[1]
|
||||
|
||||
store.remove(message_to_remove)
|
||||
|
||||
assert len(store) == 2
|
||||
assert store[0].text == "Hello"
|
||||
assert store[1].text == "How are you?"
|
||||
|
||||
def test_pop_default(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test pop method with default index."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
popped_message = store.pop()
|
||||
|
||||
assert len(store) == 2
|
||||
assert popped_message.text == "How are you?" # Last message
|
||||
|
||||
def test_pop_with_index(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test pop method with specific index."""
|
||||
store = ChatMessageList(sample_messages)
|
||||
|
||||
popped_message = store.pop(1)
|
||||
|
||||
assert len(store) == 2
|
||||
assert popped_message.text == "Hi there!"
|
||||
assert store[0].text == "Hello"
|
||||
assert store[1].text == "How are you?"
|
||||
assert len(store.messages) == 0
|
||||
|
||||
|
||||
class TestStoreState:
|
||||
"""Test cases for StoreState class."""
|
||||
"""Test cases for ChatMessageStoreState class."""
|
||||
|
||||
def test_init(self, sample_messages: list[ChatMessage]) -> None:
|
||||
"""Test StoreState initialization."""
|
||||
state = StoreState(messages=sample_messages)
|
||||
"""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 StoreState initialization with empty messages."""
|
||||
state = StoreState(messages=[])
|
||||
"""Test ChatMessageStoreState initialization with empty messages."""
|
||||
state = ChatMessageStoreState(messages=[])
|
||||
|
||||
assert len(state.messages) == 0
|
||||
|
||||
|
||||
class TestThreadState:
|
||||
"""Test cases for ThreadState class."""
|
||||
"""Test cases for AgentThreadState class."""
|
||||
|
||||
def test_init_with_service_thread_id(self) -> None:
|
||||
"""Test ThreadState initialization with service_thread_id."""
|
||||
state = ThreadState(service_thread_id="test-conv-123")
|
||||
"""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 ThreadState initialization with chat_message_store_state."""
|
||||
"""Test AgentThreadState initialization with chat_message_store_state."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
state = ThreadState(chat_message_store_state=store_data)
|
||||
state = AgentThreadState(chat_message_store_state=store_data)
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state == store_data
|
||||
|
||||
def test_init_with_both(self) -> None:
|
||||
"""Test ThreadState initialization with both parameters."""
|
||||
"""Test AgentThreadState initialization with both parameters."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
state = ThreadState(service_thread_id="test-conv-456", chat_message_store_state=store_data)
|
||||
|
||||
assert state.service_thread_id == "test-conv-456"
|
||||
assert state.chat_message_store_state == store_data
|
||||
with pytest.raises(
|
||||
AgentThreadException, match="Only one of service_thread_id or chat_message_store_state may be set"
|
||||
):
|
||||
AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data)
|
||||
|
||||
def test_init_defaults(self) -> None:
|
||||
"""Test ThreadState initialization with defaults."""
|
||||
state = ThreadState()
|
||||
"""Test AgentThreadState initialization with defaults."""
|
||||
state = AgentThreadState()
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state is None
|
||||
|
||||
@@ -678,7 +678,6 @@ def test_chat_response_updates_to_chat_response_multiple_multiple():
|
||||
assert chat_response.text == "I'm doing well, thank you! More contextFinal part"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_chat_response_from_async_generator():
|
||||
async def gen() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(text="Hello", message_id="1")
|
||||
@@ -688,7 +687,6 @@ async def test_chat_response_from_async_generator():
|
||||
assert resp.text == "Hello world"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_chat_response_from_async_generator_output_format():
|
||||
async def gen() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(text='{ "respon', message_id="1")
|
||||
@@ -702,7 +700,6 @@ async def test_chat_response_from_async_generator_output_format():
|
||||
assert resp.value.response == "Hello"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_chat_response_from_async_generator_output_format_in_method():
|
||||
async def gen() -> AsyncIterable[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(text='{ "respon', message_id="1")
|
||||
@@ -1199,7 +1196,6 @@ def agent_run_response_async() -> AgentRunResponse:
|
||||
return AgentRunResponse(messages=[ChatMessage(role="user", text="Hello")])
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_agent_run_response_from_async_generator():
|
||||
async def gen():
|
||||
yield AgentRunResponseUpdate(contents=[TextContent("A")])
|
||||
|
||||
@@ -383,7 +383,6 @@ async def test_openai_assistants_client_prepare_thread_existing_no_run(mock_asyn
|
||||
mock_async_openai.beta.threads.runs.cancel.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_assistants_client_process_stream_events_thread_run_created(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _process_stream_events with thread.run.created event."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
@@ -417,7 +416,6 @@ async def test_openai_assistants_client_process_stream_events_thread_run_created
|
||||
assert update.raw_representation == mock_response.data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_assistants_client_process_stream_events_message_delta_text(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _process_stream_events with thread.message.delta event containing text."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
@@ -462,7 +460,6 @@ async def test_openai_assistants_client_process_stream_events_message_delta_text
|
||||
assert update.raw_representation == mock_message_delta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_assistants_client_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _process_stream_events with thread.run.requires_action event."""
|
||||
chat_client = create_test_openai_assistants_client(mock_async_openai)
|
||||
@@ -506,7 +503,6 @@ async def test_openai_assistants_client_process_stream_events_requires_action(mo
|
||||
chat_client._create_function_call_contents.assert_called_once_with(mock_run, None) # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_assistants_client_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None:
|
||||
"""Test _process_stream_events with thread.run.step.created event."""
|
||||
|
||||
@@ -539,7 +535,6 @@ async def test_openai_assistants_client_process_stream_events_run_step_created(m
|
||||
assert len(updates) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_assistants_client_process_stream_events_run_completed_with_usage(
|
||||
mock_async_openai: MagicMock,
|
||||
) -> None:
|
||||
|
||||
@@ -101,7 +101,6 @@ class TimedApproval(RequestInfoMessage):
|
||||
issued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rehydrate_falls_back_when_request_type_missing() -> None:
|
||||
"""Rehydration should succeed even if the original request type cannot be imported.
|
||||
|
||||
@@ -144,7 +143,6 @@ async def test_rehydrate_falls_back_when_request_type_missing() -> None:
|
||||
assert getattr(event.data, "iteration", None) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_pending_request_detects_snapshot() -> None:
|
||||
request_id = "req-pending"
|
||||
snapshot = {
|
||||
@@ -172,7 +170,6 @@ async def test_has_pending_request_detects_snapshot() -> None:
|
||||
assert await executor.has_pending_request(request_id, ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_pending_request_false_when_snapshot_absent() -> None:
|
||||
shared_state = SharedState()
|
||||
runner_ctx = _StubRunnerContext({"pending_requests": {}})
|
||||
|
||||
@@ -5,16 +5,20 @@ from collections.abc import MutableSequence, Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, Context, ContextProvider, TextContent
|
||||
from agent_framework import ChatMessage, Context, ContextProvider
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from mem0 import AsyncMemory, AsyncMemoryClient
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import NotRequired, Self, TypedDict # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
|
||||
# Type aliases for Mem0 search response formats (v1.1 and v2; v1 is deprecated, but matches the type definition for v2)
|
||||
class MemorySearchResponse_v1_1(TypedDict):
|
||||
@@ -26,19 +30,11 @@ MemorySearchResponse_v2 = list[dict[str, Any]]
|
||||
|
||||
|
||||
class Mem0Provider(ContextProvider):
|
||||
mem0_client: AsyncMemory | AsyncMemoryClient
|
||||
api_key: str | None = None
|
||||
application_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
user_id: str | None = None
|
||||
scope_to_per_operation_thread_id: bool = False
|
||||
context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT
|
||||
|
||||
_should_close_client: bool = PrivateAttr(default=False) # Track whether we should close client connection
|
||||
"""Mem0 Context Provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mem0_client: AsyncMemory | AsyncMemoryClient | None = None,
|
||||
api_key: str | None = None,
|
||||
application_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
@@ -46,11 +42,11 @@ class Mem0Provider(ContextProvider):
|
||||
user_id: str | None = None,
|
||||
scope_to_per_operation_thread_id: bool = False,
|
||||
context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT,
|
||||
mem0_client: AsyncMemory | AsyncMemoryClient | None = None,
|
||||
) -> None:
|
||||
"""Initializes a new instance of the Mem0Provider class.
|
||||
|
||||
Args:
|
||||
mem0_client: A pre-created Mem0 MemoryClient or None to create a default client.
|
||||
api_key: The API key for authenticating with the Mem0 API. If not
|
||||
provided, it will attempt to use the MEM0_API_KEY environment variable.
|
||||
application_id: The application ID for scoping memories or None.
|
||||
@@ -59,24 +55,20 @@ class Mem0Provider(ContextProvider):
|
||||
user_id: The user ID for scoping memories or None.
|
||||
scope_to_per_operation_thread_id: Whether to scope memories to per-operation thread ID.
|
||||
context_prompt: The prompt to prepend to retrieved memories.
|
||||
mem0_client: A pre-created Mem0 MemoryClient or None to create a default client.
|
||||
"""
|
||||
should_close_client = False
|
||||
if mem0_client is None:
|
||||
mem0_client = AsyncMemoryClient(api_key=api_key)
|
||||
should_close_client = True
|
||||
|
||||
super().__init__(
|
||||
api_key=api_key, # type: ignore[reportCallIssue]
|
||||
application_id=application_id, # type: ignore[reportCallIssue]
|
||||
agent_id=agent_id, # type: ignore[reportCallIssue]
|
||||
thread_id=thread_id, # type: ignore[reportCallIssue]
|
||||
user_id=user_id, # type: ignore[reportCallIssue]
|
||||
scope_to_per_operation_thread_id=scope_to_per_operation_thread_id, # type: ignore[reportCallIssue]
|
||||
context_prompt=context_prompt, # type: ignore[reportCallIssue]
|
||||
mem0_client=mem0_client, # type: ignore[reportCallIssue]
|
||||
)
|
||||
|
||||
self.api_key = api_key
|
||||
self.application_id = application_id
|
||||
self.agent_id = agent_id
|
||||
self.thread_id = thread_id
|
||||
self.user_id = user_id
|
||||
self.scope_to_per_operation_thread_id = scope_to_per_operation_thread_id
|
||||
self.context_prompt = context_prompt
|
||||
self.mem0_client = mem0_client
|
||||
self._per_operation_thread_id: str | None = None
|
||||
self._should_close_client = should_close_client
|
||||
|
||||
@@ -100,18 +92,27 @@ class Mem0Provider(ContextProvider):
|
||||
self._validate_per_operation_thread_id(thread_id)
|
||||
self._per_operation_thread_id = self._per_operation_thread_id or thread_id
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Called when a new message is being added to the thread.
|
||||
|
||||
Args:
|
||||
thread_id: The ID of the thread or None.
|
||||
new_messages: New messages to add.
|
||||
"""
|
||||
@override
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
|
||||
invoke_exception: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._validate_filters()
|
||||
self._validate_per_operation_thread_id(thread_id)
|
||||
self._per_operation_thread_id = self._per_operation_thread_id or thread_id
|
||||
|
||||
messages_list = [new_messages] if isinstance(new_messages, ChatMessage) else list(new_messages)
|
||||
request_messages_list = (
|
||||
[request_messages] if isinstance(request_messages, ChatMessage) else list(request_messages)
|
||||
)
|
||||
response_messages_list = (
|
||||
[response_messages]
|
||||
if isinstance(response_messages, ChatMessage)
|
||||
else list(response_messages)
|
||||
if response_messages
|
||||
else []
|
||||
)
|
||||
messages_list = [*request_messages_list, *response_messages_list]
|
||||
|
||||
messages: list[dict[str, str]] = [
|
||||
{"role": message.role.value, "content": message.text}
|
||||
@@ -128,11 +129,13 @@ class Mem0Provider(ContextProvider):
|
||||
metadata={"application_id": self.application_id},
|
||||
)
|
||||
|
||||
async def model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
@override
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
"""Called before invoking the AI model to provide context.
|
||||
|
||||
Args:
|
||||
messages: List of new messages in the thread.
|
||||
kwargs: not used at present.
|
||||
|
||||
Returns:
|
||||
Context: Context object containing instructions with memories.
|
||||
@@ -159,9 +162,11 @@ class Mem0Provider(ContextProvider):
|
||||
|
||||
line_separated_memories = "\n".join(memory.get("memory", "") for memory in memories)
|
||||
|
||||
content = TextContent(f"{self.context_prompt}\n{line_separated_memories}") if line_separated_memories else None
|
||||
|
||||
return Context(contents=[content] if content else None)
|
||||
return Context(
|
||||
messages=[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")]
|
||||
if line_separated_memories
|
||||
else None
|
||||
)
|
||||
|
||||
def _validate_filters(self) -> None:
|
||||
"""Validates that at least one filter is provided.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, Context, Role, TextContent
|
||||
from agent_framework import ChatMessage, Context, Role
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.mem0 import Mem0Provider
|
||||
|
||||
@@ -173,27 +173,25 @@ class TestMem0ProviderThreadMethods:
|
||||
|
||||
assert "can only be used with one thread at a time" in str(exc_info.value)
|
||||
|
||||
async def test_messages_adding_sets_per_operation_thread_id(
|
||||
self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage]
|
||||
) -> None:
|
||||
"""Test that messages_adding sets per-operation thread ID."""
|
||||
async def test_messages_adding_sets_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test that invoked sets per-operation thread ID."""
|
||||
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
|
||||
|
||||
await provider.messages_adding("thread123", sample_messages)
|
||||
await provider.thread_created("thread123")
|
||||
|
||||
assert provider._per_operation_thread_id == "thread123"
|
||||
|
||||
|
||||
class TestMem0ProviderMessagesAdding:
|
||||
"""Test messages_adding method."""
|
||||
"""Test invoked method."""
|
||||
|
||||
async def test_messages_adding_fails_without_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test that messages_adding fails when no filters are provided."""
|
||||
"""Test that invoked fails when no filters are provided."""
|
||||
provider = Mem0Provider(mem0_client=mock_mem0_client)
|
||||
message = ChatMessage(role=Role.USER, text="Hello!")
|
||||
|
||||
with pytest.raises(ServiceInitializationError) as exc_info:
|
||||
await provider.messages_adding("thread123", message)
|
||||
await provider.invoked(message)
|
||||
|
||||
assert "At least one of the filters" in str(exc_info.value)
|
||||
|
||||
@@ -202,7 +200,7 @@ class TestMem0ProviderMessagesAdding:
|
||||
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
|
||||
message = ChatMessage(role=Role.USER, text="Hello!")
|
||||
|
||||
await provider.messages_adding("thread123", message)
|
||||
await provider.invoked(message)
|
||||
|
||||
mock_mem0_client.add.assert_called_once()
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
@@ -215,7 +213,7 @@ class TestMem0ProviderMessagesAdding:
|
||||
"""Test adding multiple messages."""
|
||||
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
|
||||
|
||||
await provider.messages_adding("thread123", sample_messages)
|
||||
await provider.invoked(sample_messages)
|
||||
|
||||
mock_mem0_client.add.assert_called_once()
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
@@ -232,7 +230,7 @@ class TestMem0ProviderMessagesAdding:
|
||||
"""Test adding messages with agent_id."""
|
||||
provider = Mem0Provider(agent_id="agent123", mem0_client=mock_mem0_client)
|
||||
|
||||
await provider.messages_adding("thread123", sample_messages)
|
||||
await provider.invoked(sample_messages)
|
||||
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
assert call_args.kwargs["agent_id"] == "agent123"
|
||||
@@ -244,7 +242,7 @@ class TestMem0ProviderMessagesAdding:
|
||||
"""Test adding messages with application_id in metadata."""
|
||||
provider = Mem0Provider(user_id="user123", application_id="app123", mem0_client=mock_mem0_client)
|
||||
|
||||
await provider.messages_adding("thread123", sample_messages)
|
||||
await provider.invoked(sample_messages)
|
||||
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
assert call_args.kwargs["metadata"] == {"application_id": "app123"}
|
||||
@@ -261,7 +259,8 @@ class TestMem0ProviderMessagesAdding:
|
||||
)
|
||||
provider._per_operation_thread_id = "operation_thread"
|
||||
|
||||
await provider.messages_adding("operation_thread", sample_messages)
|
||||
await provider.thread_created(thread_id="operation_thread")
|
||||
await provider.invoked(sample_messages)
|
||||
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
assert call_args.kwargs["run_id"] == "operation_thread"
|
||||
@@ -277,7 +276,7 @@ class TestMem0ProviderMessagesAdding:
|
||||
mem0_client=mock_mem0_client,
|
||||
)
|
||||
|
||||
await provider.messages_adding("operation_thread", sample_messages)
|
||||
await provider.invoked(sample_messages)
|
||||
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
assert call_args.kwargs["run_id"] == "base_thread"
|
||||
@@ -291,7 +290,7 @@ class TestMem0ProviderMessagesAdding:
|
||||
ChatMessage(role=Role.USER, text="Valid message"),
|
||||
]
|
||||
|
||||
await provider.messages_adding("thread123", messages)
|
||||
await provider.invoked(messages)
|
||||
|
||||
call_args = mock_mem0_client.add.call_args
|
||||
# Should only include the valid message
|
||||
@@ -305,26 +304,26 @@ class TestMem0ProviderMessagesAdding:
|
||||
ChatMessage(role=Role.USER, text=" "),
|
||||
]
|
||||
|
||||
await provider.messages_adding("thread123", messages)
|
||||
await provider.invoked(messages)
|
||||
|
||||
mock_mem0_client.add.assert_not_called()
|
||||
|
||||
|
||||
class TestMem0ProviderModelInvoking:
|
||||
"""Test model_invoking method."""
|
||||
"""Test invoking method."""
|
||||
|
||||
async def test_model_invoking_fails_without_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test that model_invoking fails when no filters are provided."""
|
||||
"""Test that invoking fails when no filters are provided."""
|
||||
provider = Mem0Provider(mem0_client=mock_mem0_client)
|
||||
message = ChatMessage(role=Role.USER, text="What's the weather?")
|
||||
|
||||
with pytest.raises(ServiceInitializationError) as exc_info:
|
||||
await provider.model_invoking(message)
|
||||
await provider.invoking(message)
|
||||
|
||||
assert "At least one of the filters" in str(exc_info.value)
|
||||
|
||||
async def test_model_invoking_single_message(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test model_invoking with a single message."""
|
||||
"""Test invoking with a single message."""
|
||||
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
|
||||
message = ChatMessage(role=Role.USER, text="What's the weather?")
|
||||
|
||||
@@ -334,7 +333,7 @@ class TestMem0ProviderModelInvoking:
|
||||
{"memory": "User lives in Seattle"},
|
||||
]
|
||||
|
||||
context = await provider.model_invoking(message)
|
||||
context = await provider.invoking(message)
|
||||
|
||||
mock_mem0_client.search.assert_called_once()
|
||||
call_args = mock_mem0_client.search.call_args
|
||||
@@ -347,39 +346,38 @@ class TestMem0ProviderModelInvoking:
|
||||
"User likes outdoor activities\nUser lives in Seattle"
|
||||
)
|
||||
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert context.contents[0].text == expected_instructions
|
||||
assert context.messages
|
||||
assert context.messages[0].text == expected_instructions
|
||||
|
||||
async def test_model_invoking_multiple_messages(
|
||||
self, mock_mem0_client: AsyncMock, sample_messages: list[ChatMessage]
|
||||
) -> None:
|
||||
"""Test model_invoking with multiple messages."""
|
||||
"""Test invoking with multiple messages."""
|
||||
provider = Mem0Provider(user_id="user123", mem0_client=mock_mem0_client)
|
||||
|
||||
mock_mem0_client.search.return_value = [{"memory": "Previous conversation context"}]
|
||||
|
||||
await provider.model_invoking(sample_messages)
|
||||
await provider.invoking(sample_messages)
|
||||
|
||||
call_args = mock_mem0_client.search.call_args
|
||||
expected_query = "Hello, how are you?\nI'm doing well, thank you!\nYou are a helpful assistant"
|
||||
assert call_args.kwargs["query"] == expected_query
|
||||
|
||||
async def test_model_invoking_with_agent_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test model_invoking with agent_id."""
|
||||
"""Test invoking with agent_id."""
|
||||
provider = Mem0Provider(agent_id="agent123", mem0_client=mock_mem0_client)
|
||||
message = ChatMessage(role=Role.USER, text="Hello")
|
||||
|
||||
mock_mem0_client.search.return_value = []
|
||||
|
||||
await provider.model_invoking(message)
|
||||
await provider.invoking(message)
|
||||
|
||||
call_args = mock_mem0_client.search.call_args
|
||||
assert call_args.kwargs["agent_id"] == "agent123"
|
||||
assert call_args.kwargs["user_id"] is None
|
||||
|
||||
async def test_model_invoking_with_scope_to_per_operation_thread_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test model_invoking with scope_to_per_operation_thread_id enabled."""
|
||||
"""Test invoking with scope_to_per_operation_thread_id enabled."""
|
||||
provider = Mem0Provider(
|
||||
user_id="user123",
|
||||
thread_id="base_thread",
|
||||
@@ -391,7 +389,7 @@ class TestMem0ProviderModelInvoking:
|
||||
|
||||
mock_mem0_client.search.return_value = []
|
||||
|
||||
await provider.model_invoking(message)
|
||||
await provider.invoking(message)
|
||||
|
||||
call_args = mock_mem0_client.search.call_args
|
||||
assert call_args.kwargs["run_id"] == "operation_thread"
|
||||
@@ -403,10 +401,10 @@ class TestMem0ProviderModelInvoking:
|
||||
|
||||
mock_mem0_client.search.return_value = []
|
||||
|
||||
context = await provider.model_invoking(message)
|
||||
context = await provider.invoking(message)
|
||||
|
||||
assert isinstance(context, Context)
|
||||
assert not context.contents
|
||||
assert not context.messages
|
||||
|
||||
async def test_model_invoking_filters_empty_message_text(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test that empty message text is filtered out from query."""
|
||||
@@ -419,13 +417,13 @@ class TestMem0ProviderModelInvoking:
|
||||
|
||||
mock_mem0_client.search.return_value = []
|
||||
|
||||
await provider.model_invoking(messages)
|
||||
await provider.invoking(messages)
|
||||
|
||||
call_args = mock_mem0_client.search.call_args
|
||||
assert call_args.kwargs["query"] == "Valid message"
|
||||
|
||||
async def test_model_invoking_custom_context_prompt(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Test model_invoking with custom context prompt."""
|
||||
"""Test invoking with custom context prompt."""
|
||||
custom_prompt = "## Custom Context\nRemember these details:"
|
||||
provider = Mem0Provider(
|
||||
user_id="user123",
|
||||
@@ -436,12 +434,11 @@ class TestMem0ProviderModelInvoking:
|
||||
|
||||
mock_mem0_client.search.return_value = [{"memory": "Test memory"}]
|
||||
|
||||
context = await provider.model_invoking(message)
|
||||
context = await provider.invoking(message)
|
||||
|
||||
expected_instructions = "## Custom Context\nRemember these details:\nTest memory"
|
||||
assert context.contents
|
||||
assert isinstance(context.contents[0], TextContent)
|
||||
assert context.contents[0].text == expected_instructions
|
||||
assert context.messages
|
||||
assert context.messages[0].text == expected_instructions
|
||||
|
||||
|
||||
class TestMem0ProviderValidation:
|
||||
|
||||
@@ -22,7 +22,7 @@ class RedisStoreState(AFBaseModel):
|
||||
|
||||
|
||||
class RedisChatMessageStore:
|
||||
"""Redis-backed implementation of ChatMessageStore using Redis Lists.
|
||||
"""Redis-backed implementation of ChatMessageStoreProtocol using Redis Lists.
|
||||
|
||||
This implementation provides persistent, thread-safe chat message storage using Redis Lists.
|
||||
Messages are stored as JSON-serialized strings in chronological order, with each conversation
|
||||
@@ -153,9 +153,9 @@ class RedisChatMessageStore:
|
||||
await pipe.execute()
|
||||
|
||||
async def add_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Add messages to the Redis store (ChatMessageStore protocol method).
|
||||
"""Add messages to the Redis store (ChatMessageStoreProtocol protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for adding messages.
|
||||
This method implements the required ChatMessageStoreProtocol protocol for adding messages.
|
||||
Messages are appended to the Redis list in chronological order, with automatic
|
||||
trimming if message limits are configured.
|
||||
|
||||
@@ -190,9 +190,9 @@ class RedisChatMessageStore:
|
||||
await self._redis_client.ltrim(self.redis_key, -self.max_messages, -1) # type: ignore[misc]
|
||||
|
||||
async def list_messages(self) -> list[ChatMessage]:
|
||||
"""Get all messages from the store in chronological order (ChatMessageStore protocol method).
|
||||
"""Get all messages from the store in chronological order (ChatMessageStoreProtocol protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for retrieving messages.
|
||||
This method implements the required ChatMessageStoreProtocol protocol for retrieving messages.
|
||||
Returns all messages stored in Redis, ordered from oldest (index 0) to newest (index -1).
|
||||
|
||||
Returns:
|
||||
@@ -220,10 +220,10 @@ class RedisChatMessageStore:
|
||||
|
||||
return messages
|
||||
|
||||
async def serialize_state(self, **kwargs: Any) -> Any:
|
||||
"""Serialize the current store state for persistence (ChatMessageStore protocol method).
|
||||
async def serialize(self, **kwargs: Any) -> Any:
|
||||
"""Serialize the current store state for persistence (ChatMessageStoreProtocol protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for state serialization.
|
||||
This method implements the required ChatMessageStoreProtocol protocol for state serialization.
|
||||
Captures the Redis connection configuration and thread information needed to
|
||||
reconstruct the store and reconnect to the same conversation data.
|
||||
|
||||
@@ -243,10 +243,43 @@ class RedisChatMessageStore:
|
||||
)
|
||||
return state.model_dump(**kwargs)
|
||||
|
||||
async def deserialize_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserialize state data into this store instance (ChatMessageStore protocol method).
|
||||
@classmethod
|
||||
async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> RedisChatMessageStore:
|
||||
"""Deserialize state data into a new store instance (ChatMessageStoreProtocol protocol method).
|
||||
|
||||
This method implements the required ChatMessageStore protocol for state deserialization.
|
||||
This method implements the required ChatMessageStoreProtocol protocol for state deserialization.
|
||||
Creates a new RedisChatMessageStore instance from previously serialized data,
|
||||
allowing the store to reconnect to the same conversation data in Redis.
|
||||
|
||||
Args:
|
||||
serialized_store_state: Previously serialized state data from serialize_state().
|
||||
Should be a dictionary with thread_id, redis_url, etc.
|
||||
**kwargs: Additional arguments passed to Pydantic model validation.
|
||||
|
||||
Returns:
|
||||
A new RedisChatMessageStore instance configured from the serialized state.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing or invalid in the serialized state.
|
||||
"""
|
||||
if not serialized_store_state:
|
||||
raise ValueError("serialized_store_state is required for deserialization")
|
||||
|
||||
# Validate and parse the serialized state using Pydantic
|
||||
state = RedisStoreState.model_validate(serialized_store_state, **kwargs)
|
||||
|
||||
# Create and return a new store instance with the deserialized configuration
|
||||
return cls(
|
||||
redis_url=state.redis_url,
|
||||
thread_id=state.thread_id,
|
||||
key_prefix=state.key_prefix,
|
||||
max_messages=state.max_messages,
|
||||
)
|
||||
|
||||
async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None:
|
||||
"""Deserialize state data into this store instance (ChatMessageStoreProtocol protocol method).
|
||||
|
||||
This method implements the required ChatMessageStoreProtocol protocol for state deserialization.
|
||||
Restores the store configuration from previously serialized data, allowing the store
|
||||
to reconnect to the same conversation data in Redis.
|
||||
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from functools import reduce
|
||||
from operator import and_
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from agent_framework import ChatMessage, Context, ContextProvider, Role, TextContent
|
||||
import numpy as np
|
||||
from agent_framework import ChatMessage, Context, ContextProvider, Role
|
||||
from agent_framework.exceptions import (
|
||||
AgentException,
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidRequestError,
|
||||
)
|
||||
from redisvl.index import AsyncSearchIndex
|
||||
from redisvl.query import FilterQuery, HybridQuery, TextQuery
|
||||
from redisvl.query.filter import FilterExpression, Tag
|
||||
from redisvl.utils.token_escaper import TokenEscaper
|
||||
from redisvl.utils.vectorize import BaseVectorizer
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
from redisvl.index import AsyncSearchIndex
|
||||
from redisvl.query import FilterQuery, HybridQuery, TextQuery
|
||||
from redisvl.query.filter import FilterExpression, Tag
|
||||
from redisvl.utils.token_escaper import TokenEscaper
|
||||
from redisvl.utils.vectorize import BaseVectorizer
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore[import] # pragma: no cover
|
||||
|
||||
|
||||
class RedisProvider(ContextProvider):
|
||||
@@ -36,41 +38,73 @@ class RedisProvider(ContextProvider):
|
||||
Uses full-text or optional hybrid vector search to ground model responses.
|
||||
"""
|
||||
|
||||
# Connection and indexing
|
||||
redis_url: str = "redis://localhost:6379"
|
||||
index_name: str = "context"
|
||||
prefix: str = "context"
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: str = "redis://localhost:6379",
|
||||
index_name: str = "context",
|
||||
prefix: str = "context",
|
||||
# Redis vectorizer configuration (optional, injected by client)
|
||||
redis_vectorizer: BaseVectorizer | None = None,
|
||||
vector_field_name: str | None = None,
|
||||
vector_algorithm: Literal["flat", "hnsw"] | None = None,
|
||||
vector_distance_metric: Literal["cosine", "ip", "l2"] | None = None,
|
||||
# Partition fields (indexed for filtering)
|
||||
application_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
scope_to_per_operation_thread_id: bool = False,
|
||||
# Prompt and runtime
|
||||
context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT,
|
||||
redis_index: Any = None,
|
||||
overwrite_index: bool = False,
|
||||
):
|
||||
"""Create a Redis Context Provider.
|
||||
|
||||
# Redis vectorizer configuration (optional, injected by client)
|
||||
redis_vectorizer: BaseVectorizer | None = None
|
||||
vector_field_name: str | None = None
|
||||
vector_algorithm: Literal["flat", "hnsw"] | None = None
|
||||
vector_distance_metric: Literal["cosine", "ip", "l2"] | None = None
|
||||
Args:
|
||||
redis_url: The Redis server URL.
|
||||
index_name: The name of the Redis index.
|
||||
prefix: The prefix for all keys in the Redis database.
|
||||
redis_vectorizer: The vectorizer to use for Redis.
|
||||
vector_field_name: The name of the vector field in Redis.
|
||||
vector_algorithm: The algorithm to use for vector search.
|
||||
vector_distance_metric: The distance metric to use for vector search.
|
||||
application_id: The application ID to scope the context.
|
||||
agent_id: The agent ID to scope the context.
|
||||
user_id: The user ID to scope the context.
|
||||
thread_id: The thread ID to scope the context.
|
||||
scope_to_per_operation_thread_id: Whether to scope to the per-operation thread ID.
|
||||
context_prompt: The context prompt to use for the provider.
|
||||
redis_index: The Redis index to use for the provider.
|
||||
overwrite_index: Whether to overwrite the existing Redis index.
|
||||
|
||||
# Partition fields (indexed for filtering)
|
||||
application_id: str | None = None
|
||||
agent_id: str | None = None
|
||||
user_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
scope_to_per_operation_thread_id: bool = False
|
||||
|
||||
# Prompt and runtime
|
||||
context_prompt: str = ContextProvider.DEFAULT_CONTEXT_PROMPT
|
||||
redis_index: Any = None
|
||||
overwrite_index: bool = False
|
||||
_per_operation_thread_id: str | None = None
|
||||
_token_escaper: TokenEscaper = TokenEscaper()
|
||||
_conversation_id: str | None = None
|
||||
_index_initialized: bool = False
|
||||
_schema_dict: dict[str, Any] | None = None
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Post-initialization hook to set up computed fields after Pydantic initialization.
|
||||
|
||||
This is called automatically by Pydantic after the model is initialized.
|
||||
"""
|
||||
# Create Redis index using the cached schema_dict property
|
||||
self.redis_index = AsyncSearchIndex.from_dict(self.schema_dict, redis_url=self.redis_url, validate_on_load=True)
|
||||
self.redis_url = redis_url
|
||||
self.index_name = index_name
|
||||
self.prefix = prefix
|
||||
if redis_vectorizer is not None and not isinstance(redis_vectorizer, BaseVectorizer):
|
||||
raise AgentException(
|
||||
f"The redis vectorizer is not a valid type, got: {type(redis_vectorizer)}, expected: BaseVectorizer."
|
||||
)
|
||||
self.redis_vectorizer = redis_vectorizer
|
||||
self.vector_field_name = vector_field_name
|
||||
self.vector_algorithm: Literal["flat", "hnsw"] | None = vector_algorithm
|
||||
self.vector_distance_metric: Literal["cosine", "ip", "l2"] | None = vector_distance_metric
|
||||
self.application_id = application_id
|
||||
self.agent_id = agent_id
|
||||
self.user_id = user_id
|
||||
self.thread_id = thread_id
|
||||
self.scope_to_per_operation_thread_id = scope_to_per_operation_thread_id
|
||||
self.context_prompt = context_prompt
|
||||
self.overwrite_index = overwrite_index
|
||||
self._per_operation_thread_id: str | None = None
|
||||
self._token_escaper: TokenEscaper = TokenEscaper()
|
||||
self._conversation_id: str | None = None
|
||||
self._index_initialized: bool = False
|
||||
self._schema_dict: dict[str, Any] | None = None
|
||||
self.redis_index = redis_index or AsyncSearchIndex.from_dict(
|
||||
self.schema_dict, redis_url=self.redis_url, validate_on_load=True
|
||||
)
|
||||
|
||||
@property
|
||||
def schema_dict(self) -> dict[str, Any]:
|
||||
@@ -429,6 +463,7 @@ class RedisProvider(ContextProvider):
|
||||
"""
|
||||
return self._per_operation_thread_id if self.scope_to_per_operation_thread_id else self.thread_id
|
||||
|
||||
@override
|
||||
async def thread_created(self, thread_id: str | None) -> None:
|
||||
"""Called when a new thread is created.
|
||||
|
||||
@@ -442,20 +477,27 @@ class RedisProvider(ContextProvider):
|
||||
# Track current conversation id (Agent passes conversation_id here)
|
||||
self._conversation_id = thread_id or self._conversation_id
|
||||
|
||||
async def messages_adding(self, thread_id: str | None, new_messages: ChatMessage | Sequence[ChatMessage]) -> None:
|
||||
"""Called when a new message is being added to the thread.
|
||||
|
||||
Validates scope, normalizes allowed roles, and persists messages to Redis via add().
|
||||
|
||||
Args:
|
||||
thread_id: The ID of the thread or None.
|
||||
new_messages: New messages to add.
|
||||
"""
|
||||
@override
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
|
||||
invoke_exception: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self._validate_filters()
|
||||
self._validate_per_operation_thread_id(thread_id)
|
||||
self._per_operation_thread_id = self._per_operation_thread_id or thread_id
|
||||
|
||||
messages_list = [new_messages] if isinstance(new_messages, ChatMessage) else list(new_messages)
|
||||
request_messages_list = (
|
||||
[request_messages] if isinstance(request_messages, ChatMessage) else list(request_messages)
|
||||
)
|
||||
response_messages_list = (
|
||||
[response_messages]
|
||||
if isinstance(response_messages, ChatMessage)
|
||||
else list(response_messages)
|
||||
if response_messages
|
||||
else []
|
||||
)
|
||||
messages_list = [*request_messages_list, *response_messages_list]
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
for message in messages_list:
|
||||
@@ -475,7 +517,8 @@ class RedisProvider(ContextProvider):
|
||||
if messages:
|
||||
await self._add(data=messages)
|
||||
|
||||
async def model_invoking(self, messages: ChatMessage | MutableSequence[ChatMessage]) -> Context:
|
||||
@override
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
"""Called before invoking the model to provide scoped context.
|
||||
|
||||
Concatenates recent messages into a query, fetches matching memories from Redis.
|
||||
@@ -483,6 +526,7 @@ class RedisProvider(ContextProvider):
|
||||
|
||||
Args:
|
||||
messages: List of new messages in the thread.
|
||||
kwargs: not used at present at present.
|
||||
|
||||
Returns:
|
||||
Context: Context object containing instructions with memories.
|
||||
@@ -495,8 +539,12 @@ class RedisProvider(ContextProvider):
|
||||
line_separated_memories = "\n".join(
|
||||
str(memory.get("content", "")) for memory in memories if memory.get("content")
|
||||
)
|
||||
content = TextContent(f"{self.context_prompt}\n{line_separated_memories}") if line_separated_memories else None
|
||||
return Context(contents=[content] if content else None)
|
||||
|
||||
return Context(
|
||||
messages=[ChatMessage(role="user", text=f"{self.context_prompt}\n{line_separated_memories}")]
|
||||
if line_separated_memories
|
||||
else None
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Async context manager entry.
|
||||
|
||||
@@ -239,7 +239,7 @@ class TestRedisChatMessageStore:
|
||||
|
||||
async def test_serialize_state(self, redis_store):
|
||||
"""Test state serialization."""
|
||||
state = await redis_store.serialize_state()
|
||||
state = await redis_store.serialize()
|
||||
|
||||
expected_state = {
|
||||
"thread_id": "test_thread_123",
|
||||
@@ -259,7 +259,7 @@ class TestRedisChatMessageStore:
|
||||
"max_messages": 50,
|
||||
}
|
||||
|
||||
await redis_store.deserialize_state(serialized_state)
|
||||
await redis_store.update_from_state(serialized_state)
|
||||
|
||||
assert redis_store.thread_id == "restored_thread_456"
|
||||
assert redis_store.redis_url == "redis://localhost:6380"
|
||||
@@ -270,7 +270,7 @@ class TestRedisChatMessageStore:
|
||||
"""Test deserializing empty state doesn't change anything."""
|
||||
original_thread_id = redis_store.thread_id
|
||||
|
||||
await redis_store.deserialize_state(None)
|
||||
await redis_store.update_from_state(None)
|
||||
|
||||
assert redis_store.thread_id == original_thread_id
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import numpy as np
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, Role
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from pydantic import ValidationError
|
||||
from agent_framework.exceptions import AgentException, ServiceInitializationError
|
||||
from redisvl.utils.vectorize import CustomTextVectorizer
|
||||
|
||||
from agent_framework_redis import RedisProvider
|
||||
@@ -121,21 +120,18 @@ class TestRedisProviderMessages:
|
||||
ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant"),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Writes require at least one scoping filter to avoid unbounded operations
|
||||
async def test_messages_adding_requires_filters(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider()
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
await provider.messages_adding("thread123", ChatMessage(role=Role.USER, text="Hello"))
|
||||
await provider.invoked("thread123", ChatMessage(role=Role.USER, text="Hello"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Captures the per-operation thread id when provided
|
||||
async def test_thread_created_sets_per_operation_id(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1")
|
||||
await provider.thread_created("t1")
|
||||
assert provider._per_operation_thread_id == "t1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Enforces single-thread usage when scope_to_per_operation_thread_id is True
|
||||
async def test_thread_created_conflict_when_scoped(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True)
|
||||
@@ -144,7 +140,6 @@ class TestRedisProviderMessages:
|
||||
await provider.thread_created("t2")
|
||||
assert "only be used with one thread" in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Aggregates all results from the async paginator into a flat list
|
||||
async def test_search_all_paginates(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
async def gen(_q, page_size: int = 200): # noqa: ARG001, ANN001
|
||||
@@ -158,14 +153,12 @@ class TestRedisProviderMessages:
|
||||
|
||||
|
||||
class TestRedisProviderModelInvoking:
|
||||
@pytest.mark.asyncio
|
||||
# Reads require at least one scoping filter to avoid unbounded operations
|
||||
async def test_model_invoking_requires_filters(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider()
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
await provider.model_invoking(ChatMessage(role=Role.USER, text="Hi"))
|
||||
await provider.invoking(ChatMessage(role=Role.USER, text="Hi"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Ensures text-only search path is used and context is composed from hits
|
||||
async def test_textquery_path_and_context_contents(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict, patch_queries
|
||||
@@ -175,7 +168,7 @@ class TestRedisProviderModelInvoking:
|
||||
provider = RedisProvider(user_id="u1")
|
||||
|
||||
# Act
|
||||
ctx = await provider.model_invoking([ChatMessage(role=Role.USER, text="q1")])
|
||||
ctx = await provider.invoking([ChatMessage(role=Role.USER, text="q1")])
|
||||
|
||||
# Assert: TextQuery used (not HybridQuery), filter_expression included
|
||||
assert patch_queries["TextQuery"].call_count == 1
|
||||
@@ -187,27 +180,25 @@ class TestRedisProviderModelInvoking:
|
||||
assert "filter_expression" in kwargs
|
||||
|
||||
# Context contains memories joined after the default prompt
|
||||
assert ctx.contents is not None and len(ctx.contents) == 1
|
||||
text = ctx.contents[0].text
|
||||
assert ctx.messages is not None and len(ctx.messages) == 1
|
||||
text = ctx.messages[0].text
|
||||
assert text.endswith("A\nB")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# When no results are returned, Context should have no contents
|
||||
async def test_model_invoking_empty_results_returns_empty_context(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict, patch_queries
|
||||
): # noqa: ARG002
|
||||
mock_index.query = AsyncMock(return_value=[])
|
||||
provider = RedisProvider(user_id="u1")
|
||||
ctx = await provider.model_invoking([ChatMessage(role=Role.USER, text="any")])
|
||||
assert ctx.contents is None
|
||||
ctx = await provider.invoking([ChatMessage(role=Role.USER, text="any")])
|
||||
assert ctx.messages == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Ensures hybrid vector-text search is used when a vectorizer and vector field are configured
|
||||
async def test_hybridquery_path_with_vectorizer(self, mock_index: AsyncMock, patch_index_from_dict, patch_queries): # noqa: ARG002
|
||||
mock_index.query = AsyncMock(return_value=[{"content": "Hit"}])
|
||||
provider = RedisProvider(user_id="u1", redis_vectorizer=CUSTOM_VECTORIZER, vector_field_name="vec")
|
||||
|
||||
ctx = await provider.model_invoking([ChatMessage(role=Role.USER, text="hello")])
|
||||
ctx = await provider.invoking([ChatMessage(role=Role.USER, text="hello")])
|
||||
|
||||
# Assert: HybridQuery used with vector and vector field
|
||||
assert patch_queries["HybridQuery"].call_count == 1
|
||||
@@ -220,18 +211,16 @@ class TestRedisProviderModelInvoking:
|
||||
assert "filter_expression" in k
|
||||
|
||||
# Context assembled from returned memories
|
||||
assert ctx.contents and "Hit" in ctx.contents[0].text
|
||||
assert ctx.messages and "Hit" in ctx.messages[0].text
|
||||
|
||||
|
||||
class TestRedisProviderContextManager:
|
||||
@pytest.mark.asyncio
|
||||
# Verifies async context manager returns self for chaining
|
||||
async def test_async_context_manager_returns_self(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1")
|
||||
async with provider as ctx:
|
||||
assert ctx is provider
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Exit should be a no-op and not raise
|
||||
async def test_aexit_noop(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1")
|
||||
@@ -239,7 +228,6 @@ class TestRedisProviderContextManager:
|
||||
|
||||
|
||||
class TestMessagesAddingBehavior:
|
||||
@pytest.mark.asyncio
|
||||
# Adds messages while injecting partition defaults and preserving allowed roles
|
||||
async def test_messages_adding_adds_partition_defaults_and_roles(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
@@ -257,7 +245,7 @@ class TestMessagesAddingBehavior:
|
||||
ChatMessage(role=Role.SYSTEM, text="s"),
|
||||
]
|
||||
|
||||
await provider.messages_adding("t1", msgs)
|
||||
await provider.invoked(msgs)
|
||||
|
||||
# Ensure load invoked with shaped docs containing defaults
|
||||
assert mock_index.load.await_count == 1
|
||||
@@ -270,9 +258,7 @@ class TestMessagesAddingBehavior:
|
||||
assert d["application_id"] == "app"
|
||||
assert d["agent_id"] == "agent"
|
||||
assert d["user_id"] == "u1"
|
||||
assert d["thread_id"] == "t1" # scoped via per-operation thread id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Skips blank text and disallowed roles (e.g., TOOL) when adding messages
|
||||
async def test_messages_adding_ignores_blank_and_disallowed_roles(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
@@ -282,37 +268,34 @@ class TestMessagesAddingBehavior:
|
||||
ChatMessage(role=Role.USER, text=" "),
|
||||
ChatMessage(role=Role.TOOL, text="tool output"),
|
||||
]
|
||||
await provider.messages_adding("tid", msgs)
|
||||
await provider.invoked(msgs)
|
||||
# No valid messages -> no load
|
||||
assert mock_index.load.await_count == 0
|
||||
|
||||
|
||||
class TestIndexCreationPublicCalls:
|
||||
@pytest.mark.asyncio
|
||||
# Ensures index is created only once when drop=True on first public write call
|
||||
async def test_messages_adding_triggers_index_create_once_when_drop_true(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", drop_redis_index=True)
|
||||
await provider.messages_adding("t1", ChatMessage(role=Role.USER, text="m1"))
|
||||
await provider.messages_adding("t1", ChatMessage(role=Role.USER, text="m2"))
|
||||
provider = RedisProvider(user_id="u1")
|
||||
await provider.invoked(ChatMessage(role=Role.USER, text="m1"))
|
||||
await provider.invoked(ChatMessage(role=Role.USER, text="m2"))
|
||||
# create only on first call
|
||||
assert mock_index.create.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Ensures index is created when drop=False and the index does not exist on first read
|
||||
async def test_model_invoking_triggers_create_when_drop_false_and_not_exists(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=False)
|
||||
provider = RedisProvider(user_id="u1", drop_redis_index=False)
|
||||
provider = RedisProvider(user_id="u1")
|
||||
mock_index.query = AsyncMock(return_value=[{"content": "C"}])
|
||||
await provider.model_invoking([ChatMessage(role=Role.USER, text="q")])
|
||||
await provider.invoking([ChatMessage(role=Role.USER, text="q")])
|
||||
assert mock_index.create.await_count == 1
|
||||
|
||||
|
||||
class TestThreadCreatedAdditional:
|
||||
@pytest.mark.asyncio
|
||||
# Allows None or same thread id repeatedly; different id raises when scoped
|
||||
async def test_thread_created_allows_none_and_same_id(self, patch_index_from_dict): # noqa: ARG002
|
||||
provider = RedisProvider(user_id="u1", scope_to_per_operation_thread_id=True)
|
||||
@@ -327,8 +310,7 @@ class TestThreadCreatedAdditional:
|
||||
|
||||
|
||||
class TestVectorPopulation:
|
||||
@pytest.mark.asyncio
|
||||
# When vectorizer configured, messages_adding should embed content and populate the vector field
|
||||
# When vectorizer configured, invoked should embed content and populate the vector field
|
||||
async def test_messages_adding_populates_vector_field_when_vectorizer_present(
|
||||
self, mock_index: AsyncMock, patch_index_from_dict
|
||||
): # noqa: ARG002
|
||||
@@ -339,7 +321,7 @@ class TestVectorPopulation:
|
||||
vector_field_name="vec",
|
||||
)
|
||||
|
||||
await provider.messages_adding("t1", ChatMessage(role=Role.USER, text="hello"))
|
||||
await provider.invoked(ChatMessage(role=Role.USER, text="hello"))
|
||||
assert mock_index.load.await_count == 1
|
||||
(loaded_args, _kwargs) = mock_index.load.call_args
|
||||
docs = loaded_args[0]
|
||||
@@ -365,12 +347,11 @@ class TestRedisProviderSchemaVectors:
|
||||
class DummyVectorizer:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(AgentException):
|
||||
RedisProvider(user_id="u1", redis_vectorizer=DummyVectorizer(), vector_field_name="vec")
|
||||
|
||||
|
||||
class TestEnsureIndex:
|
||||
@pytest.mark.asyncio
|
||||
# Creates index once and marks _index_initialized to prevent duplicate calls
|
||||
async def test_ensure_index_creates_once(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
# Mock index doesn't exist, so it will be created
|
||||
@@ -386,7 +367,6 @@ class TestEnsureIndex:
|
||||
await provider._ensure_index()
|
||||
assert mock_index.create.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Creates index with overwrite=True when overwrite_index=True
|
||||
async def test_ensure_index_with_overwrite_true(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=True)
|
||||
@@ -397,7 +377,6 @@ class TestEnsureIndex:
|
||||
# Should call create with overwrite=True, drop=False
|
||||
mock_index.create.assert_called_once_with(overwrite=True, drop=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Creates index with overwrite=False when index doesn't exist
|
||||
async def test_ensure_index_create_if_missing(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=False)
|
||||
@@ -408,7 +387,6 @@ class TestEnsureIndex:
|
||||
# Should call create with overwrite=False, drop=False
|
||||
mock_index.create.assert_called_once_with(overwrite=False, drop=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Validates schema compatibility when index exists and overwrite=False
|
||||
async def test_ensure_index_schema_validation_success(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=True)
|
||||
@@ -424,7 +402,6 @@ class TestEnsureIndex:
|
||||
patch_index_from_dict.from_existing.assert_called_once_with("context", redis_url="redis://localhost:6379")
|
||||
mock_index.create.assert_called_once_with(overwrite=False, drop=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# Raises ServiceInitializationError when schemas don't match
|
||||
async def test_ensure_index_schema_validation_failure(self, mock_index: AsyncMock, patch_index_from_dict): # noqa: ARG002
|
||||
mock_index.exists = AsyncMock(return_value=True)
|
||||
|
||||
@@ -104,7 +104,8 @@ ignore = [
|
||||
"D104", # allow missing docstring in public package
|
||||
"D418", # allow overload to have a docstring
|
||||
"TD003", # allow missing link to todo issue
|
||||
"FIX002" # allow todo
|
||||
"FIX002", # allow todo
|
||||
"B027" # allow empty non-abstract method in ABC
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
|
||||
@@ -9,6 +9,7 @@ This folder contains examples demonstrating different ways to create and use age
|
||||
| [`azure_ai_basic.py`](azure_ai_basic.py) | The simplest way to create an agent using `ChatAgent` with `AzureAIAgentClient`. It automatically handles all configuration using environment variables. |
|
||||
| [`azure_ai_with_explicit_settings.py`](azure_ai_with_explicit_settings.py) | Shows how to create an agent with explicitly configured `AzureAIAgentClient` settings, including project endpoint, model deployment, credentials, and agent name. |
|
||||
| [`azure_ai_with_existing_agent.py`](azure_ai_with_existing_agent.py) | Shows how to work with a pre-existing agent by providing the agent ID to the Azure AI chat client. This example also demonstrates proper cleanup of manually created agents. |
|
||||
| [`azure_ai_with_existing_thread.py`](azure_ai_with_existing_thread.py) | Shows how to work with a pre-existing thread by providing the thread ID to the Azure AI chat client. This example also demonstrates proper cleanup of manually created threads. |
|
||||
| [`azure_ai_with_function_tools.py`](azure_ai_with_function_tools.py) | Demonstrates how to use function tools with agents. Shows both agent-level tools (defined when creating the agent) and query-level tools (provided with specific queries). |
|
||||
| [`azure_ai_with_code_interpreter.py`](azure_ai_with_code_interpreter.py) | Shows how to use the HostedCodeInterpreterTool with Azure AI agents to write and execute Python code. Includes helper methods for accessing code interpreter data from response chunks. |
|
||||
| [`azure_ai_with_local_mcp.py`](azure_ai_with_local_mcp.py) | Shows how to integrate Azure AI agents with Model Context Protocol (MCP) servers for enhanced functionality and tool integration. Demonstrates both agent-level and run-level tool configuration. |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import ChatAgent
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("=== Azure AI Chat Client with Existing Thread ===")
|
||||
|
||||
# Create the client
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as client,
|
||||
):
|
||||
# Create an thread that will persist
|
||||
created_thread = await client.agents.threads.create()
|
||||
|
||||
try:
|
||||
async with ChatAgent(
|
||||
# passing in the client is optional here, so if you take the agent_id from the portal
|
||||
# you can use it directly without the two lines above.
|
||||
chat_client=AzureAIAgentClient(project_client=client),
|
||||
instructions="You are a helpful weather agent.",
|
||||
tools=get_weather,
|
||||
) as agent:
|
||||
thread = agent.get_new_thread(service_thread_id=created_thread.id)
|
||||
assert thread.is_initialized
|
||||
result = await agent.run("What's the weather like in Tokyo?", thread=thread)
|
||||
print(f"Result: {result}\n")
|
||||
finally:
|
||||
# Clean up the thread manually
|
||||
await client.agents.threads.delete(created_thread.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import AgentThread, ChatAgent, ChatMessageList
|
||||
from agent_framework import AgentThread, ChatAgent, ChatMessageStore
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from pydantic import Field
|
||||
@@ -125,7 +125,7 @@ async def example_with_existing_thread_messages() -> None:
|
||||
|
||||
# You can also create a new thread from existing messages
|
||||
messages = await thread.message_store.list_messages() if thread.message_store else []
|
||||
new_thread = AgentThread(message_store=ChatMessageList(messages))
|
||||
new_thread = AgentThread(message_store=ChatMessageStore(messages))
|
||||
|
||||
query3 = "How does the Paris weather compare to London?"
|
||||
print(f"User: {query3}")
|
||||
|
||||
@@ -101,8 +101,7 @@ class EchoAgent(BaseAgent):
|
||||
|
||||
# Notify the thread of new messages if provided
|
||||
if thread is not None:
|
||||
await self._notify_thread_of_new_messages(thread, normalized_messages)
|
||||
await self._notify_thread_of_new_messages(thread, response_message)
|
||||
await self._notify_thread_of_new_messages(thread, normalized_messages, response_message)
|
||||
|
||||
return AgentRunResponse(messages=[response_message])
|
||||
|
||||
@@ -136,10 +135,6 @@ class EchoAgent(BaseAgent):
|
||||
else:
|
||||
response_text = f"{self.echo_prefix}[Non-text message received]"
|
||||
|
||||
# Notify the thread of input messages if provided
|
||||
if thread is not None:
|
||||
await self._notify_thread_of_new_messages(thread, normalized_messages)
|
||||
|
||||
# Simulate streaming by yielding the response word by word
|
||||
words = response_text.split()
|
||||
for i, word in enumerate(words):
|
||||
@@ -157,7 +152,7 @@ class EchoAgent(BaseAgent):
|
||||
# Notify the thread of the complete response if provided
|
||||
if thread is not None:
|
||||
complete_response = ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text=response_text)])
|
||||
await self._notify_thread_of_new_messages(thread, complete_response)
|
||||
await self._notify_thread_of_new_messages(thread, normalized_messages, complete_response)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import AgentThread, ChatAgent, ChatMessageList
|
||||
from agent_framework import AgentThread, ChatAgent, ChatMessageStore
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from pydantic import Field
|
||||
|
||||
@@ -119,7 +119,7 @@ async def example_with_existing_thread_messages() -> None:
|
||||
# You can also create a new thread from existing messages
|
||||
messages = await thread.message_store.list_messages() if thread.message_store else []
|
||||
|
||||
new_thread = AgentThread(message_store=ChatMessageList(messages))
|
||||
new_thread = AgentThread(message_store=ChatMessageStore(messages))
|
||||
|
||||
query3 = "How does the Paris weather compare to London?"
|
||||
print(f"User: {query3}")
|
||||
|
||||
@@ -63,7 +63,7 @@ The provider supports both full‑text only and hybrid vector search:
|
||||
|
||||
`redis_basics.py` walks through three scenarios:
|
||||
|
||||
1. Standalone provider usage: adds messages and retrieves context via `model_invoking`.
|
||||
1. Standalone provider usage: adds messages and retrieves context via `invoking`.
|
||||
2. Agent integration: teaches the agent a preference and verifies it is remembered across turns.
|
||||
3. Agent + tool: calls a sample tool (flight search) and then asks the agent to recall details remembered from the tool output.
|
||||
|
||||
@@ -108,5 +108,3 @@ You should see the agent responses and, when using embeddings, context retrieved
|
||||
- Ensure at least one of `application_id`, `agent_id`, `user_id`, or `thread_id` is set; the provider requires a scope.
|
||||
- If using embeddings, verify `OPENAI_API_KEY` is set and reachable.
|
||||
- Make sure Redis exposes RediSearch (Redis Stack image or managed service with search enabled).
|
||||
|
||||
|
||||
|
||||
@@ -27,21 +27,17 @@ Run:
|
||||
python redis_basics.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import ChatMessage, Role
|
||||
from agent_framework_redis._provider import RedisProvider
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from redisvl.utils.vectorize import OpenAITextVectorizer
|
||||
from agent_framework_redis._provider import RedisProvider
|
||||
from redisvl.extensions.cache.embeddings import EmbeddingsCache
|
||||
from redisvl.utils.vectorize import OpenAITextVectorizer
|
||||
|
||||
|
||||
def search_flights(
|
||||
origin_airport_code: str,
|
||||
destination_airport_code: str,
|
||||
detailed: bool = False
|
||||
) -> str:
|
||||
def search_flights(origin_airport_code: str, destination_airport_code: str, detailed: bool = False) -> str:
|
||||
"""Simulated flight-search tool to demonstrate tool memory.
|
||||
|
||||
The agent can call this function, and the returned details can be stored
|
||||
@@ -50,9 +46,27 @@ def search_flights(
|
||||
"""
|
||||
# Minimal static catalog used to simulate a tool's structured output
|
||||
flights = {
|
||||
("JFK", "LAX"): {"airline": "SkyJet", "duration": "6h 15m", "price": 325, "cabin": "Economy", "baggage": "1 checked bag"},
|
||||
("SFO", "SEA"): {"airline": "Pacific Air", "duration": "2h 5m", "price": 129, "cabin": "Economy", "baggage": "Carry-on only"},
|
||||
("LHR", "DXB"): {"airline": "EuroWings", "duration": "6h 50m", "price": 499, "cabin": "Business", "baggage": "2 bags included"},
|
||||
("JFK", "LAX"): {
|
||||
"airline": "SkyJet",
|
||||
"duration": "6h 15m",
|
||||
"price": 325,
|
||||
"cabin": "Economy",
|
||||
"baggage": "1 checked bag",
|
||||
},
|
||||
("SFO", "SEA"): {
|
||||
"airline": "Pacific Air",
|
||||
"duration": "2h 5m",
|
||||
"price": 129,
|
||||
"cabin": "Economy",
|
||||
"baggage": "Carry-on only",
|
||||
},
|
||||
("LHR", "DXB"): {
|
||||
"airline": "EuroWings",
|
||||
"duration": "6h 50m",
|
||||
"price": 499,
|
||||
"cabin": "Business",
|
||||
"baggage": "2 bags included",
|
||||
},
|
||||
}
|
||||
|
||||
route = (origin_airport_code.upper(), destination_airport_code.upper())
|
||||
@@ -97,7 +111,7 @@ async def main() -> None:
|
||||
)
|
||||
# The provider manages persistence and retrieval. application_id/agent_id/user_id
|
||||
# scope data for multi-tenant separation; thread_id (set later) narrows to a
|
||||
# specific conversation.
|
||||
# specific conversation.
|
||||
provider = RedisProvider(
|
||||
redis_url="redis://localhost:6379",
|
||||
index_name="redis_basics",
|
||||
@@ -109,7 +123,7 @@ async def main() -> None:
|
||||
vector_algorithm="hnsw",
|
||||
vector_distance_metric="cosine",
|
||||
)
|
||||
|
||||
|
||||
# Build sample chat messages to persist to Redis
|
||||
messages = [
|
||||
ChatMessage(role=Role.USER, text="runA CONVO: User Message"),
|
||||
@@ -121,14 +135,12 @@ async def main() -> None:
|
||||
# Threads are logical boundaries used by the provider to group and retrieve
|
||||
# conversation-specific context.
|
||||
await provider.thread_created(thread_id="runA")
|
||||
await provider.messages_adding(thread_id="runA", new_messages=messages)
|
||||
await provider.invoked(request_messages=messages)
|
||||
|
||||
# Retrieve relevant memories for a hypothetical model call. The provider uses
|
||||
# the current request messages as the retrieval query and returns context to
|
||||
# be injected into the model's instructions.
|
||||
ctx = await provider.model_invoking([
|
||||
ChatMessage(role=Role.SYSTEM, text="B: Assistant Message")
|
||||
])
|
||||
ctx = await provider.invoking([ChatMessage(role=Role.SYSTEM, text="B: Assistant Message")])
|
||||
|
||||
# Inspect retrieved memories that would be injected into instructions
|
||||
# (Debug-only output so you can verify retrieval works as expected.)
|
||||
@@ -167,13 +179,14 @@ async def main() -> None:
|
||||
# Create agent wired to the Redis context provider. The provider automatically
|
||||
# persists conversational details and surfaces relevant context on each turn.
|
||||
agent = client.create_agent(
|
||||
name="MemoryEnhancedAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Personalize replies using provided context. "
|
||||
"Before answering, always check for stored context"
|
||||
),
|
||||
tools=[],
|
||||
context_providers=provider)
|
||||
name="MemoryEnhancedAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Personalize replies using provided context. "
|
||||
"Before answering, always check for stored context"
|
||||
),
|
||||
tools=[],
|
||||
context_providers=provider,
|
||||
)
|
||||
|
||||
# Teach a user preference; the agent writes this to the provider's memory
|
||||
query = "Remember that I enjoy glugenflorgle"
|
||||
@@ -201,20 +214,21 @@ async def main() -> None:
|
||||
prefix="context_3",
|
||||
application_id="matrix_of_kermits",
|
||||
agent_id="agent_kermit",
|
||||
user_id="kermit"
|
||||
user_id="kermit",
|
||||
)
|
||||
|
||||
# Create agent exposing the flight search tool. Tool outputs are captured by the
|
||||
# provider and become retrievable context for later turns.
|
||||
client = OpenAIChatClient(ai_model_id=os.getenv("OPENAI_CHAT_MODEL_ID"), api_key=os.getenv("OPENAI_API_KEY"))
|
||||
agent = client.create_agent(
|
||||
name="MemoryEnhancedAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Personalize replies using provided context. "
|
||||
"Before answering, always check for stored context"
|
||||
),
|
||||
tools=search_flights,
|
||||
context_providers=provider)
|
||||
name="MemoryEnhancedAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Personalize replies using provided context. "
|
||||
"Before answering, always check for stored context"
|
||||
),
|
||||
tools=search_flights,
|
||||
context_providers=provider,
|
||||
)
|
||||
# Invoke the tool; outputs become part of memory/context
|
||||
query = "Are there any flights from new york city (jfk) to la? Give me details"
|
||||
result = await agent.run(query)
|
||||
@@ -229,5 +243,6 @@ async def main() -> None:
|
||||
# Drop / delete the provider index in Redis
|
||||
await provider.redis_index.delete()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
"""Redis Context Provider: Basic usage and agent integration
|
||||
|
||||
This example demonstrates how to use the Redis ChatMessageStore to persist
|
||||
This example demonstrates how to use the Redis ChatMessageStoreProtocol to persist
|
||||
conversational details. Pass it as a constructor argument to create_agent.
|
||||
|
||||
Requirements:
|
||||
@@ -14,15 +14,14 @@ Run:
|
||||
python redis_conversation.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework_redis._provider import RedisProvider
|
||||
from agent_framework_redis._chat_message_store import RedisChatMessageStore
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from redisvl.utils.vectorize import OpenAITextVectorizer
|
||||
from agent_framework_redis._chat_message_store import RedisChatMessageStore
|
||||
from agent_framework_redis._provider import RedisProvider
|
||||
from redisvl.extensions.cache.embeddings import EmbeddingsCache
|
||||
|
||||
from redisvl.utils.vectorize import OpenAITextVectorizer
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -65,15 +64,15 @@ async def main() -> None:
|
||||
# Create agent wired to the Redis context provider. The provider automatically
|
||||
# persists conversational details and surfaces relevant context on each turn.
|
||||
agent = client.create_agent(
|
||||
name="MemoryEnhancedAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Personalize replies using provided context. "
|
||||
"Before answering, always check for stored context"
|
||||
),
|
||||
tools=[],
|
||||
context_providers=provider,
|
||||
chat_message_store_factory=chat_message_store_factory,
|
||||
)
|
||||
name="MemoryEnhancedAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Personalize replies using provided context. "
|
||||
"Before answering, always check for stored context"
|
||||
),
|
||||
tools=[],
|
||||
context_providers=provider,
|
||||
chat_message_store_factory=chat_message_store_factory,
|
||||
)
|
||||
|
||||
# Teach a user preference; the agent writes this to the provider's memory
|
||||
query = "Remember that I enjoy gumbo"
|
||||
@@ -109,5 +108,6 @@ async def main() -> None:
|
||||
# Drop / delete the provider index in Redis
|
||||
await provider.redis_index.delete()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, ChatOptions, Context, ContextProvider
|
||||
from agent_framework.azure import AzureAIAgentClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
name: str | None = None
|
||||
age: int | None = None
|
||||
|
||||
|
||||
class UserInfoMemory(ContextProvider):
|
||||
def __init__(self, chat_client: ChatClientProtocol, user_info: UserInfo | None = None, **kwargs: Any):
|
||||
"""Create the memory.
|
||||
|
||||
If you pass in kwargs, they will be attempted to be used to create a UserInfo object.
|
||||
"""
|
||||
|
||||
self._chat_client = chat_client
|
||||
if user_info:
|
||||
self.user_info = user_info
|
||||
elif kwargs:
|
||||
self.user_info = UserInfo.model_validate(kwargs)
|
||||
else:
|
||||
self.user_info = UserInfo()
|
||||
|
||||
async def invoked(
|
||||
self,
|
||||
request_messages: ChatMessage | Sequence[ChatMessage],
|
||||
response_messages: ChatMessage | Sequence[ChatMessage] | None = None,
|
||||
invoke_exception: Exception | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Extract user information from messages after each agent call."""
|
||||
# Check if we need to extract user info from user messages
|
||||
user_messages = [msg for msg in request_messages if hasattr(msg, "role") and msg.role.value == "user"] # type: ignore
|
||||
|
||||
if (self.user_info.name is None or self.user_info.age is None) and user_messages:
|
||||
try:
|
||||
# Use the chat client to extract structured information
|
||||
result = await self._chat_client.get_response(
|
||||
messages=request_messages, # type: ignore
|
||||
chat_options=ChatOptions(
|
||||
instructions="Extract the user's name and age from the message if present. If not present return nulls.",
|
||||
response_format=UserInfo,
|
||||
),
|
||||
)
|
||||
|
||||
# Update user info with extracted data
|
||||
if result.value:
|
||||
if self.user_info.name is None and result.value.name:
|
||||
self.user_info.name = result.value.name
|
||||
if self.user_info.age is None and result.value.age:
|
||||
self.user_info.age = result.value.age
|
||||
|
||||
except Exception:
|
||||
pass # Failed to extract, continue without updating
|
||||
|
||||
async def invoking(self, messages: ChatMessage | MutableSequence[ChatMessage], **kwargs: Any) -> Context:
|
||||
"""Provide user information context before each agent call."""
|
||||
instructions: list[str] = []
|
||||
|
||||
if self.user_info.name is None:
|
||||
instructions.append(
|
||||
"Ask the user for their name and politely decline to answer any questions until they provide it."
|
||||
)
|
||||
else:
|
||||
instructions.append(f"The user's name is {self.user_info.name}.")
|
||||
|
||||
if self.user_info.age is None:
|
||||
instructions.append(
|
||||
"Ask the user for their age and politely decline to answer any questions until they provide it."
|
||||
)
|
||||
else:
|
||||
instructions.append(f"The user's age is {self.user_info.age}.")
|
||||
|
||||
# Return context with additional instructions
|
||||
return Context(instructions=" ".join(instructions))
|
||||
|
||||
def serialize(self) -> str:
|
||||
"""Serialize the user info for thread persistence."""
|
||||
return self.user_info.model_dump_json()
|
||||
|
||||
|
||||
async def main():
|
||||
async with AzureCliCredential() as credential:
|
||||
chat_client = AzureAIAgentClient(async_credential=credential)
|
||||
|
||||
# Create the memory provider
|
||||
memory_provider = UserInfoMemory(chat_client)
|
||||
|
||||
# Create the agent with memory
|
||||
async with ChatAgent(
|
||||
chat_client=chat_client,
|
||||
instructions="You are a friendly assistant. Always address the user by their name.",
|
||||
context_providers=memory_provider,
|
||||
) as agent:
|
||||
# Create a new thread for the conversation
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
print(await agent.run("Hello, what is the square root of 9?", thread=thread))
|
||||
print(await agent.run("My name is Ruaidhrí", thread=thread))
|
||||
print(await agent.run("I am 20 years old", thread=thread))
|
||||
|
||||
# Access the memory component via the thread's get_service method and inspect the memories
|
||||
user_info_memory = thread.context_provider.providers[0] # type: ignore
|
||||
if user_info_memory:
|
||||
print()
|
||||
print(f"MEMORY - User Name: {user_info_memory.user_info.name}") # type: ignore
|
||||
print(f"MEMORY - User Age: {user_info_memory.user_info.age}") # type: ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, ChatMessageStore
|
||||
from agent_framework import ChatMessage, ChatMessageStoreProtocol
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -15,7 +15,7 @@ class CustomStoreState(BaseModel):
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class CustomChatMessageStore(ChatMessageStore):
|
||||
class CustomChatMessageStore(ChatMessageStoreProtocol):
|
||||
"""Implementation of custom chat message store.
|
||||
In real applications, this can be an implementation of relational database or vector store."""
|
||||
|
||||
|
||||
@@ -5,48 +5,47 @@ import os
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import AgentThread
|
||||
from agent_framework._threads import deserialize_thread_state
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.redis import RedisChatMessageStore
|
||||
|
||||
|
||||
async def example_basic_redis_store() -> None:
|
||||
async def example_manual_memory_store() -> None:
|
||||
"""Basic example of using Redis chat message store."""
|
||||
print("=== Basic Redis Chat Message Store Example ===")
|
||||
|
||||
|
||||
# Create Redis store with auto-generated thread ID
|
||||
redis_store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
# thread_id will be auto-generated if not provided
|
||||
)
|
||||
|
||||
|
||||
print(f"Created store with thread ID: {redis_store.thread_id}")
|
||||
|
||||
|
||||
# Create thread with Redis store
|
||||
thread = AgentThread(message_store=redis_store)
|
||||
|
||||
|
||||
# Create agent
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="RedisBot",
|
||||
instructions="You are a helpful assistant that remembers our conversation using Redis.",
|
||||
)
|
||||
|
||||
|
||||
# Have a conversation
|
||||
print("\n--- Starting conversation ---")
|
||||
query1 = "Hello! My name is Alice and I love pizza."
|
||||
print(f"User: {query1}")
|
||||
response1 = await agent.run(query1, thread=thread)
|
||||
print(f"Agent: {response1.text}")
|
||||
|
||||
|
||||
query2 = "What do you remember about me?"
|
||||
print(f"User: {query2}")
|
||||
response2 = await agent.run(query2, thread=thread)
|
||||
print(f"Agent: {response2.text}")
|
||||
|
||||
|
||||
# Show messages are stored in Redis
|
||||
messages = await redis_store.list_messages()
|
||||
print(f"\nTotal messages in Redis: {len(messages)}")
|
||||
|
||||
|
||||
# Cleanup
|
||||
await redis_store.clear()
|
||||
await redis_store.aclose()
|
||||
@@ -56,51 +55,51 @@ async def example_basic_redis_store() -> None:
|
||||
async def example_user_session_management() -> None:
|
||||
"""Example of managing user sessions with Redis."""
|
||||
print("=== User Session Management Example ===")
|
||||
|
||||
|
||||
user_id = "alice_123"
|
||||
session_id = f"session_{uuid4()}"
|
||||
|
||||
|
||||
# Create Redis store for specific user session
|
||||
def create_user_session_store():
|
||||
return RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id=f"user_{user_id}_{session_id}",
|
||||
max_messages=10 # Keep only last 10 messages
|
||||
max_messages=10, # Keep only last 10 messages
|
||||
)
|
||||
|
||||
|
||||
# Create agent with factory pattern
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="SessionBot",
|
||||
instructions="You are a helpful assistant. Keep track of user preferences.",
|
||||
chat_message_store_factory=create_user_session_store,
|
||||
)
|
||||
|
||||
|
||||
# Start conversation
|
||||
thread = agent.get_new_thread()
|
||||
|
||||
|
||||
print(f"Started session for user {user_id}")
|
||||
if hasattr(thread.message_store, 'thread_id'):
|
||||
if hasattr(thread.message_store, "thread_id"):
|
||||
print(f"Thread ID: {thread.message_store.thread_id}") # type: ignore[union-attr]
|
||||
|
||||
|
||||
# Simulate conversation
|
||||
queries = [
|
||||
"Hi, I'm Alice and I prefer vegetarian food.",
|
||||
"What restaurants would you recommend?",
|
||||
"I also love Italian cuisine.",
|
||||
"Can you remember my food preferences?"
|
||||
"Can you remember my food preferences?",
|
||||
]
|
||||
|
||||
|
||||
for i, query in enumerate(queries, 1):
|
||||
print(f"\n--- Message {i} ---")
|
||||
print(f"User: {query}")
|
||||
response = await agent.run(query, thread=thread)
|
||||
print(f"Agent: {response.text}")
|
||||
|
||||
|
||||
# Show persistent storage
|
||||
if thread.message_store:
|
||||
messages = await thread.message_store.list_messages() # type: ignore[union-attr]
|
||||
print(f"\nMessages stored for user {user_id}: {len(messages)}")
|
||||
|
||||
|
||||
# Cleanup
|
||||
if thread.message_store:
|
||||
await thread.message_store.clear() # type: ignore[union-attr]
|
||||
@@ -111,58 +110,58 @@ async def example_user_session_management() -> None:
|
||||
async def example_conversation_persistence() -> None:
|
||||
"""Example of conversation persistence across application restarts."""
|
||||
print("=== Conversation Persistence Example ===")
|
||||
|
||||
|
||||
conversation_id = "persistent_chat_001"
|
||||
|
||||
|
||||
# Phase 1: Start conversation
|
||||
print("--- Phase 1: Starting conversation ---")
|
||||
store1 = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
thread1 = AgentThread(message_store=store1)
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="PersistentBot",
|
||||
instructions="You are a helpful assistant. Remember our conversation history.",
|
||||
)
|
||||
|
||||
|
||||
# Start conversation
|
||||
query1 = "Hello! I'm working on a Python project about machine learning."
|
||||
print(f"User: {query1}")
|
||||
response1 = await agent.run(query1, thread=thread1)
|
||||
print(f"Agent: {response1.text}")
|
||||
|
||||
|
||||
query2 = "I'm specifically interested in neural networks."
|
||||
print(f"User: {query2}")
|
||||
response2 = await agent.run(query2, thread=thread1)
|
||||
print(f"Agent: {response2.text}")
|
||||
|
||||
|
||||
print(f"Stored {len(await store1.list_messages())} messages in Redis")
|
||||
await store1.aclose()
|
||||
|
||||
|
||||
# Phase 2: Resume conversation (simulating app restart)
|
||||
print("\n--- Phase 2: Resuming conversation (after 'restart') ---")
|
||||
store2 = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id=conversation_id, # Same thread ID
|
||||
)
|
||||
|
||||
|
||||
thread2 = AgentThread(message_store=store2)
|
||||
|
||||
|
||||
# Continue conversation - agent should remember context
|
||||
query3 = "What was I working on before?"
|
||||
print(f"User: {query3}")
|
||||
response3 = await agent.run(query3, thread=thread2)
|
||||
print(f"Agent: {response3.text}")
|
||||
|
||||
|
||||
query4 = "Can you suggest some Python libraries for neural networks?"
|
||||
print(f"User: {query4}")
|
||||
response4 = await agent.run(query4, thread=thread2)
|
||||
print(f"Agent: {response4.text}")
|
||||
|
||||
|
||||
print(f"Total messages after resuming: {len(await store2.list_messages())}")
|
||||
|
||||
|
||||
# Cleanup
|
||||
await store2.clear()
|
||||
await store2.aclose()
|
||||
@@ -172,52 +171,49 @@ async def example_conversation_persistence() -> None:
|
||||
async def example_thread_serialization() -> None:
|
||||
"""Example of thread state serialization and deserialization."""
|
||||
print("=== Thread Serialization Example ===")
|
||||
|
||||
|
||||
# Create initial thread with Redis store
|
||||
original_store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id="serialization_test",
|
||||
max_messages=50,
|
||||
)
|
||||
|
||||
|
||||
original_thread = AgentThread(message_store=original_store)
|
||||
|
||||
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="SerializationBot",
|
||||
instructions="You are a helpful assistant.",
|
||||
)
|
||||
|
||||
|
||||
# Have initial conversation
|
||||
print("--- Initial conversation ---")
|
||||
query1 = "Hello! I'm testing serialization."
|
||||
print(f"User: {query1}")
|
||||
response1 = await agent.run(query1, thread=original_thread)
|
||||
print(f"Agent: {response1.text}")
|
||||
|
||||
|
||||
# Serialize thread state
|
||||
serialized_thread = await original_thread.serialize()
|
||||
print(f"\nSerialized thread state: {serialized_thread}")
|
||||
|
||||
|
||||
# Close original connection
|
||||
await original_store.aclose()
|
||||
|
||||
|
||||
# Deserialize thread state (simulating loading from database/file)
|
||||
print("\n--- Deserializing thread state ---")
|
||||
|
||||
|
||||
# Create a new thread with the same Redis store type
|
||||
# This ensures the correct store type is used for deserialization
|
||||
restored_store = RedisChatMessageStore(redis_url="redis://localhost:6379")
|
||||
restored_thread = AgentThread(message_store=restored_store)
|
||||
|
||||
# Deserialize the thread state into the properly typed thread
|
||||
await deserialize_thread_state(restored_thread, serialized_thread)
|
||||
|
||||
restored_thread = await AgentThread.deserialize(serialized_thread, message_store=restored_store)
|
||||
|
||||
# Continue conversation with restored thread
|
||||
query2 = "Do you remember what I said about testing?"
|
||||
print(f"User: {query2}")
|
||||
response2 = await agent.run(query2, thread=restored_thread)
|
||||
print(f"Agent: {response2.text}")
|
||||
|
||||
|
||||
# Cleanup
|
||||
if restored_thread.message_store:
|
||||
await restored_thread.message_store.clear() # type: ignore[union-attr]
|
||||
@@ -228,20 +224,20 @@ async def example_thread_serialization() -> None:
|
||||
async def example_message_limits() -> None:
|
||||
"""Example of automatic message trimming with limits."""
|
||||
print("=== Message Limits Example ===")
|
||||
|
||||
|
||||
# Create store with small message limit
|
||||
store = RedisChatMessageStore(
|
||||
redis_url="redis://localhost:6379",
|
||||
thread_id="limits_test",
|
||||
max_messages=3, # Keep only 3 most recent messages
|
||||
)
|
||||
|
||||
|
||||
thread = AgentThread(message_store=store)
|
||||
agent = OpenAIChatClient().create_agent(
|
||||
name="LimitBot",
|
||||
instructions="You are a helpful assistant with limited memory.",
|
||||
)
|
||||
|
||||
|
||||
# Send multiple messages to test trimming
|
||||
messages = [
|
||||
"Message 1: Hello!",
|
||||
@@ -250,22 +246,22 @@ async def example_message_limits() -> None:
|
||||
"Message 4: Tell me a joke.",
|
||||
"Message 5: This should trigger trimming.",
|
||||
]
|
||||
|
||||
|
||||
for i, query in enumerate(messages, 1):
|
||||
print(f"\n--- Sending message {i} ---")
|
||||
print(f"User: {query}")
|
||||
response = await agent.run(query, thread=thread)
|
||||
print(f"Agent: {response.text}")
|
||||
|
||||
|
||||
stored_messages = await store.list_messages()
|
||||
print(f"Messages in store: {len(stored_messages)}")
|
||||
if len(stored_messages) > 0:
|
||||
print(f"Oldest message: {stored_messages[0].text[:30]}...")
|
||||
|
||||
|
||||
# Final check
|
||||
final_messages = await store.list_messages()
|
||||
print(f"\nFinal message count: {len(final_messages)} (should be <= 6: 3 messages × 2 per exchange)")
|
||||
|
||||
|
||||
# Cleanup
|
||||
await store.clear()
|
||||
await store.aclose()
|
||||
@@ -280,12 +276,12 @@ async def main() -> None:
|
||||
print("- Redis server running on localhost:6379")
|
||||
print("- OPENAI_API_KEY environment variable set")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
# Check prerequisites
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("ERROR: OPENAI_API_KEY environment variable not set")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Test Redis connection
|
||||
test_store = RedisChatMessageStore(redis_url="redis://localhost:6379")
|
||||
@@ -298,17 +294,17 @@ async def main() -> None:
|
||||
print(f"ERROR: Cannot connect to Redis: {e}")
|
||||
print("Please ensure Redis is running on localhost:6379")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Run all examples
|
||||
await example_basic_redis_store()
|
||||
await example_manual_memory_store()
|
||||
await example_user_session_management()
|
||||
await example_conversation_persistence()
|
||||
await example_thread_serialization()
|
||||
await example_message_limits()
|
||||
|
||||
|
||||
print("All examples completed successfully!")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error running examples: {e}")
|
||||
raise
|
||||
|
||||
Generated
+144
-126
@@ -1322,7 +1322,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
@@ -1340,16 +1340,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.117.1"
|
||||
version = "0.118.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155, upload-time = "2025-09-20T20:16:56.663Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536, upload-time = "2025-09-29T03:37:23.126Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959, upload-time = "2025-09-20T20:16:53.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1670,53 +1670,63 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.75.0"
|
||||
version = "1.75.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/88/fe2844eefd3d2188bc0d7a2768c6375b46dfd96469ea52d8aeee8587d7e0/grpcio-1.75.0.tar.gz", hash = "sha256:b989e8b09489478c2d19fecc744a298930f40d8b27c3638afbfe84d22f36ce4e", size = 12722485, upload-time = "2025-09-16T09:20:21.731Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/f7/8963848164c7604efb3a3e6ee457fdb3a469653e19002bd24742473254f8/grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2", size = 12731327, upload-time = "2025-09-26T09:03:36.887Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/90/91f780f6cb8b2aa1bc8b8f8561a4e9d3bfe5dea10a4532843f2b044e18ac/grpcio-1.75.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:1ec9cbaec18d9597c718b1ed452e61748ac0b36ba350d558f9ded1a94cc15ec7", size = 5696373, upload-time = "2025-09-16T09:18:07.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c6/eaf9065ff15d0994e1674e71e1ca9542ee47f832b4df0fde1b35e5641fa1/grpcio-1.75.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7ee5ee42bfae8238b66a275f9ebcf6f295724375f2fa6f3b52188008b6380faf", size = 11465905, upload-time = "2025-09-16T09:18:12.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/21/ae33e514cb7c3f936b378d1c7aab6d8e986814b3489500c5cc860c48ce88/grpcio-1.75.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9146e40378f551eed66c887332afc807fcce593c43c698e21266a4227d4e20d2", size = 6282149, upload-time = "2025-09-16T09:18:15.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/46/dff6344e6f3e81707bc87bba796592036606aca04b6e9b79ceec51902b80/grpcio-1.75.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0c40f368541945bb664857ecd7400acb901053a1abbcf9f7896361b2cfa66798", size = 6940277, upload-time = "2025-09-16T09:18:17.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/5f/e52cb2c16e097d950c36e7bb2ef46a3b2e4c7ae6b37acb57d88538182b85/grpcio-1.75.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50a6e43a9adc6938e2a16c9d9f8a2da9dd557ddd9284b73b07bd03d0e098d1e9", size = 6460422, upload-time = "2025-09-16T09:18:19.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/527533f0bd9cace7cd800b7dae903e273cc987fc472a398a4bb6747fec9b/grpcio-1.75.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dce15597ca11913b78e1203c042d5723e3ea7f59e7095a1abd0621be0e05b895", size = 7089969, upload-time = "2025-09-16T09:18:21.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/4f/1d448820bc88a2be7045aac817a59ba06870e1ebad7ed19525af7ac079e7/grpcio-1.75.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:851194eec47755101962da423f575ea223c9dd7f487828fe5693920e8745227e", size = 8033548, upload-time = "2025-09-16T09:18:23.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/00/19e87ab12c8b0d73a252eef48664030de198514a4e30bdf337fa58bcd4dd/grpcio-1.75.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ca123db0813eef80625a4242a0c37563cb30a3edddebe5ee65373854cf187215", size = 7487161, upload-time = "2025-09-16T09:18:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d0/f7b9deaa6ccca9997fa70b4e143cf976eaec9476ecf4d05f7440ac400635/grpcio-1.75.0-cp310-cp310-win32.whl", hash = "sha256:222b0851e20c04900c63f60153503e918b08a5a0fad8198401c0b1be13c6815b", size = 3946254, upload-time = "2025-09-16T09:18:28.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/42/8d04744c7dc720cc9805a27f879cbf7043bb5c78dce972f6afb8613860de/grpcio-1.75.0-cp310-cp310-win_amd64.whl", hash = "sha256:bb58e38a50baed9b21492c4b3f3263462e4e37270b7ea152fc10124b4bd1c318", size = 4640072, upload-time = "2025-09-16T09:18:30.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b7/a6f42596fc367656970f5811e5d2d9912ca937aa90621d5468a11680ef47/grpcio-1.75.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:7f89d6d0cd43170a80ebb4605cad54c7d462d21dc054f47688912e8bf08164af", size = 5699769, upload-time = "2025-09-16T09:18:32.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/42/284c463a311cd2c5f804fd4fdbd418805460bd5d702359148dd062c1685d/grpcio-1.75.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cb6c5b075c2d092f81138646a755f0dad94e4622300ebef089f94e6308155d82", size = 11480362, upload-time = "2025-09-16T09:18:35.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/10/60d54d5a03062c3ae91bddb6e3acefe71264307a419885f453526d9203ff/grpcio-1.75.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:494dcbade5606128cb9f530ce00331a90ecf5e7c5b243d373aebdb18e503c346", size = 6284753, upload-time = "2025-09-16T09:18:38.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/af/381a4bfb04de5e2527819452583e694df075c7a931e9bf1b2a603b593ab2/grpcio-1.75.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:050760fd29c8508844a720f06c5827bb00de8f5e02f58587eb21a4444ad706e5", size = 6944103, upload-time = "2025-09-16T09:18:40.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/18/c80dd7e1828bd6700ce242c1616871927eef933ed0c2cee5c636a880e47b/grpcio-1.75.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:266fa6209b68a537b2728bb2552f970e7e78c77fe43c6e9cbbe1f476e9e5c35f", size = 6464036, upload-time = "2025-09-16T09:18:43.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3f/78520c7ed9ccea16d402530bc87958bbeb48c42a2ec8032738a7864d38f8/grpcio-1.75.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:06d22e1d8645e37bc110f4c589cb22c283fd3de76523065f821d6e81de33f5d4", size = 7097455, upload-time = "2025-09-16T09:18:45.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/69/3cebe4901a865eb07aefc3ee03a02a632e152e9198dadf482a7faf926f31/grpcio-1.75.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9880c323595d851292785966cadb6c708100b34b163cab114e3933f5773cba2d", size = 8037203, upload-time = "2025-09-16T09:18:47.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ed/1e483d1eba5032642c10caf28acf07ca8de0508244648947764956db346a/grpcio-1.75.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:55a2d5ae79cd0f68783fb6ec95509be23746e3c239290b2ee69c69a38daa961a", size = 7492085, upload-time = "2025-09-16T09:18:50.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/65/6ef676aa7dbd9578dfca990bb44d41a49a1e36344ca7d79de6b59733ba96/grpcio-1.75.0-cp311-cp311-win32.whl", hash = "sha256:352dbdf25495eef584c8de809db280582093bc3961d95a9d78f0dfb7274023a2", size = 3944697, upload-time = "2025-09-16T09:18:53.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/83/b753373098b81ec5cb01f71c21dfd7aafb5eb48a1566d503e9fd3c1254fe/grpcio-1.75.0-cp311-cp311-win_amd64.whl", hash = "sha256:678b649171f229fb16bda1a2473e820330aa3002500c4f9fd3a74b786578e90f", size = 4642235, upload-time = "2025-09-16T09:18:56.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/93/a1b29c2452d15cecc4a39700fbf54721a3341f2ddbd1bd883f8ec0004e6e/grpcio-1.75.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:fa35ccd9501ffdd82b861809cbfc4b5b13f4b4c5dc3434d2d9170b9ed38a9054", size = 5661861, upload-time = "2025-09-16T09:18:58.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ce/7280df197e602d14594e61d1e60e89dfa734bb59a884ba86cdd39686aadb/grpcio-1.75.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:0fcb77f2d718c1e58cc04ef6d3b51e0fa3b26cf926446e86c7eba105727b6cd4", size = 11459982, upload-time = "2025-09-16T09:19:01.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9b/37e61349771f89b543a0a0bbc960741115ea8656a2414bfb24c4de6f3dd7/grpcio-1.75.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36764a4ad9dc1eb891042fab51e8cdf7cc014ad82cee807c10796fb708455041", size = 6239680, upload-time = "2025-09-16T09:19:04.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/66/f645d9d5b22ca307f76e71abc83ab0e574b5dfef3ebde4ec8b865dd7e93e/grpcio-1.75.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:725e67c010f63ef17fc052b261004942763c0b18dcd84841e6578ddacf1f9d10", size = 6908511, upload-time = "2025-09-16T09:19:07.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9a/34b11cd62d03c01b99068e257595804c695c3c119596c7077f4923295e19/grpcio-1.75.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91fbfc43f605c5ee015c9056d580a70dd35df78a7bad97e05426795ceacdb59f", size = 6429105, upload-time = "2025-09-16T09:19:10.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/46/76eaceaad1f42c1e7e6a5b49a61aac40fc5c9bee4b14a1630f056ac3a57e/grpcio-1.75.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a9337ac4ce61c388e02019d27fa837496c4b7837cbbcec71b05934337e51531", size = 7060578, upload-time = "2025-09-16T09:19:12.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/82/181a0e3f1397b6d43239e95becbeb448563f236c0db11ce990f073b08d01/grpcio-1.75.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ee16e232e3d0974750ab5f4da0ab92b59d6473872690b5e40dcec9a22927f22e", size = 8003283, upload-time = "2025-09-16T09:19:15.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/09/a335bca211f37a3239be4b485e3c12bf3da68d18b1f723affdff2b9e9680/grpcio-1.75.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55dfb9122973cc69520b23d39867726722cafb32e541435707dc10249a1bdbc6", size = 7460319, upload-time = "2025-09-16T09:19:18.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/59/6330105cdd6bc4405e74c96838cd7e148c3653ae3996e540be6118220c79/grpcio-1.75.0-cp312-cp312-win32.whl", hash = "sha256:fb64dd62face3d687a7b56cd881e2ea39417af80f75e8b36f0f81dfd93071651", size = 3934011, upload-time = "2025-09-16T09:19:21.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/14/e1309a570b7ebdd1c8ca24c4df6b8d6690009fa8e0d997cb2c026ce850c9/grpcio-1.75.0-cp312-cp312-win_amd64.whl", hash = "sha256:6b365f37a9c9543a9e91c6b4103d68d38d5bcb9965b11d5092b3c157bd6a5ee7", size = 4637934, upload-time = "2025-09-16T09:19:23.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/64/dbce0ffb6edaca2b292d90999dd32a3bd6bc24b5b77618ca28440525634d/grpcio-1.75.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:1bb78d052948d8272c820bb928753f16a614bb2c42fbf56ad56636991b427518", size = 5666860, upload-time = "2025-09-16T09:19:25.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e6/da02c8fa882ad3a7f868d380bb3da2c24d35dd983dd12afdc6975907a352/grpcio-1.75.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:9dc4a02796394dd04de0b9673cb79a78901b90bb16bf99ed8cb528c61ed9372e", size = 11455148, upload-time = "2025-09-16T09:19:28.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/a0/84f87f6c2cf2a533cfce43b2b620eb53a51428ec0c8fe63e5dd21d167a70/grpcio-1.75.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:437eeb16091d31498585d73b133b825dc80a8db43311e332c08facf820d36894", size = 6243865, upload-time = "2025-09-16T09:19:31.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/12/53da07aa701a4839dd70d16e61ce21ecfcc9e929058acb2f56e9b2dd8165/grpcio-1.75.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c2c39984e846bd5da45c5f7bcea8fafbe47c98e1ff2b6f40e57921b0c23a52d0", size = 6915102, upload-time = "2025-09-16T09:19:33.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/c0/7eaceafd31f52ec4bf128bbcf36993b4bc71f64480f3687992ddd1a6e315/grpcio-1.75.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38d665f44b980acdbb2f0e1abf67605ba1899f4d2443908df9ec8a6f26d2ed88", size = 6432042, upload-time = "2025-09-16T09:19:36.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/12/a2ce89a9f4fc52a16ed92951f1b05f53c17c4028b3db6a4db7f08332bee8/grpcio-1.75.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e8e752ab5cc0a9c5b949808c000ca7586223be4f877b729f034b912364c3964", size = 7062984, upload-time = "2025-09-16T09:19:39.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/a6/2642a9b491e24482d5685c0f45c658c495a5499b43394846677abed2c966/grpcio-1.75.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a6788b30aa8e6f207c417874effe3f79c2aa154e91e78e477c4825e8b431ce0", size = 8001212, upload-time = "2025-09-16T09:19:41.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/20/530d4428750e9ed6ad4254f652b869a20a40a276c1f6817b8c12d561f5ef/grpcio-1.75.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc33e67cab6141c54e75d85acd5dec616c5095a957ff997b4330a6395aa9b51", size = 7457207, upload-time = "2025-09-16T09:19:44.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/6f/843670007e0790af332a21468d10059ea9fdf97557485ae633b88bd70efc/grpcio-1.75.0-cp313-cp313-win32.whl", hash = "sha256:c8cfc780b7a15e06253aae5f228e1e84c0d3c4daa90faf5bc26b751174da4bf9", size = 3934235, upload-time = "2025-09-16T09:19:46.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/92/c846b01b38fdf9e2646a682b12e30a70dc7c87dfe68bd5e009ee1501c14b/grpcio-1.75.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c91d5b16eff3cbbe76b7a1eaaf3d91e7a954501e9d4f915554f87c470475c3d", size = 4637558, upload-time = "2025-09-16T09:19:49.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/57/89fd829fb00a6d0bee3fbcb2c8a7aa0252d908949b6ab58bfae99d39d77e/grpcio-1.75.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:1712b5890b22547dd29f3215c5788d8fc759ce6dd0b85a6ba6e2731f2d04c088", size = 5705534, upload-time = "2025-09-26T09:00:52.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/dd/2f8536e092551cf804e96bcda79ecfbc51560b214a0f5b7ebc253f0d4664/grpcio-1.75.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8d04e101bba4b55cea9954e4aa71c24153ba6182481b487ff376da28d4ba46cf", size = 11484103, upload-time = "2025-09-26T09:00:59.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3d/affe2fb897804c98d56361138e73786af8f4dd876b9d9851cfe6342b53c8/grpcio-1.75.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:683cfc70be0c1383449097cba637317e4737a357cfc185d887fd984206380403", size = 6289953, upload-time = "2025-09-26T09:01:03.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/aa/0f40b7f47a0ff10d7e482bc3af22dac767c7ff27205915f08962d5ca87a2/grpcio-1.75.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:491444c081a54dcd5e6ada57314321ae526377f498d4aa09d975c3241c5b9e1c", size = 6949785, upload-time = "2025-09-26T09:01:07.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/45/b04407e44050781821c84f26df71b3f7bc469923f92f9f8bc27f1406dbcc/grpcio-1.75.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce08d4e112d0d38487c2b631ec8723deac9bc404e9c7b1011426af50a79999e4", size = 6465708, upload-time = "2025-09-26T09:01:11.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/3e/4ae3ec0a4d20dcaafbb6e597defcde06399ccdc5b342f607323f3b47f0a3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5a2acda37fc926ccc4547977ac3e56b1df48fe200de968e8c8421f6e3093df6c", size = 7100912, upload-time = "2025-09-26T09:01:14.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/3f/a9085dab5c313bb0cb853f222d095e2477b9b8490a03634cdd8d19daa5c3/grpcio-1.75.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:745c5fe6bf05df6a04bf2d11552c7d867a2690759e7ab6b05c318a772739bd75", size = 8042497, upload-time = "2025-09-26T09:01:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/87/ea54eba931ab9ed3f999ba95f5d8d01a20221b664725bab2fe93e3dee848/grpcio-1.75.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:259526a7159d39e2db40d566fe3e8f8e034d0fb2db5bf9c00e09aace655a4c2b", size = 7493284, upload-time = "2025-09-26T09:01:20.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/5e/287f1bf1a998f4ac46ef45d518de3b5da08b4e86c7cb5e1108cee30b0282/grpcio-1.75.1-cp310-cp310-win32.whl", hash = "sha256:f4b29b9aabe33fed5df0a85e5f13b09ff25e2c05bd5946d25270a8bd5682dac9", size = 3950809, upload-time = "2025-09-26T09:01:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/3cbfc06a4ec160dc77403b29ecb5cf76ae329eb63204fea6a7c715f1dfdb/grpcio-1.75.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf2e760978dcce7ff7d465cbc7e276c3157eedc4c27aa6de7b594c7a295d3d61", size = 4644704, upload-time = "2025-09-26T09:01:25.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/3c/35ca9747473a306bfad0cee04504953f7098527cd112a4ab55c55af9e7bd/grpcio-1.75.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:573855ca2e58e35032aff30bfbd1ee103fbcf4472e4b28d4010757700918e326", size = 5709761, upload-time = "2025-09-26T09:01:28.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2c/ecbcb4241e4edbe85ac2663f885726fea0e947767401288b50d8fdcb9200/grpcio-1.75.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:6a4996a2c8accc37976dc142d5991adf60733e223e5c9a2219e157dc6a8fd3a2", size = 11496691, upload-time = "2025-09-26T09:01:31.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/40/bc07aee2911f0d426fa53fe636216100c31a8ea65a400894f280274cb023/grpcio-1.75.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b1ea1bbe77ecbc1be00af2769f4ae4a88ce93be57a4f3eebd91087898ed749f9", size = 6296084, upload-time = "2025-09-26T09:01:34.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d1/10c067f6c67396cbf46448b80f27583b5e8c4b46cdfbe18a2a02c2c2f290/grpcio-1.75.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e5b425aee54cc5e3e3c58f00731e8a33f5567965d478d516d35ef99fd648ab68", size = 6950403, upload-time = "2025-09-26T09:01:36.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/42/5f628abe360b84dfe8dd8f32be6b0606dc31dc04d3358eef27db791ea4d5/grpcio-1.75.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0049a7bf547dafaeeb1db17079ce79596c298bfe308fc084d023c8907a845b9a", size = 6470166, upload-time = "2025-09-26T09:01:39.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/a24035080251324019882ee2265cfde642d6476c0cf8eb207fc693fcebdc/grpcio-1.75.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b8ea230c7f77c0a1a3208a04a1eda164633fb0767b4cefd65a01079b65e5b1f", size = 7107828, upload-time = "2025-09-26T09:01:41.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/d18b984c1c9ba0318e3628dbbeb6af77a5007f02abc378c845070f2d3edd/grpcio-1.75.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:36990d629c3c9fb41e546414e5af52d0a7af37ce7113d9682c46d7e2919e4cca", size = 8045421, upload-time = "2025-09-26T09:01:45.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/b6/4bf9aacff45deca5eac5562547ed212556b831064da77971a4e632917da3/grpcio-1.75.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b10ad908118d38c2453ade7ff790e5bce36580c3742919007a2a78e3a1e521ca", size = 7503290, upload-time = "2025-09-26T09:01:49.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/15/d8d69d10223cb54c887a2180bd29fe5fa2aec1d4995c8821f7aa6eaf72e4/grpcio-1.75.1-cp311-cp311-win32.whl", hash = "sha256:d6be2b5ee7bea656c954dcf6aa8093c6f0e6a3ef9945c99d99fcbfc88c5c0bfe", size = 3950631, upload-time = "2025-09-26T09:01:51.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/40/7b8642d45fff6f83300c24eaac0380a840e5e7fe0e8d80afd31b99d7134e/grpcio-1.75.1-cp311-cp311-win_amd64.whl", hash = "sha256:61c692fb05956b17dd6d1ab480f7f10ad0536dba3bc8fd4e3c7263dc244ed772", size = 4646131, upload-time = "2025-09-26T09:01:53.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/81/42be79e73a50aaa20af66731c2defeb0e8c9008d9935a64dd8ea8e8c44eb/grpcio-1.75.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:7b888b33cd14085d86176b1628ad2fcbff94cfbbe7809465097aa0132e58b018", size = 5668314, upload-time = "2025-09-26T09:01:55.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/3686ed15822fedc58c22f82b3a7403d9faf38d7c33de46d4de6f06e49426/grpcio-1.75.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8775036efe4ad2085975531d221535329f5dac99b6c2a854a995456098f99546", size = 11476125, upload-time = "2025-09-26T09:01:57.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/85/21c71d674f03345ab183c634ecd889d3330177e27baea8d5d247a89b6442/grpcio-1.75.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb658f703468d7fbb5dcc4037c65391b7dc34f808ac46ed9136c24fc5eeb041d", size = 6246335, upload-time = "2025-09-26T09:02:00.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/db/3beb661bc56a385ae4fa6b0e70f6b91ac99d47afb726fe76aaff87ebb116/grpcio-1.75.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b7177a1cdb3c51b02b0c0a256b0a72fdab719600a693e0e9037949efffb200b", size = 6916309, upload-time = "2025-09-26T09:02:02.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf", size = 6435419, upload-time = "2025-09-26T09:02:05.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/b8/090c98983e0a9d602e3f919a6e2d4e470a8b489452905f9a0fa472cac059/grpcio-1.75.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d86880ecaeb5b2f0a8afa63824de93adb8ebe4e49d0e51442532f4e08add7d6", size = 7064893, upload-time = "2025-09-26T09:02:07.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c0/6d53d4dbbd00f8bd81571f5478d8a95528b716e0eddb4217cc7cb45aae5f/grpcio-1.75.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a8041d2f9e8a742aeae96f4b047ee44e73619f4f9d24565e84d5446c623673b6", size = 8011922, upload-time = "2025-09-26T09:02:09.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7c/48455b2d0c5949678d6982c3e31ea4d89df4e16131b03f7d5c590811cbe9/grpcio-1.75.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3652516048bf4c314ce12be37423c79829f46efffb390ad64149a10c6071e8de", size = 7466181, upload-time = "2025-09-26T09:02:12.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/12/04a0e79081e3170b6124f8cba9b6275871276be06c156ef981033f691880/grpcio-1.75.1-cp312-cp312-win32.whl", hash = "sha256:44b62345d8403975513af88da2f3d5cc76f73ca538ba46596f92a127c2aea945", size = 3938543, upload-time = "2025-09-26T09:02:14.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d7/11350d9d7fb5adc73d2b0ebf6ac1cc70135577701e607407fe6739a90021/grpcio-1.75.1-cp312-cp312-win_amd64.whl", hash = "sha256:b1e191c5c465fa777d4cafbaacf0c01e0d5278022082c0abbd2ee1d6454ed94d", size = 4641938, upload-time = "2025-09-26T09:02:16.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/74/bac4ab9f7722164afdf263ae31ba97b8174c667153510322a5eba4194c32/grpcio-1.75.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3bed22e750d91d53d9e31e0af35a7b0b51367e974e14a4ff229db5b207647884", size = 5672779, upload-time = "2025-09-26T09:02:19.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/52/d0483cfa667cddaa294e3ab88fd2c2a6e9dc1a1928c0e5911e2e54bd5b50/grpcio-1.75.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5b8f381eadcd6ecaa143a21e9e80a26424c76a0a9b3d546febe6648f3a36a5ac", size = 11470623, upload-time = "2025-09-26T09:02:22.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e4/d1954dce2972e32384db6a30273275e8c8ea5a44b80347f9055589333b3f/grpcio-1.75.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5bf4001d3293e3414d0cf99ff9b1139106e57c3a66dfff0c5f60b2a6286ec133", size = 6248838, upload-time = "2025-09-26T09:02:26.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/43/073363bf63826ba8077c335d797a8d026f129dc0912b69c42feaf8f0cd26/grpcio-1.75.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f82ff474103e26351dacfe8d50214e7c9322960d8d07ba7fa1d05ff981c8b2d", size = 6922663, upload-time = "2025-09-26T09:02:28.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/6f/076ac0df6c359117676cacfa8a377e2abcecec6a6599a15a672d331f6680/grpcio-1.75.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ee119f4f88d9f75414217823d21d75bfe0e6ed40135b0cbbfc6376bc9f7757d", size = 6436149, upload-time = "2025-09-26T09:02:30.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/27/1d08824f1d573fcb1fa35ede40d6020e68a04391709939e1c6f4193b445f/grpcio-1.75.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:664eecc3abe6d916fa6cf8dd6b778e62fb264a70f3430a3180995bf2da935446", size = 7067989, upload-time = "2025-09-26T09:02:33.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/98/98594cf97b8713feb06a8cb04eeef60b4757e3e2fb91aa0d9161da769843/grpcio-1.75.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c32193fa08b2fbebf08fe08e84f8a0aad32d87c3ad42999c65e9449871b1c66e", size = 8010717, upload-time = "2025-09-26T09:02:36.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/7e/bb80b1bba03c12158f9254762cdf5cced4a9bc2e8ed51ed335915a5a06ef/grpcio-1.75.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5cebe13088b9254f6e615bcf1da9131d46cfa4e88039454aca9cb65f639bd3bc", size = 7463822, upload-time = "2025-09-26T09:02:38.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/1c/1ea57fdc06927eb5640f6750c697f596f26183573069189eeaf6ef86ba2d/grpcio-1.75.1-cp313-cp313-win32.whl", hash = "sha256:4b4c678e7ed50f8ae8b8dbad15a865ee73ce12668b6aaf411bf3258b5bc3f970", size = 3938490, upload-time = "2025-09-26T09:02:40.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/24/fbb8ff1ccadfbf78ad2401c41aceaf02b0d782c084530d8871ddd69a2d49/grpcio-1.75.1-cp313-cp313-win_amd64.whl", hash = "sha256:5573f51e3f296a1bcf71e7a690c092845fb223072120f4bdb7a5b48e111def66", size = 4642538, upload-time = "2025-09-26T09:02:42.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1b/9a0a5cecd24302b9fdbcd55d15ed6267e5f3d5b898ff9ac8cbe17ee76129/grpcio-1.75.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:c05da79068dd96723793bffc8d0e64c45f316248417515f28d22204d9dae51c7", size = 5673319, upload-time = "2025-09-26T09:02:44.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ec/9d6959429a83fbf5df8549c591a8a52bb313976f6646b79852c4884e3225/grpcio-1.75.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06373a94fd16ec287116a825161dca179a0402d0c60674ceeec8c9fba344fe66", size = 11480347, upload-time = "2025-09-26T09:02:47.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/7a/26da709e42c4565c3d7bf999a9569da96243ce34a8271a968dee810a7cf1/grpcio-1.75.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4484f4b7287bdaa7a5b3980f3c7224c3c622669405d20f69549f5fb956ad0421", size = 6254706, upload-time = "2025-09-26T09:02:50.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/08/dcb26a319d3725f199c97e671d904d84ee5680de57d74c566a991cfab632/grpcio-1.75.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2720c239c1180eee69f7883c1d4c83fc1a495a2535b5fa322887c70bf02b16e8", size = 6922501, upload-time = "2025-09-26T09:02:52.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/66/044d412c98408a5e23cb348845979a2d17a2e2b6c3c34c1ec91b920f49d0/grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c", size = 6437492, upload-time = "2025-09-26T09:02:55.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/9d/5e3e362815152aa1afd8b26ea613effa005962f9da0eec6e0e4527e7a7d1/grpcio-1.75.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3e71a2105210366bfc398eef7f57a664df99194f3520edb88b9c3a7e46ee0d64", size = 7081061, upload-time = "2025-09-26T09:02:58.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1a/46615682a19e100f46e31ddba9ebc297c5a5ab9ddb47b35443ffadb8776c/grpcio-1.75.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8679aa8a5b67976776d3c6b0521e99d1c34db8a312a12bcfd78a7085cb9b604e", size = 8010849, upload-time = "2025-09-26T09:03:00.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/8e/3204b94ac30b0f675ab1c06540ab5578660dc8b690db71854d3116f20d00/grpcio-1.75.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aad1c774f4ebf0696a7f148a56d39a3432550612597331792528895258966dc0", size = 7464478, upload-time = "2025-09-26T09:03:03.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/97/2d90652b213863b2cf466d9c1260ca7e7b67a16780431b3eb1d0420e3d5b/grpcio-1.75.1-cp314-cp314-win32.whl", hash = "sha256:62ce42d9994446b307649cb2a23335fa8e927f7ab2cbf5fcb844d6acb4d85f9c", size = 4012672, upload-time = "2025-09-26T09:03:05.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/df/e2e6e9fc1c985cd1a59e6996a05647c720fe8a03b92f5ec2d60d366c531e/grpcio-1.75.1-cp314-cp314-win_amd64.whl", hash = "sha256:f86e92275710bea3000cb79feca1762dc0ad3b27830dd1a74e82ab321d4ee464", size = 4772475, upload-time = "2025-09-26T09:03:07.661Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2171,7 +2181,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langfuse"
|
||||
version = "3.5.1"
|
||||
version = "3.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -2184,14 +2194,14 @@ dependencies = [
|
||||
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/7e/1d63ebcd38d64d05588bf48d52882871ba3b4f851bb8c3ff52c40285f5b4/langfuse-3.5.1.tar.gz", hash = "sha256:996ba858df3220f447da9dc90637721725e837de53c47b5276fa1e755e65e5b3", size = 187563, upload-time = "2025-09-25T11:35:16.369Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/12/8813d38f49d13c3d9d9c25a6d9545e028ca2ed96071fcbc26a5cdf498769/langfuse-3.5.2.tar.gz", hash = "sha256:bfd94f6cd768129d004da812bb85a34d81c28c23e4d4c1fb3a29b086c94d4269", size = 188047, upload-time = "2025-09-28T14:14:43.262Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6d/88d38794d9c5fb92c3236855ba4286ca1546c72a8b3a3a9d1bdf63712a0b/langfuse-3.5.1-py3-none-any.whl", hash = "sha256:10f9224c21babe9015b320058bcade6b4279d63da5182de08a76caf18cb8d552", size = 348549, upload-time = "2025-09-25T11:35:14.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c9/44189f008cac5f1bb252fb1a4f89dd3cf466f808d7f44eb56c8ed3c0319a/langfuse-3.5.2-py3-none-any.whl", hash = "sha256:51527aed5a3237410e587b04e07268382063435a4af2d53e7d9096a7b6535c94", size = 349012, upload-time = "2025-09-28T14:14:41.629Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.77.4"
|
||||
version = "1.77.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -2202,15 +2212,14 @@ dependencies = [
|
||||
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "jsonschema", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pondpond", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/b7/0d3c6dbcff3064238d123f90ae96764a85352f3f5caab6695a55007fd019/litellm-1.77.4.tar.gz", hash = "sha256:ce652e10ecf5b36767bfdf58e53b2802e22c3de383b03554e6ee1a4a66fa743d", size = 10330773, upload-time = "2025-09-24T17:52:44.876Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/a3/85fc92d998ec9645c9fac108618681ef411ca4b338cc7544d6b3aad57699/litellm-1.77.5.tar.gz", hash = "sha256:8e8a83b49c4a6ae044b1a1c01adfbdef72b0031b86f1463dd743e267fa1d7b99", size = 10351819, upload-time = "2025-09-28T07:17:39.393Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/32/90f8587818d146d604ed6eec95f96378363fda06b14817399cc68853383e/litellm-1.77.4-py3-none-any.whl", hash = "sha256:66c2bb776f1e19ceddfa977a2bbf7f05e6f26c4b1fec8b2093bd171d842701b8", size = 9138493, upload-time = "2025-09-24T17:52:40.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/4c/89553f7e375ef39497d86f2266a0cdb37371a07e9e0aa8949f33c15a4198/litellm-1.77.5-py3-none-any.whl", hash = "sha256:07f53964c08d555621d4376cc42330458301ae889bfb6303155dcabc51095fbf", size = 9165458, upload-time = "2025-09-28T07:17:35.474Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2226,12 +2235,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "madoka"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/eb/95288b1c4aa541eb296a6271e3f8c7ece03b78923ac47dbe95d2287d9f5e/madoka-0.7.1.tar.gz", hash = "sha256:e258baa84fc0a3764365993b8bf5e1b065383a6ca8c9f862fb3e3e709843fae7", size = 81413, upload-time = "2019-02-10T18:38:01.382Z" }
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
@@ -2246,60 +2249,87 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2713,11 +2743,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "narwhals"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/b8/3cb005704866f1cc19e8d6b15d0467255821ba12d82f20ea15912672e54c/narwhals-2.5.0.tar.gz", hash = "sha256:8ae0b6f39597f14c0dc52afc98949d6f8be89b5af402d2d98101d2f7d3561418", size = 558573, upload-time = "2025-09-12T10:04:24.436Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/40ff412dabf90ef6b99266b0b74f217bb88733541733849e0153a108c750/narwhals-2.6.0.tar.gz", hash = "sha256:5c9e2ba923e6a0051017e146184e49fb793548936f978ce130c9f55a9a81240e", size = 561649, upload-time = "2025-09-29T09:08:56.482Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5a/22741c5c0e5f6e8050242bfc2052ba68bc94b1735ed5bca35404d136d6ec/narwhals-2.5.0-py3-none-any.whl", hash = "sha256:7e213f9ca7db3f8bf6f7eff35eaee6a1cf80902997e1b78d49b7755775d8f423", size = 407296, upload-time = "2025-09-12T10:04:22.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/3b/0e2c535c3e6970cfc5763b67f6cc31accaab35a7aa3e322fb6a12830450f/narwhals-2.6.0-py3-none-any.whl", hash = "sha256:3215ea42afb452c6c8527e79cefbe542b674aa08d7e2e99d46b2c9708870e0d4", size = 408435, upload-time = "2025-09-29T09:08:54.503Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3543,18 +3573,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pondpond"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "madoka", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/9b/8411458ca8ce8b5b9b135e4a19823f1caf958ca9985883db104323492982/pondpond-1.4.1.tar.gz", hash = "sha256:8afa34b869d1434d21dd2ec12644abc3b1733fcda8fcf355300338a13a79bb7b", size = 15237, upload-time = "2024-03-01T07:08:06.756Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/d4/f18d6985157cc68f76469480182cbee2a03a45858456955acf57f9dcbb4c/pondpond-1.4.1-py3-none-any.whl", hash = "sha256:641028ead4e8018ca6de1220c660ddd6d6fbf62a60e72f410655dd0451d82880", size = 14498, upload-time = "2024-03-01T07:08:04.63Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portalocker"
|
||||
version = "3.2.0"
|
||||
|
||||
Reference in New Issue
Block a user