From 10d10364a933489bbcb401de2fbf607dab96b5ba Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 29 Sep 2025 18:22:34 +0200 Subject: [PATCH] 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 --- .../agent_framework_azure_ai/_chat_client.py | 9 +- .../agent_framework_copilotstudio/_agent.py | 49 +- .../{test_agent.py => test_copilot_agent.py} | 10 - .../devui/agent_framework_devui/_executor.py | 10 +- python/packages/devui/tests/test_discovery.py | 4 +- python/packages/devui/tests/test_execution.py | 7 +- python/packages/devui/tests/test_mapper.py | 6 - python/packages/devui/tests/test_server.py | 5 +- python/packages/lab/pyproject.toml | 4 +- .../_sliding_window.py | 27 +- .../tau2/agent_framework_lab_tau2/runner.py | 6 +- .../lab/tau2/tests/test_sliding_window.py | 62 ++- .../packages/main/agent_framework/_agents.py | 374 ++++++++------ .../packages/main/agent_framework/_clients.py | 17 +- python/packages/main/agent_framework/_mcp.py | 3 + .../packages/main/agent_framework/_memory.py | 121 +++-- .../main/agent_framework/_middleware.py | 6 +- .../packages/main/agent_framework/_threads.py | 480 +++++++++--------- .../packages/main/agent_framework/_types.py | 5 +- .../agent_framework/_workflow/__init__.py | 2 - .../main/agent_framework/_workflow/_agent.py | 22 +- .../main/agent_framework/exceptions.py | 6 + .../openai/_assistants_client.py | 2 +- .../agent_framework/openai/_chat_client.py | 9 +- .../openai/_responses_client.py | 4 +- python/packages/main/tests/main/conftest.py | 2 +- .../packages/main/tests/main/test_agents.py | 170 +++---- .../packages/main/tests/main/test_memory.py | 234 +++++---- .../tests/main/test_middleware_with_agent.py | 13 +- .../packages/main/tests/main/test_threads.py | 228 +++------ python/packages/main/tests/main/test_types.py | 4 - .../openai/test_openai_assistants_client.py | 5 - .../test_request_info_executor_rehydrate.py | 3 - .../mem0/agent_framework_mem0/_provider.py | 83 +-- .../mem0/tests/test_mem0_context_provider.py | 75 ++- .../_chat_message_store.py | 55 +- .../redis/agent_framework_redis/_provider.py | 164 +++--- .../tests/test_redis_chat_message_store.py | 6 +- .../redis/tests/test_redis_provider.py | 63 +-- python/pyproject.toml | 3 +- .../getting_started/agents/azure_ai/README.md | 1 + .../azure_ai/azure_ai_with_existing_thread.py | 52 ++ .../azure_chat_client_with_thread.py | 4 +- .../agents/custom/custom_agent.py | 9 +- .../openai/openai_chat_client_with_thread.py | 4 +- .../context_providers/redis/README.md | 4 +- .../context_providers/redis/redis_basics.py | 79 +-- .../redis/redis_conversation.py | 30 +- .../simple_context_provider.py | 120 +++++ .../custom_chat_message_store_thread.py | 4 +- .../redis_chat_message_store_thread.py | 118 +++-- python/uv.lock | 270 +++++----- 52 files changed, 1642 insertions(+), 1411 deletions(-) rename python/packages/copilotstudio/tests/{test_agent.py => test_copilot_agent.py} (98%) create mode 100644 python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_thread.py create mode 100644 python/samples/getting_started/context_providers/simple_context_provider.py diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 7ef3e73111..753f9323dc 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -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]] = [] diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index 345118c57f..164a31e059 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -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, diff --git a/python/packages/copilotstudio/tests/test_agent.py b/python/packages/copilotstudio/tests/test_copilot_agent.py similarity index 98% rename from python/packages/copilotstudio/tests/test_agent.py rename to python/packages/copilotstudio/tests/test_copilot_agent.py index e2b160610e..2a2e36263a 100644 --- a/python/packages/copilotstudio/tests/test_agent.py +++ b/python/packages/copilotstudio/tests/test_copilot_agent.py @@ -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) diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index 108549507c..f28765cc2c 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -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) diff --git a/python/packages/devui/tests/test_discovery.py b/python/packages/devui/tests/test_discovery.py index 3e0ce79a17..de5da6f754 100644 --- a/python/packages/devui/tests/test_discovery.py +++ b/python/packages/devui/tests/test_discovery.py @@ -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: diff --git a/python/packages/devui/tests/test_execution.py b/python/packages/devui/tests/test_execution.py index c43f58de61..0e17cdf9d3 100644 --- a/python/packages/devui/tests/test_execution.py +++ b/python/packages/devui/tests/test_execution.py @@ -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( diff --git a/python/packages/devui/tests/test_mapper.py b/python/packages/devui/tests/test_mapper.py index 111886e0af..3ff6797ebd 100644 --- a/python/packages/devui/tests/test_mapper.py +++ b/python/packages/devui/tests/test_mapper.py @@ -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 diff --git a/python/packages/devui/tests/test_server.py b/python/packages/devui/tests/test_server.py index 793d0964f1..c3e01e3b3f 100644 --- a/python/packages/devui/tests/test_server.py +++ b/python/packages/devui/tests/test_server.py @@ -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) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index c023741937..310e913f77 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -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", -] \ No newline at end of file +] diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py b/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py index 160c04fe62..b4e2754c60 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/_sliding_window.py @@ -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 diff --git a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py index 4234698b6f..8f1358625e 100644 --- a/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py +++ b/python/packages/lab/tau2/agent_framework_lab_tau2/runner.py @@ -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) diff --git a/python/packages/lab/tau2/tests/test_sliding_window.py b/python/packages/lab/tau2/tests/test_sliding_window.py index 6939cd7017..e4ea3cd5ad 100644 --- a/python/packages/lab/tau2/tests/test_sliding_window.py +++ b/python/packages/lab/tau2/tests/test_sliding_window.py @@ -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 ) diff --git a/python/packages/main/agent_framework/_agents.py b/python/packages/main/agent_framework/_agents.py index fc9fc17156..0ea1a1d121 100644 --- a/python/packages/main/agent_framework/_agents.py +++ b/python/packages/main/agent_framework/_agents.py @@ -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) diff --git a/python/packages/main/agent_framework/_clients.py b/python/packages/main/agent_framework/_clients.py index e98c0e54ed..af3bbd7a80 100644 --- a/python/packages/main/agent_framework/_clients.py +++ b/python/packages/main/agent_framework/_clients.py @@ -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. diff --git a/python/packages/main/agent_framework/_mcp.py b/python/packages/main/agent_framework/_mcp.py index 180aecfc95..f337a26707 100644 --- a/python/packages/main/agent_framework/_mcp.py +++ b/python/packages/main/agent_framework/_mcp.py @@ -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]: diff --git a/python/packages/main/agent_framework/_memory.py b/python/packages/main/agent_framework/_memory.py index a10a9a0e7a..1676774f7b 100644 --- a/python/packages/main/agent_framework/_memory.py +++ b/python/packages/main/agent_framework/_memory.py @@ -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, diff --git a/python/packages/main/agent_framework/_middleware.py b/python/packages/main/agent_framework/_middleware.py index d557e45ed3..f2561db17f 100644 --- a/python/packages/main/agent_framework/_middleware.py +++ b/python/packages/main/agent_framework/_middleware.py @@ -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, diff --git a/python/packages/main/agent_framework/_threads.py b/python/packages/main/agent_framework/_threads.py index e180ed433c..c334d76538 100644 --- a/python/packages/main/agent_framework/_threads.py +++ b/python/packages/main/agent_framework/_threads.py @@ -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 diff --git a/python/packages/main/agent_framework/_types.py b/python/packages/main/agent_framework/_types.py index fbbd338816..e7bad53279 100644 --- a/python/packages/main/agent_framework/_types.py +++ b/python/packages/main/agent_framework/_types.py @@ -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} diff --git a/python/packages/main/agent_framework/_workflow/__init__.py b/python/packages/main/agent_framework/_workflow/__init__.py index 86a19fdaf6..e579807344 100644 --- a/python/packages/main/agent_framework/_workflow/__init__.py +++ b/python/packages/main/agent_framework/_workflow/__init__.py @@ -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() diff --git a/python/packages/main/agent_framework/_workflow/_agent.py b/python/packages/main/agent_framework/_workflow/_agent.py index b210b20e95..f03efbc309 100644 --- a/python/packages/main/agent_framework/_workflow/_agent.py +++ b/python/packages/main/agent_framework/_workflow/_agent.py @@ -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, diff --git a/python/packages/main/agent_framework/exceptions.py b/python/packages/main/agent_framework/exceptions.py index 128981022b..ef71df0657 100644 --- a/python/packages/main/agent_framework/exceptions.py +++ b/python/packages/main/agent_framework/exceptions.py @@ -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.""" diff --git a/python/packages/main/agent_framework/openai/_assistants_client.py b/python/packages/main/agent_framework/openai/_assistants_client.py index 70733f7287..c5c05c9508 100644 --- a/python/packages/main/agent_framework/openai/_assistants_client.py +++ b/python/packages/main/agent_framework/openai/_assistants_client.py @@ -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 diff --git a/python/packages/main/agent_framework/openai/_chat_client.py b/python/packages/main/agent_framework/openai/_chat_client.py index abc12bcc70..4024206766 100644 --- a/python/packages/main/agent_framework/openai/_chat_client.py +++ b/python/packages/main/agent_framework/openai/_chat_client.py @@ -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: diff --git a/python/packages/main/agent_framework/openai/_responses_client.py b/python/packages/main/agent_framework/openai/_responses_client.py index 9c17e35cc6..07338f847f 100644 --- a/python/packages/main/agent_framework/openai/_responses_client.py +++ b/python/packages/main/agent_framework/openai/_responses_client.py @@ -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") diff --git a/python/packages/main/tests/main/conftest.py b/python/packages/main/tests/main/conftest.py index f7813cdd0f..d9980e02cc 100644 --- a/python/packages/main/tests/main/conftest.py +++ b/python/packages/main/tests/main/conftest.py @@ -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) diff --git a/python/packages/main/tests/main/test_agents.py b/python/packages/main/tests/main/test_agents.py index f41aeee9e1..cc5f248f3e 100644 --- a/python/packages/main/tests/main/test_agents.py +++ b/python/packages/main/tests/main/test_agents.py @@ -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 diff --git a/python/packages/main/tests/main/test_memory.py b/python/packages/main/tests/main/test_memory.py index 5c823066a2..f3750f20e2 100644 --- a/python/packages/main/tests/main/test_memory.py +++ b/python/packages/main/tests/main/test_memory.py @@ -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" diff --git a/python/packages/main/tests/main/test_middleware_with_agent.py b/python/packages/main/tests/main/test_middleware_with_agent.py index 025ce9b3ae..60a10049da 100644 --- a/python/packages/main/tests/main/test_middleware_with_agent.py +++ b/python/packages/main/tests/main/test_middleware_with_agent.py @@ -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: diff --git a/python/packages/main/tests/main/test_threads.py b/python/packages/main/tests/main/test_threads.py index a39fe09467..f9fe7c13ed 100644 --- a/python/packages/main/tests/main/test_threads.py +++ b/python/packages/main/tests/main/test_threads.py @@ -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 diff --git a/python/packages/main/tests/main/test_types.py b/python/packages/main/tests/main/test_types.py index 87bf31f138..4aac2e59f5 100644 --- a/python/packages/main/tests/main/test_types.py +++ b/python/packages/main/tests/main/test_types.py @@ -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")]) diff --git a/python/packages/main/tests/openai/test_openai_assistants_client.py b/python/packages/main/tests/openai/test_openai_assistants_client.py index f00610d5f5..cc3c96c157 100644 --- a/python/packages/main/tests/openai/test_openai_assistants_client.py +++ b/python/packages/main/tests/openai/test_openai_assistants_client.py @@ -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: diff --git a/python/packages/main/tests/workflow/test_request_info_executor_rehydrate.py b/python/packages/main/tests/workflow/test_request_info_executor_rehydrate.py index 7174965e73..9b1a98b1db 100644 --- a/python/packages/main/tests/workflow/test_request_info_executor_rehydrate.py +++ b/python/packages/main/tests/workflow/test_request_info_executor_rehydrate.py @@ -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": {}}) diff --git a/python/packages/mem0/agent_framework_mem0/_provider.py b/python/packages/mem0/agent_framework_mem0/_provider.py index ae099dbec9..c38741ca6b 100644 --- a/python/packages/mem0/agent_framework_mem0/_provider.py +++ b/python/packages/mem0/agent_framework_mem0/_provider.py @@ -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. diff --git a/python/packages/mem0/tests/test_mem0_context_provider.py b/python/packages/mem0/tests/test_mem0_context_provider.py index 05d5563d5f..324a655bf3 100644 --- a/python/packages/mem0/tests/test_mem0_context_provider.py +++ b/python/packages/mem0/tests/test_mem0_context_provider.py @@ -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: diff --git a/python/packages/redis/agent_framework_redis/_chat_message_store.py b/python/packages/redis/agent_framework_redis/_chat_message_store.py index f6b6095b0a..95c5281187 100644 --- a/python/packages/redis/agent_framework_redis/_chat_message_store.py +++ b/python/packages/redis/agent_framework_redis/_chat_message_store.py @@ -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. diff --git a/python/packages/redis/agent_framework_redis/_provider.py b/python/packages/redis/agent_framework_redis/_provider.py index 9ebaea6835..2c0a9a63f7 100644 --- a/python/packages/redis/agent_framework_redis/_provider.py +++ b/python/packages/redis/agent_framework_redis/_provider.py @@ -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. diff --git a/python/packages/redis/tests/test_redis_chat_message_store.py b/python/packages/redis/tests/test_redis_chat_message_store.py index 05ddf601e3..c94d3c7740 100644 --- a/python/packages/redis/tests/test_redis_chat_message_store.py +++ b/python/packages/redis/tests/test_redis_chat_message_store.py @@ -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 diff --git a/python/packages/redis/tests/test_redis_provider.py b/python/packages/redis/tests/test_redis_provider.py index 83291f2571..723334741b 100644 --- a/python/packages/redis/tests/test_redis_provider.py +++ b/python/packages/redis/tests/test_redis_provider.py @@ -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) diff --git a/python/pyproject.toml b/python/pyproject.toml index ec96cfb1d9..6211f8b34f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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] diff --git a/python/samples/getting_started/agents/azure_ai/README.md b/python/samples/getting_started/agents/azure_ai/README.md index f6c34a2a46..982699097c 100644 --- a/python/samples/getting_started/agents/azure_ai/README.md +++ b/python/samples/getting_started/agents/azure_ai/README.md @@ -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. | diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_thread.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_thread.py new file mode 100644 index 0000000000..1eb4e1ae88 --- /dev/null +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_existing_thread.py @@ -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()) diff --git a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py b/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py index 45533fcb3d..1a1e0e9612 100644 --- a/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py +++ b/python/samples/getting_started/agents/azure_openai/azure_chat_client_with_thread.py @@ -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}") diff --git a/python/samples/getting_started/agents/custom/custom_agent.py b/python/samples/getting_started/agents/custom/custom_agent.py index 045dce2802..c138ba71fd 100644 --- a/python/samples/getting_started/agents/custom/custom_agent.py +++ b/python/samples/getting_started/agents/custom/custom_agent.py @@ -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: diff --git a/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py b/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py index 91b5558237..7d1a796590 100644 --- a/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py +++ b/python/samples/getting_started/agents/openai/openai_chat_client_with_thread.py @@ -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}") diff --git a/python/samples/getting_started/context_providers/redis/README.md b/python/samples/getting_started/context_providers/redis/README.md index e221c7b1d0..540e4c477c 100644 --- a/python/samples/getting_started/context_providers/redis/README.md +++ b/python/samples/getting_started/context_providers/redis/README.md @@ -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). - - diff --git a/python/samples/getting_started/context_providers/redis/redis_basics.py b/python/samples/getting_started/context_providers/redis/redis_basics.py index b5e78b6c59..f0c24a0a66 100644 --- a/python/samples/getting_started/context_providers/redis/redis_basics.py +++ b/python/samples/getting_started/context_providers/redis/redis_basics.py @@ -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()) diff --git a/python/samples/getting_started/context_providers/redis/redis_conversation.py b/python/samples/getting_started/context_providers/redis/redis_conversation.py index 80258b5f0c..dfc4b1045a 100644 --- a/python/samples/getting_started/context_providers/redis/redis_conversation.py +++ b/python/samples/getting_started/context_providers/redis/redis_conversation.py @@ -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()) diff --git a/python/samples/getting_started/context_providers/simple_context_provider.py b/python/samples/getting_started/context_providers/simple_context_provider.py new file mode 100644 index 0000000000..86494e1aee --- /dev/null +++ b/python/samples/getting_started/context_providers/simple_context_provider.py @@ -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()) diff --git a/python/samples/getting_started/threads/custom_chat_message_store_thread.py b/python/samples/getting_started/threads/custom_chat_message_store_thread.py index 497e277f26..19223ecd7a 100644 --- a/python/samples/getting_started/threads/custom_chat_message_store_thread.py +++ b/python/samples/getting_started/threads/custom_chat_message_store_thread.py @@ -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.""" diff --git a/python/samples/getting_started/threads/redis_chat_message_store_thread.py b/python/samples/getting_started/threads/redis_chat_message_store_thread.py index b43812ea7f..30d647fd40 100644 --- a/python/samples/getting_started/threads/redis_chat_message_store_thread.py +++ b/python/samples/getting_started/threads/redis_chat_message_store_thread.py @@ -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 diff --git a/python/uv.lock b/python/uv.lock index eee1d3aa8f..18fab2d9cc 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -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"