diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 61e678a38f..80f2b6dec9 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -859,7 +859,6 @@ def _build_request_data( request_response_format=request_response_format, response_format=req_body.get("response_format"), enable_tool_calls=enable_tool_calls, - thread_id=thread_id, correlation_id=correlation_id, created_at=datetime.now(timezone.utc), ).to_dict() diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py index ec06009d88..61553ae4ac 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py @@ -8,346 +8,41 @@ """ import asyncio -import inspect -from collections.abc import AsyncIterable, Callable +from collections.abc import Callable from typing import Any, cast import azure.durable_functions as df -from agent_framework import ( - AgentProtocol, - AgentRunResponse, - AgentRunResponseUpdate, - ChatMessage, - ErrorContent, - Role, - get_logger, -) +from agent_framework import AgentProtocol, get_logger from agent_framework_durabletask import ( - AgentCallbackContext, + AgentEntity, + AgentEntityStateProviderMixin, AgentResponseCallbackProtocol, - DurableAgentState, - DurableAgentStateData, - DurableAgentStateEntry, - DurableAgentStateRequest, - DurableAgentStateResponse, - RunRequest, ) logger = get_logger("agent_framework.azurefunctions.entities") -class AgentEntity: - """Durable entity that manages agent execution and conversation state. - - This entity: - - Maintains conversation history - - Executes agent with messages - - Stores agent responses - - Handles tool execution +class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin): + """Azure Functions Durable Entity state provider for AgentEntity. - Operations: - - run: Execute the agent with a message - - run_agent: (Deprecated) Execute the agent with a message - - reset: Clear conversation history - - Attributes: - agent: The AgentProtocol instance - state: The DurableAgentState managing conversation history + This class utilizes the Durable Entity context from `azure-functions-durable` package + to get and set the state of the agent entity. """ - agent: AgentProtocol - state: DurableAgentState - - def __init__( - self, - agent: AgentProtocol, - callback: AgentResponseCallbackProtocol | None = None, - ): - """Initialize the agent entity. - - Args: - agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol) - callback: Optional callback invoked during streaming updates and final responses - """ - self.agent = agent - self.state = DurableAgentState() - self.callback = callback - - logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}") - - def _is_error_response(self, entry: DurableAgentStateEntry) -> bool: - """Check if a conversation history entry is an error response. - - Error responses should be kept in history for tracking but not sent to the agent - since Azure OpenAI doesn't support 'error' content type. - - Args: - entry: A conversation history entry (DurableAgentStateEntry or dict) - - Returns: - True if the entry is a response containing error content, False otherwise - """ - if isinstance(entry, DurableAgentStateResponse): - return entry.is_error - return False - - async def run_agent( - self, - context: df.DurableEntityContext, - request: RunRequest | dict[str, Any] | str, - ) -> AgentRunResponse: - """(Deprecated) Execute the agent with a message directly in the entity. - - Args: - context: Entity context - request: RunRequest object, dict, or string message (for backward compatibility) - - Returns: - AgentRunResponse enriched with execution metadata. - """ - return await self.run(context, request) - - async def run( - self, - context: df.DurableEntityContext, - request: RunRequest | dict[str, Any] | str, - ) -> AgentRunResponse: - """Execute the agent with a message directly in the entity. - - Args: - context: Entity context - request: RunRequest object, dict, or string message (for backward compatibility) + def __init__(self, context: df.DurableEntityContext) -> None: + self._context = context - Returns: - AgentRunResponse enriched with execution metadata. - """ - if isinstance(request, str): - run_request = RunRequest(message=request, role=Role.USER) - elif isinstance(request, dict): - run_request = RunRequest.from_dict(request) - else: - run_request = request + def _get_state_dict(self) -> dict[str, Any]: + raw_state = self._context.get_state(lambda: {}) + if not isinstance(raw_state, dict): + return {} + return cast(dict[str, Any], raw_state) - message = run_request.message - thread_id = run_request.thread_id - correlation_id = run_request.correlation_id - if not thread_id: - raise ValueError("RunRequest must include a thread_id") - if not correlation_id: - raise ValueError("RunRequest must include a correlation_id") - response_format = run_request.response_format - enable_tool_calls = run_request.enable_tool_calls - - logger.debug(f"[AgentEntity.run] Received Message: {run_request}") - - state_request = DurableAgentStateRequest.from_run_request(run_request) - self.state.data.conversation_history.append(state_request) - - try: - # Build messages from conversation history, excluding error responses - # Error responses are kept in history for tracking but not sent to the agent - chat_messages: list[ChatMessage] = [ - m.to_chat_message() - for entry in self.state.data.conversation_history - if not self._is_error_response(entry) - for m in entry.messages - ] - - run_kwargs: dict[str, Any] = {"messages": chat_messages} - if not enable_tool_calls: - run_kwargs["tools"] = None - if response_format: - run_kwargs["response_format"] = response_format - - agent_run_response: AgentRunResponse = await self._invoke_agent( - run_kwargs=run_kwargs, - correlation_id=correlation_id, - thread_id=thread_id, - request_message=message, - ) - - logger.debug( - "[AgentEntity.run] Agent invocation completed - response type: %s", - type(agent_run_response).__name__, - ) - - try: - response_text = agent_run_response.text if agent_run_response.text else "No response" - logger.debug(f"Response: {response_text[:100]}...") - except Exception as extraction_error: - logger.error( - "Error extracting response text: %s", - extraction_error, - exc_info=True, - ) - - state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response) - self.state.data.conversation_history.append(state_response) - - logger.debug("[AgentEntity.run] AgentRunResponse stored in conversation history") - - return agent_run_response - - except Exception as exc: - logger.exception("[AgentEntity.run] Agent execution failed.") + def _set_state_dict(self, state: dict[str, Any]) -> None: + self._context.set_state(state) - # Create error message - error_message = ChatMessage( - role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)] - ) - - error_response = AgentRunResponse(messages=[error_message]) - - # Create and store error response in conversation history - error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response) - error_state_response.is_error = True - self.state.data.conversation_history.append(error_state_response) - - return error_response - - async def _invoke_agent( - self, - run_kwargs: dict[str, Any], - correlation_id: str, - thread_id: str, - request_message: str, - ) -> AgentRunResponse: - """Execute the agent, preferring streaming when available.""" - callback_context: AgentCallbackContext | None = None - if self.callback is not None: - callback_context = self._build_callback_context( - correlation_id=correlation_id, - thread_id=thread_id, - request_message=request_message, - ) - - run_stream_callable = getattr(self.agent, "run_stream", None) - if callable(run_stream_callable): - try: - stream_candidate = run_stream_callable(**run_kwargs) - if inspect.isawaitable(stream_candidate): - stream_candidate = await stream_candidate - - return await self._consume_stream( - stream=cast(AsyncIterable[AgentRunResponseUpdate], stream_candidate), - callback_context=callback_context, - ) - except TypeError as type_error: - if "__aiter__" not in str(type_error): - raise - logger.debug( - "run_stream returned a non-async result; falling back to run(): %s", - type_error, - ) - except Exception as stream_error: - logger.warning( - "run_stream failed; falling back to run(): %s", - stream_error, - exc_info=True, - ) - else: - logger.debug("Agent does not expose run_stream; falling back to run().") - - agent_run_response = await self._invoke_non_stream(run_kwargs) - await self._notify_final_response(agent_run_response, callback_context) - return agent_run_response - - async def _consume_stream( - self, - stream: AsyncIterable[AgentRunResponseUpdate], - callback_context: AgentCallbackContext | None = None, - ) -> AgentRunResponse: - """Consume streaming responses and build the final AgentRunResponse.""" - updates: list[AgentRunResponseUpdate] = [] - - async for update in stream: - updates.append(update) - await self._notify_stream_update(update, callback_context) - - if updates: - response = AgentRunResponse.from_agent_run_response_updates(updates) - else: - logger.debug("[AgentEntity] No streaming updates received; creating empty response") - response = AgentRunResponse(messages=[]) - - await self._notify_final_response(response, callback_context) - return response - - async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentRunResponse: - """Invoke the agent without streaming support.""" - run_callable = getattr(self.agent, "run", None) - if run_callable is None or not callable(run_callable): - raise AttributeError("Agent does not implement run() method") - - result = run_callable(**run_kwargs) - if inspect.isawaitable(result): - result = await result - - if not isinstance(result, AgentRunResponse): - raise TypeError(f"Agent run() must return an AgentRunResponse instance; received {type(result).__name__}") - - return result - - async def _notify_stream_update( - self, - update: AgentRunResponseUpdate, - context: AgentCallbackContext | None, - ) -> None: - """Invoke the streaming callback if one is registered.""" - if self.callback is None or context is None: - return - - try: - callback_result = self.callback.on_streaming_response_update(update, context) - if inspect.isawaitable(callback_result): - await callback_result - except Exception as exc: - logger.warning( - "[AgentEntity] Streaming callback raised an exception: %s", - exc, - exc_info=True, - ) - - async def _notify_final_response( - self, - response: AgentRunResponse, - context: AgentCallbackContext | None, - ) -> None: - """Invoke the final response callback if one is registered.""" - if self.callback is None or context is None: - return - - try: - callback_result = self.callback.on_agent_response(response, context) - if inspect.isawaitable(callback_result): - await callback_result - except Exception as exc: - logger.warning( - "[AgentEntity] Response callback raised an exception: %s", - exc, - exc_info=True, - ) - - def _build_callback_context( - self, - correlation_id: str, - thread_id: str, - request_message: str, - ) -> AgentCallbackContext: - """Create the callback context provided to consumers.""" - agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__ - return AgentCallbackContext( - agent_name=agent_name, - correlation_id=correlation_id, - thread_id=thread_id, - request_message=request_message, - ) - - def reset(self, context: df.DurableEntityContext) -> None: - """Reset the entity state (clear conversation history).""" - logger.debug("[AgentEntity.reset] Resetting entity state") - self.state.data = DurableAgentStateData(conversation_history=[]) - logger.debug("[AgentEntity.reset] State reset complete") + def _get_thread_id_from_entity(self) -> str: + return self._context.entity_key def create_agent_entity( @@ -368,19 +63,10 @@ async def _entity_coroutine(context: df.DurableEntityContext) -> None: """Async handler that executes the entity operations.""" try: logger.debug("[entity_function] Entity triggered") - logger.debug(f"[entity_function] Operation: {context.operation_name}") - - current_state = context.get_state(lambda: None) - logger.debug("Retrieved state: %s", str(current_state)[:100]) - entity = AgentEntity(agent, callback) + logger.debug("[entity_function] Operation: %s", context.operation_name) - if current_state is not None: - entity.state = DurableAgentState.from_dict(current_state) - logger.debug( - "[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count - ) - else: - logger.debug("[entity_function] Created new entity instance") + state_provider = AzureFunctionEntityStateProvider(context) + entity = AgentEntity(agent, callback, state_provider=state_provider) operation = context.operation_name @@ -394,21 +80,18 @@ async def _entity_coroutine(context: df.DurableEntityContext) -> None: # Fall back to treating input as message string request = "" if input_data is None else str(cast(object, input_data)) - result = await entity.run(context, request) + result = await entity.run(request) context.set_result(result.to_dict()) elif operation == "reset": - entity.reset(context) + entity.reset() context.set_result({"status": "reset"}) else: logger.error("[entity_function] Unknown operation: %s", operation) context.set_result({"error": f"Unknown operation: {operation}"}) - serialized_state = entity.state.to_dict() - logger.debug("State dict: %s", serialized_state) - context.set_state(serialized_state) - logger.info(f"[entity_function] Operation {operation} completed successfully") + logger.info("[entity_function] Operation %s completed successfully", operation) except Exception as exc: logger.exception("[entity_function] Error executing entity operation %s", exc) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py index 24b1b27368..cf91132916 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py @@ -278,9 +278,9 @@ def my_orchestration(context): message=message_str, enable_tool_calls=enable_tool_calls, correlation_id=correlation_id, - thread_id=session_id.key, response_format=response_format, orchestration_id=self.context.instance_id, + created_at=self.context.current_utc_datetime, ) logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100]) diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index 1fbfa57e39..466cb8ea85 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -2,6 +2,8 @@ """Unit tests for AgentFunctionApp.""" +# pyright: reportPrivateUsage=false + import json from collections.abc import Awaitable, Callable from typing import Any, TypeVar @@ -17,15 +19,36 @@ THREAD_ID_HEADER, WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER, + AgentEntity, + AgentEntityStateProviderMixin, DurableAgentState, ) from agent_framework_azurefunctions import AgentFunctionApp -from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity +from agent_framework_azurefunctions._entities import create_agent_entity TFunc = TypeVar("TFunc", bound=Callable[..., Any]) +def _identity_decorator(func: TFunc) -> TFunc: + return func + + +class _InMemoryStateProvider(AgentEntityStateProviderMixin): + def __init__(self, *, thread_id: str = "test-thread", initial_state: dict[str, Any] | None = None) -> None: + self._thread_id = thread_id + self._state_dict: dict[str, Any] = initial_state or {} + + def _get_state_dict(self) -> dict[str, Any]: + return self._state_dict + + def _set_state_dict(self, state: dict[str, Any]) -> None: + self._state_dict = state + + def _get_thread_id_from_entity(self) -> str: + return self._thread_id + + class TestAgentFunctionAppInit: """Test suite for AgentFunctionApp initialization.""" @@ -89,7 +112,7 @@ def test_add_agent_uses_specific_callback(self) -> None: app.add_agent(mock_agent, callback=specific_callback) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is specific_callback assert enable_http_endpoint is True @@ -105,7 +128,7 @@ def test_default_callback_applied_when_no_specific(self) -> None: app.add_agent(mock_agent) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is default_callback assert enable_http_endpoint is True @@ -120,7 +143,7 @@ def test_init_with_agents_uses_default_callback(self) -> None: AgentFunctionApp(agents=[mock_agent], default_callback=default_callback) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is default_callback assert enable_http_endpoint is True @@ -336,13 +359,12 @@ async def test_entity_run_agent_operation(self) -> None: return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Test response")]) ) - entity = AgentEntity(mock_agent) - mock_context = Mock() + entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123")) - result = await entity.run( - mock_context, - {"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"}, - ) + result = await entity.run({ + "message": "Test message", + "correlationId": "corr-app-entity-1", + }) assert isinstance(result, AgentRunResponse) assert result.text == "Test response" @@ -355,22 +377,17 @@ async def test_entity_stores_conversation_history(self) -> None: return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response 1")]) ) - entity = AgentEntity(mock_agent) - mock_context = Mock() + entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) # Send first message - await entity.run( - mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"} - ) + await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-2"}) # Each conversation turn creates 2 entries: request and response history = entity.state.data.conversation_history[0].messages # Request entry assert len(history) == 1 # Just the user message # Send second message - await entity.run( - mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"} - ) + await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"}) # Now we have 4 entries total (2 requests + 2 responses) # Access the first request entry @@ -394,32 +411,26 @@ async def test_entity_increments_message_count(self) -> None: return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")]) ) - entity = AgentEntity(mock_agent) - mock_context = Mock() + entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) assert len(entity.state.data.conversation_history) == 0 - await entity.run( - mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"} - ) + await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"}) assert len(entity.state.data.conversation_history) == 2 - await entity.run( - mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"} - ) + await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-3b"}) assert len(entity.state.data.conversation_history) == 4 def test_entity_reset(self) -> None: """Test that entity reset clears state.""" mock_agent = Mock() - entity = AgentEntity(mock_agent) + entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider()) # Set some state entity.state = DurableAgentState() # Reset - mock_context = Mock() - entity.reset(mock_context) + entity.reset() assert len(entity.state.data.conversation_history) == 0 @@ -448,7 +459,6 @@ def test_entity_function_handles_run_operation(self) -> None: mock_context.operation_name = "run" mock_context.get_input.return_value = { "message": "Test message", - "thread_id": "conv-123", "correlationId": "corr-app-factory-1", } mock_context.get_state.return_value = None @@ -476,7 +486,6 @@ def test_entity_function_handles_run_agent_operation(self) -> None: mock_context.operation_name = "run_agent" mock_context.get_input.return_value = { "message": "Test message", - "thread_id": "conv-123", "correlationId": "corr-app-factory-1", } mock_context.get_state.return_value = None @@ -596,7 +605,11 @@ def test_entity_function_restores_state(self) -> None: } mock_context = Mock() - mock_context.operation_name = "reset" + mock_context.operation_name = "run" + mock_context.get_input.return_value = { + "message": "Test message", + "correlationId": "corr-restore-1", + } mock_context.get_state.return_value = existing_state with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock: @@ -613,12 +626,12 @@ async def test_entity_handles_agent_error(self) -> None: mock_agent = Mock() mock_agent.run = AsyncMock(side_effect=Exception("Agent error")) - entity = AgentEntity(mock_agent) - mock_context = Mock() + entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1")) - result = await entity.run( - mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"} - ) + result = await entity.run({ + "message": "Test message", + "correlationId": "corr-app-error-1", + }) assert isinstance(result, AgentRunResponse) assert len(result.messages) == 1 @@ -711,7 +724,7 @@ def test_extract_thread_id_from_query_params(self) -> None: request = Mock() request.params = {"thread_id": "query-thread"} - req_body = {} + req_body: dict[str, Any] = {} thread_id = app._resolve_thread_id(request, req_body) @@ -778,7 +791,7 @@ async def test_http_run_accepts_plain_text(self) -> None: assert run_request["message"] == "Plain text via HTTP" assert run_request["role"] == "user" - assert "thread_id" in run_request + assert "thread_id" not in run_request async def test_http_run_accept_header_returns_json(self) -> None: """Test that Accept header requesting JSON results in JSON response.""" @@ -914,9 +927,9 @@ def test_setup_mcp_tool_trigger_registers_decorators(self) -> None: patch.object(app, "durable_client_input") as client_mock, ): # Setup mock decorator chain - func_name_mock.return_value = lambda f: f - mcp_trigger_mock.return_value = lambda f: f - client_mock.return_value = lambda f: f + func_name_mock.return_value = _identity_decorator + mcp_trigger_mock.return_value = _identity_decorator + client_mock.return_value = _identity_decorator app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description) @@ -939,11 +952,11 @@ def test_setup_mcp_tool_trigger_uses_default_description(self) -> None: app = AgentFunctionApp() with ( - patch.object(app, "function_name", return_value=lambda f: f), + patch.object(app, "function_name", return_value=_identity_decorator), patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, - patch.object(app, "durable_client_input", return_value=lambda f: f), + patch.object(app, "durable_client_input", return_value=_identity_decorator), ): - mcp_trigger_mock.return_value = lambda f: f + mcp_trigger_mock.return_value = _identity_decorator app._setup_mcp_tool_trigger(mock_agent.name, None) @@ -1065,10 +1078,10 @@ def test_health_check_includes_mcp_tool_enabled(self) -> None: app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) # Capture the health check handler function - captured_handler = None + captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None - def capture_decorator(*args, **kwargs): - def decorator(func): + def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]: + def decorator(func: TFunc) -> TFunc: nonlocal captured_handler captured_handler = func return func diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index 9d779695df..7ae845ed2b 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -1,42 +1,22 @@ # Copyright (c) Microsoft. All rights reserved. -"""Unit tests for AgentEntity and entity operations. +"""Unit tests for create_agent_entity factory function. Run with: pytest tests/test_entities.py -v """ -import asyncio -from collections.abc import AsyncIterator, Callable -from datetime import datetime +from collections.abc import Callable from typing import Any, TypeVar -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock import pytest -from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, ErrorContent, Role -from agent_framework_durabletask import ( - DurableAgentState, - DurableAgentStateData, - DurableAgentStateMessage, - DurableAgentStateRequest, - DurableAgentStateTextContent, - RunRequest, -) -from pydantic import BaseModel +from agent_framework import AgentRunResponse, ChatMessage -from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity +from agent_framework_azurefunctions._entities import create_agent_entity TFunc = TypeVar("TFunc", bound=Callable[..., Any]) -def _role_value(chat_message: DurableAgentStateMessage) -> str: - """Helper to extract the string role from a ChatMessage.""" - role = getattr(chat_message, "role", None) - role_value = getattr(role, "value", role) - if role_value is None: - return "" - return str(role_value) - - def _agent_response(text: str | None) -> AgentRunResponse: """Create an AgentRunResponse with a single assistant message.""" message = ( @@ -45,379 +25,6 @@ def _agent_response(text: str | None) -> AgentRunResponse: return AgentRunResponse(messages=[message]) -class RecordingCallback: - """Callback implementation capturing streaming and final responses for assertions.""" - - def __init__(self): - self.stream_mock = AsyncMock() - self.response_mock = AsyncMock() - - async def on_streaming_response_update( - self, - update: AgentRunResponseUpdate, - context: Any, - ) -> None: - await self.stream_mock(update, context) - - async def on_agent_response(self, response: AgentRunResponse, context: Any) -> None: - await self.response_mock(response, context) - - -class EntityStructuredResponse(BaseModel): - answer: float - - -class TestAgentEntityInit: - """Test suite for AgentEntity initialization.""" - - def test_init_creates_entity(self) -> None: - """Test that AgentEntity initializes correctly.""" - mock_agent = Mock() - - entity = AgentEntity(mock_agent) - - assert entity.agent == mock_agent - assert len(entity.state.data.conversation_history) == 0 - assert entity.state.data.extension_data is None - assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION - - def test_init_stores_agent_reference(self) -> None: - """Test that the agent reference is stored correctly.""" - mock_agent = Mock() - mock_agent.name = "TestAgent" - - entity = AgentEntity(mock_agent) - - assert entity.agent.name == "TestAgent" - - def test_init_with_different_agent_types(self) -> None: - """Test initialization with different agent types.""" - agent1 = Mock() - agent1.__class__.__name__ = "AzureOpenAIAgent" - - agent2 = Mock() - agent2.__class__.__name__ = "CustomAgent" - - entity1 = AgentEntity(agent1) - entity2 = AgentEntity(agent2) - - assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent" - assert entity2.agent.__class__.__name__ == "CustomAgent" - - -class TestAgentEntityRunAgent: - """Test suite for the run_agent operation.""" - - async def test_run_executes_agent(self) -> None: - """Test that run executes the agent.""" - mock_agent = Mock() - mock_response = _agent_response("Test response") - mock_agent.run = AsyncMock(return_value=mock_response) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run( - mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"} - ) - - # Verify agent.run was called - mock_agent.run.assert_called_once() - _, kwargs = mock_agent.run.call_args - sent_messages: list[Any] = kwargs.get("messages") - assert len(sent_messages) == 1 - sent_message = sent_messages[0] - assert isinstance(sent_message, ChatMessage) - assert getattr(sent_message, "text", None) == "Test message" - assert getattr(sent_message.role, "value", sent_message.role) == "user" - - # Verify result - assert isinstance(result, AgentRunResponse) - assert result.text == "Test response" - - async def test_run_agent_executes_agent(self) -> None: - """Test that run_agent executes the agent.""" - mock_agent = Mock() - mock_response = _agent_response("Test response") - mock_agent.run = AsyncMock(return_value=mock_response) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run_agent( - mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"} - ) - - # Verify agent.run was called - mock_agent.run.assert_called_once() - _, kwargs = mock_agent.run.call_args - sent_messages: list[Any] = kwargs.get("messages") - assert len(sent_messages) == 1 - sent_message = sent_messages[0] - assert isinstance(sent_message, ChatMessage) - assert getattr(sent_message, "text", None) == "Test message" - assert getattr(sent_message.role, "value", sent_message.role) == "user" - - # Verify result - assert isinstance(result, AgentRunResponse) - assert result.text == "Test response" - - async def test_run_agent_streaming_callbacks_invoked(self) -> None: - """Ensure streaming updates trigger callbacks and run() is not used.""" - - updates = [ - AgentRunResponseUpdate(text="Hello"), - AgentRunResponseUpdate(text=" world"), - ] - - async def update_generator() -> AsyncIterator[AgentRunResponseUpdate]: - for update in updates: - yield update - - mock_agent = Mock() - mock_agent.name = "StreamingAgent" - mock_agent.run_stream = Mock(return_value=update_generator()) - mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds")) - - callback = RecordingCallback() - entity = AgentEntity(mock_agent, callback=callback) - mock_context = Mock() - - result = await entity.run( - mock_context, - { - "message": "Tell me something", - "thread_id": "session-1", - "correlationId": "corr-stream-1", - }, - ) - - assert isinstance(result, AgentRunResponse) - assert "Hello" in result.text - assert callback.stream_mock.await_count == len(updates) - assert callback.response_mock.await_count == 1 - mock_agent.run.assert_not_called() - - # Validate callback arguments - stream_calls = callback.stream_mock.await_args_list - for expected_update, recorded_call in zip(updates, stream_calls, strict=True): - assert recorded_call.args[0] is expected_update - context = recorded_call.args[1] - assert context.agent_name == "StreamingAgent" - assert context.correlation_id == "corr-stream-1" - assert context.thread_id == "session-1" - assert context.request_message == "Tell me something" - - final_call = callback.response_mock.await_args - assert final_call is not None - final_response, final_context = final_call.args - assert final_context.agent_name == "StreamingAgent" - assert final_context.correlation_id == "corr-stream-1" - assert final_context.thread_id == "session-1" - assert final_context.request_message == "Tell me something" - assert getattr(final_response, "text", "").strip() - - async def test_run_agent_final_callback_without_streaming(self) -> None: - """Ensure the final callback fires even when streaming is unavailable.""" - - mock_agent = Mock() - mock_agent.name = "NonStreamingAgent" - mock_agent.run_stream = None - agent_response = _agent_response("Final response") - mock_agent.run = AsyncMock(return_value=agent_response) - - callback = RecordingCallback() - entity = AgentEntity(mock_agent, callback=callback) - mock_context = Mock() - - result = await entity.run( - mock_context, - { - "message": "Hi", - "thread_id": "session-2", - "correlationId": "corr-final-1", - }, - ) - - assert isinstance(result, AgentRunResponse) - assert result.text == "Final response" - assert callback.stream_mock.await_count == 0 - assert callback.response_mock.await_count == 1 - - final_call = callback.response_mock.await_args - assert final_call is not None - assert final_call.args[0] is agent_response - final_context = final_call.args[1] - assert final_context.agent_name == "NonStreamingAgent" - assert final_context.correlation_id == "corr-final-1" - assert final_context.thread_id == "session-2" - assert final_context.request_message == "Hi" - - async def test_run_agent_updates_conversation_history(self) -> None: - """Test that run_agent updates the conversation history.""" - mock_agent = Mock() - mock_response = _agent_response("Agent response") - mock_agent.run = AsyncMock(return_value=mock_response) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - await entity.run( - mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"} - ) - - # Should have 1 entry: user message + assistant response - user_history = entity.state.data.conversation_history[0].messages - assistant_history = entity.state.data.conversation_history[1].messages - - assert len(user_history) == 1 - - user_msg = user_history[0] - assert _role_value(user_msg) == "user" - assert user_msg.text == "User message" - - assistant_msg = assistant_history[0] - assert _role_value(assistant_msg) == "assistant" - assert assistant_msg.text == "Agent response" - - async def test_run_agent_increments_message_count(self) -> None: - """Test that run_agent increments the message count.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - assert len(entity.state.data.conversation_history) == 0 - - await entity.run( - mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"} - ) - assert len(entity.state.data.conversation_history) == 2 - - await entity.run( - mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"} - ) - assert len(entity.state.data.conversation_history) == 4 - - await entity.run( - mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"} - ) - assert len(entity.state.data.conversation_history) == 6 - - async def test_run_agent_with_none_thread_id(self) -> None: - """Test run_agent with a None thread identifier.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - with pytest.raises(ValueError, match="thread_id"): - await entity.run(mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"}) - - async def test_run_agent_multiple_conversations(self) -> None: - """Test that run_agent maintains history across multiple messages.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - # Send multiple messages - await entity.run( - mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"} - ) - await entity.run( - mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"} - ) - await entity.run( - mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"} - ) - - history = entity.state.data.conversation_history - assert len(history) == 6 - assert entity.state.message_count == 6 - - -class TestAgentEntityReset: - """Test suite for the reset operation.""" - - def test_reset_clears_conversation_history(self) -> None: - """Test that reset clears the conversation history.""" - mock_agent = Mock() - entity = AgentEntity(mock_agent) - - # Add some history with proper DurableAgentStateEntry objects - entity.state.data.conversation_history = [ - DurableAgentStateRequest( - correlation_id="test-1", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="msg1")], - ) - ], - ), - ] - - mock_context = Mock() - entity.reset(mock_context) - - assert entity.state.data.conversation_history == [] - - def test_reset_with_extension_data(self) -> None: - """Test that reset works when entity has extension data.""" - mock_agent = Mock() - entity = AgentEntity(mock_agent) - - # Set up some initial state with conversation history - entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"}) - - mock_context = Mock() - entity.reset(mock_context) - - assert len(entity.state.data.conversation_history) == 0 - - def test_reset_clears_message_count(self) -> None: - """Test that reset clears the message count.""" - mock_agent = Mock() - entity = AgentEntity(mock_agent) - - mock_context = Mock() - entity.reset(mock_context) - - assert len(entity.state.data.conversation_history) == 0 - - async def test_reset_after_conversation(self) -> None: - """Test reset after a full conversation.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - # Have a conversation - await entity.run( - mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"} - ) - await entity.run( - mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"} - ) - - # Verify state before reset - assert entity.state.message_count == 4 - assert len(entity.state.data.conversation_history) == 4 - - # Reset - entity.reset(mock_context) - - # Verify state after reset - assert entity.state.message_count == 0 - assert len(entity.state.data.conversation_history) == 0 - - class TestCreateAgentEntity: """Test suite for the create_agent_entity factory function.""" @@ -439,9 +46,9 @@ def test_entity_function_handles_run_agent(self) -> None: # Mock context mock_context = Mock() mock_context.operation_name = "run" + mock_context.entity_key = "conv-123" mock_context.get_input.return_value = { "message": "Test message", - "thread_id": "conv-123", "correlationId": "corr-entity-factory", } mock_context.get_state.return_value = None @@ -535,7 +142,7 @@ def test_entity_function_creates_new_entity_on_first_call(self) -> None: assert state["data"] == {"conversationHistory": []} def test_entity_function_restores_existing_state(self) -> None: - """Test that the entity function restores existing state.""" + """Test that the entity function can operate when existing state is present.""" mock_agent = Mock() entity_function = create_agent_entity(mock_agent) @@ -584,482 +191,14 @@ def test_entity_function_restores_existing_state(self) -> None: mock_context.operation_name = "reset" mock_context.get_state.return_value = existing_state - with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock: - entity_function(mock_context) - - from_dict_mock.assert_called_once_with(existing_state) - - -class TestErrorHandling: - """Test suite for error handling in entities.""" - - async def test_run_agent_handles_agent_exception(self) -> None: - """Test that run_agent handles agent exceptions.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(side_effect=Exception("Agent failed")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run( - mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"} - ) - - assert isinstance(result, AgentRunResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, ErrorContent) - assert "Agent failed" in (content.message or "") - assert content.error_code == "Exception" - - async def test_run_agent_handles_value_error(self) -> None: - """Test that run_agent handles ValueError instances.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run( - mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"} - ) - - assert isinstance(result, AgentRunResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, ErrorContent) - assert content.error_code == "ValueError" - assert "Invalid input" in str(content.message) - - async def test_run_agent_handles_timeout_error(self) -> None: - """Test that run_agent handles TimeoutError instances.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run( - mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"} - ) - - assert isinstance(result, AgentRunResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, ErrorContent) - assert content.error_code == "TimeoutError" - - def test_entity_function_handles_exception_in_operation(self) -> None: - """Test that the entity function handles exceptions gracefully.""" - mock_agent = Mock() - - entity_function = create_agent_entity(mock_agent) - - mock_context = Mock() - mock_context.operation_name = "run" - mock_context.get_input.side_effect = Exception("Input error") - mock_context.get_state.return_value = None - - # Execute - should not raise entity_function(mock_context) - # Verify error was set assert mock_context.set_result.called - result = mock_context.set_result.call_args[0][0] - assert "error" in result - - async def test_run_agent_preserves_message_on_error(self) -> None: - """Test that run_agent preserves message information on error.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(side_effect=Exception("Error")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - result = await entity.run( - mock_context, - {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"}, - ) - - # Even on error, message info should be preserved - assert isinstance(result, AgentRunResponse) - assert len(result.messages) == 1 - content = result.messages[0].contents[0] - assert isinstance(content, ErrorContent) - - -class TestConversationHistory: - """Test suite for conversation history tracking.""" - - async def test_conversation_history_has_timestamps(self) -> None: - """Test that conversation history entries include timestamps.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - await entity.run( - mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"} - ) - - # Check both user and assistant messages have timestamps - for entry in entity.state.data.conversation_history: - timestamp = entry.created_at - assert timestamp is not None - # Verify timestamp is in ISO format - datetime.fromisoformat(str(timestamp)) - - async def test_conversation_history_ordering(self) -> None: - """Test that conversation history maintains the correct order.""" - mock_agent = Mock() - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - # Send multiple messages with different responses - mock_agent.run = AsyncMock(return_value=_agent_response("Response 1")) - await entity.run( - mock_context, - {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"}, - ) - - mock_agent.run = AsyncMock(return_value=_agent_response("Response 2")) - await entity.run( - mock_context, - {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"}, - ) - - mock_agent.run = AsyncMock(return_value=_agent_response("Response 3")) - await entity.run( - mock_context, - {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"}, - ) - - # Verify order - history = entity.state.data.conversation_history - # Each conversation turn creates 2 entries: request and response - assert history[0].messages[0].text == "Message 1" # Request 1 - assert history[1].messages[0].text == "Response 1" # Response 1 - assert history[2].messages[0].text == "Message 2" # Request 2 - assert history[3].messages[0].text == "Response 2" # Response 2 - assert history[4].messages[0].text == "Message 3" # Request 3 - assert history[5].messages[0].text == "Response 3" # Response 3 - async def test_conversation_history_role_alternation(self) -> None: - """Test that conversation history alternates between user and assistant roles.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - await entity.run( - mock_context, - {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"}, - ) - await entity.run( - mock_context, - {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"}, - ) - - # Check role alternation - history = entity.state.data.conversation_history - # Each conversation turn creates 2 entries: request and response - assert history[0].messages[0].role == "user" # Request 1 - assert history[1].messages[0].role == "assistant" # Response 1 - assert history[2].messages[0].role == "user" # Request 2 - assert history[3].messages[0].role == "assistant" # Response 2 - - -class TestRunRequestSupport: - """Test suite for RunRequest support in entities.""" - - async def test_run_agent_with_run_request_object(self) -> None: - """Test run_agent with a RunRequest object.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - request = RunRequest( - message="Test message", - thread_id="conv-123", - role=Role.USER, - enable_tool_calls=True, - correlation_id="corr-runreq-1", - ) - - result = await entity.run(mock_context, request) - - assert isinstance(result, AgentRunResponse) - assert result.text == "Response" - - async def test_run_agent_with_dict_request(self) -> None: - """Test run_agent with a dictionary request.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - request_dict = { - "message": "Test message", - "thread_id": "conv-456", - "role": "system", - "enable_tool_calls": False, - "correlationId": "corr-runreq-2", - } - - result = await entity.run(mock_context, request_dict) - - assert isinstance(result, AgentRunResponse) - assert result.text == "Response" - - async def test_run_agent_with_string_raises_without_correlation(self) -> None: - """Test that run_agent rejects legacy string input without correlation ID.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - with pytest.raises(ValueError): - await entity.run(mock_context, "Simple message") - - async def test_run_agent_stores_role_in_history(self) -> None: - """Test that run_agent stores the role in conversation history.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - # Send as system role - request = RunRequest( - message="System message", - thread_id="conv-runreq-3", - role=Role.SYSTEM, - correlation_id="corr-runreq-3", - ) - - await entity.run(mock_context, request) - - # Check that system role was stored - history = entity.state.data.conversation_history - assert history[0].messages[0].role == "system" - assert history[0].messages[0].text == "System message" - - async def test_run_agent_with_response_format(self) -> None: - """Test run_agent with a JSON response format.""" - mock_agent = Mock() - # Return JSON response - mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}')) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - request = RunRequest( - message="What is the answer?", - thread_id="conv-runreq-4", - response_format=EntityStructuredResponse, - correlation_id="corr-runreq-4", - ) - - result = await entity.run(mock_context, request) - - assert isinstance(result, AgentRunResponse) - assert result.text == '{"answer": 42}' - assert result.value is None - - async def test_run_agent_disable_tool_calls(self) -> None: - """Test run_agent with tool calls disabled.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity = AgentEntity(mock_agent) - mock_context = Mock() - - request = RunRequest( - message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5" - ) - - result = await entity.run(mock_context, request) - - assert isinstance(result, AgentRunResponse) - # Agent should have been called (tool disabling is framework-dependent) - mock_agent.run.assert_called_once() - - async def test_entity_function_with_run_request_dict(self) -> None: - """Test that the entity function handles the RunRequest dict format.""" - mock_agent = Mock() - mock_agent.run = AsyncMock(return_value=_agent_response("Response")) - - entity_function = create_agent_entity(mock_agent) - - mock_context = Mock() - mock_context.operation_name = "run" - mock_context.get_input.return_value = { - "message": "Test message", - "thread_id": "conv-789", - "role": "user", - "enable_tool_calls": True, - "correlationId": "corr-runreq-6", - } - mock_context.get_state.return_value = None - - await asyncio.to_thread(entity_function, mock_context) - - # Verify result was set - assert mock_context.set_result.called - result = mock_context.set_result.call_args[0][0] - assert isinstance(result, dict) - - # Check if messages are present - assert "messages" in result - assert len(result["messages"]) > 0 - message = result["messages"][0] - - # Check for text in various possible locations - text_found = False - if "text" in message and message["text"] == "Response": - text_found = True - elif "contents" in message: - for content in message["contents"]: - if isinstance(content, dict) and content.get("text") == "Response": - text_found = True - break - - assert text_found, f"Response text not found in message: {message}" - - -class TestDurableAgentStateRequestOrchestrationId: - """Test suite for DurableAgentStateRequest orchestration_id field.""" - - def test_request_with_orchestration_id(self) -> None: - """Test creating a request with an orchestration_id.""" - request = DurableAgentStateRequest( - correlation_id="corr-123", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="test")], - ) - ], - orchestration_id="orch-456", - ) - - assert request.orchestration_id == "orch-456" - - def test_request_to_dict_includes_orchestration_id(self) -> None: - """Test that to_dict includes orchestrationId when set.""" - request = DurableAgentStateRequest( - correlation_id="corr-123", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="test")], - ) - ], - orchestration_id="orch-789", - ) - - data = request.to_dict() - - assert "orchestrationId" in data - assert data["orchestrationId"] == "orch-789" - - def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None: - """Test that to_dict excludes orchestrationId when not set.""" - request = DurableAgentStateRequest( - correlation_id="corr-123", - created_at=datetime.now(), - messages=[ - DurableAgentStateMessage( - role="user", - contents=[DurableAgentStateTextContent(text="test")], - ) - ], - ) - - data = request.to_dict() - - assert "orchestrationId" not in data - - def test_request_from_dict_with_orchestration_id(self) -> None: - """Test from_dict correctly parses orchestrationId.""" - data = { - "$type": "request", - "correlationId": "corr-123", - "createdAt": "2024-01-01T00:00:00Z", - "messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}], - "orchestrationId": "orch-from-dict", - } - - request = DurableAgentStateRequest.from_dict(data) - - assert request.orchestration_id == "orch-from-dict" - - def test_request_from_run_request_with_orchestration_id(self) -> None: - """Test from_run_request correctly transfers orchestration_id.""" - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - orchestration_id="orch-from-run-request", - ) - - durable_request = DurableAgentStateRequest.from_run_request(run_request) - - assert durable_request.orchestration_id == "orch-from-run-request" - - def test_request_from_run_request_without_orchestration_id(self) -> None: - """Test from_run_request correctly handles missing orchestration_id.""" - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - ) - - durable_request = DurableAgentStateRequest.from_run_request(run_request) - - assert durable_request.orchestration_id is None - - -class TestDurableAgentStateMessageCreatedAt: - """Test suite for DurableAgentStateMessage created_at field handling.""" - - def test_message_from_run_request_without_created_at_preserves_none(self) -> None: - """Test from_run_request preserves None created_at instead of defaulting to current time. - - When a RunRequest has no created_at value, the resulting DurableAgentStateMessage - should also have None for created_at, not default to current UTC time. - """ - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - created_at=None, # Explicitly None - ) - - durable_message = DurableAgentStateMessage.from_run_request(run_request) - - assert durable_message.created_at is None - - def test_message_from_run_request_with_created_at_parses_correctly(self) -> None: - """Test from_run_request correctly parses a valid created_at timestamp.""" - run_request = RunRequest( - message="test message", - correlation_id="corr-run", - created_at="2024-01-15T10:30:00Z", - ) - - durable_message = DurableAgentStateMessage.from_run_request(run_request) - - assert durable_message.created_at is not None - assert durable_message.created_at.year == 2024 - assert durable_message.created_at.month == 1 - assert durable_message.created_at.day == 15 + # Reset should clear history and persist via set_state + assert mock_context.set_state.called + persisted_state = mock_context.set_state.call_args[0][0] + assert persisted_state["data"]["conversationHistory"] == [] if __name__ == "__main__": diff --git a/python/packages/azurefunctions/tests/test_models.py b/python/packages/azurefunctions/tests/test_models.py index 2a9e781a54..5f4dc47080 100644 --- a/python/packages/azurefunctions/tests/test_models.py +++ b/python/packages/azurefunctions/tests/test_models.py @@ -150,20 +150,18 @@ class TestRunRequest: def test_init_with_defaults(self) -> None: """Test RunRequest initialization with defaults.""" - request = RunRequest(message="Hello", thread_id="thread-default") + request = RunRequest(message="Hello") assert request.message == "Hello" assert request.role == Role.USER assert request.response_format is None assert request.enable_tool_calls is True - assert request.thread_id == "thread-default" def test_init_with_all_fields(self) -> None: """Test RunRequest initialization with all fields.""" schema = ModuleStructuredResponse request = RunRequest( message="Hello", - thread_id="thread-123", role=Role.SYSTEM, response_format=schema, enable_tool_calls=False, @@ -173,31 +171,29 @@ def test_init_with_all_fields(self) -> None: assert request.role == Role.SYSTEM assert request.response_format is schema assert request.enable_tool_calls is False - assert request.thread_id == "thread-123" def test_init_coerces_string_role(self) -> None: """Ensure string role values are coerced into Role instances.""" - request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type] + request = RunRequest(message="Hello", role="system") # type: ignore[arg-type] assert request.role == Role.SYSTEM def test_to_dict_with_defaults(self) -> None: """Test to_dict with default values.""" - request = RunRequest(message="Test message", thread_id="thread-to-dict") + request = RunRequest(message="Test message") data = request.to_dict() assert data["message"] == "Test message" assert data["enable_tool_calls"] is True assert data["role"] == "user" assert "response_format" not in data or data["response_format"] is None - assert data["thread_id"] == "thread-to-dict" + assert "thread_id" not in data def test_to_dict_with_all_fields(self) -> None: """Test to_dict with all fields.""" schema = ModuleStructuredResponse request = RunRequest( message="Hello", - thread_id="thread-456", role=Role.ASSISTANT, response_format=schema, enable_tool_calls=False, @@ -210,17 +206,22 @@ def test_to_dict_with_all_fields(self) -> None: assert data["response_format"]["module"] == schema.__module__ assert data["response_format"]["qualname"] == schema.__qualname__ assert data["enable_tool_calls"] is False - assert data["thread_id"] == "thread-456" + assert "thread_id" not in data def test_from_dict_with_defaults(self) -> None: """Test from_dict with minimal data.""" - data = {"message": "Hello", "thread_id": "thread-from-dict"} + data = {"message": "Hello"} request = RunRequest.from_dict(data) assert request.message == "Hello" assert request.role == Role.USER assert request.enable_tool_calls is True - assert request.thread_id == "thread-from-dict" + + def test_from_dict_ignores_thread_id_field(self) -> None: + """Ensure legacy thread_id input does not break RunRequest parsing.""" + request = RunRequest.from_dict({"message": "Hello", "thread_id": "ignored"}) + + assert request.message == "Hello" def test_from_dict_with_all_fields(self) -> None: """Test from_dict with all fields.""" @@ -233,7 +234,6 @@ def test_from_dict_with_all_fields(self) -> None: "qualname": ModuleStructuredResponse.__qualname__, }, "enable_tool_calls": False, - "thread_id": "thread-789", } request = RunRequest.from_dict(data) @@ -241,11 +241,10 @@ def test_from_dict_with_all_fields(self) -> None: assert request.role == Role.SYSTEM assert request.response_format is ModuleStructuredResponse assert request.enable_tool_calls is False - assert request.thread_id == "thread-789" def test_from_dict_with_unknown_role_preserves_value(self) -> None: """Test from_dict keeps custom roles intact.""" - data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"} + data = {"message": "Test", "role": "reviewer"} request = RunRequest.from_dict(data) assert request.role.value == "reviewer" @@ -253,18 +252,15 @@ def test_from_dict_with_unknown_role_preserves_value(self) -> None: def test_from_dict_empty_message(self) -> None: """Test from_dict with empty message.""" - data = {"thread_id": "thread-empty"} - request = RunRequest.from_dict(data) + request = RunRequest.from_dict({}) assert request.message == "" assert request.role == Role.USER - assert request.thread_id == "thread-empty" def test_round_trip_dict_conversion(self) -> None: """Test round-trip to_dict and from_dict.""" original = RunRequest( message="Test message", - thread_id="thread-123", role=Role.SYSTEM, response_format=ModuleStructuredResponse, enable_tool_calls=False, @@ -277,13 +273,11 @@ def test_round_trip_dict_conversion(self) -> None: assert restored.role == original.role assert restored.response_format is ModuleStructuredResponse assert restored.enable_tool_calls == original.enable_tool_calls - assert restored.thread_id == original.thread_id def test_round_trip_with_pydantic_response_format(self) -> None: """Ensure Pydantic response formats serialize and deserialize properly.""" original = RunRequest( message="Structured", - thread_id="thread-pydantic", response_format=ModuleStructuredResponse, ) @@ -298,14 +292,14 @@ def test_round_trip_with_pydantic_response_format(self) -> None: def test_init_with_correlationId(self) -> None: """Test RunRequest initialization with correlationId.""" - request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123") + request = RunRequest(message="Test message", correlation_id="corr-123") assert request.message == "Test message" assert request.correlation_id == "corr-123" def test_to_dict_with_correlationId(self) -> None: """Test to_dict includes correlationId.""" - request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456") + request = RunRequest(message="Test", correlation_id="corr-456") data = request.to_dict() assert data["message"] == "Test" @@ -313,18 +307,16 @@ def test_to_dict_with_correlationId(self) -> None: def test_from_dict_with_correlationId(self) -> None: """Test from_dict with correlationId.""" - data = {"message": "Test", "correlationId": "corr-789", "thread_id": "thread-corr-from-dict"} + data = {"message": "Test", "correlationId": "corr-789"} request = RunRequest.from_dict(data) assert request.message == "Test" assert request.correlation_id == "corr-789" - assert request.thread_id == "thread-corr-from-dict" def test_round_trip_with_correlationId(self) -> None: """Test round-trip to_dict and from_dict with correlationId.""" original = RunRequest( message="Test message", - thread_id="thread-123", role=Role.SYSTEM, correlation_id="corr-123", ) @@ -335,13 +327,11 @@ def test_round_trip_with_correlationId(self) -> None: assert restored.message == original.message assert restored.role == original.role assert restored.correlation_id == original.correlation_id - assert restored.thread_id == original.thread_id def test_init_with_orchestration_id(self) -> None: """Test RunRequest initialization with orchestration_id.""" request = RunRequest( message="Test message", - thread_id="thread-orch-init", orchestration_id="orch-123", ) @@ -352,7 +342,6 @@ def test_to_dict_with_orchestration_id(self) -> None: """Test to_dict includes orchestrationId.""" request = RunRequest( message="Test", - thread_id="thread-orch-to-dict", orchestration_id="orch-456", ) data = request.to_dict() @@ -364,7 +353,6 @@ def test_to_dict_excludes_orchestration_id_when_none(self) -> None: """Test to_dict excludes orchestrationId when not set.""" request = RunRequest( message="Test", - thread_id="thread-orch-none", ) data = request.to_dict() @@ -375,19 +363,16 @@ def test_from_dict_with_orchestration_id(self) -> None: data = { "message": "Test", "orchestrationId": "orch-789", - "thread_id": "thread-orch-from-dict", } request = RunRequest.from_dict(data) assert request.message == "Test" assert request.orchestration_id == "orch-789" - assert request.thread_id == "thread-orch-from-dict" def test_round_trip_with_orchestration_id(self) -> None: """Test round-trip to_dict and from_dict with orchestration_id.""" original = RunRequest( message="Test message", - thread_id="thread-123", role=Role.SYSTEM, correlation_id="corr-123", orchestration_id="orch-123", @@ -400,20 +385,17 @@ def test_round_trip_with_orchestration_id(self) -> None: assert restored.role == original.role assert restored.correlation_id == original.correlation_id assert restored.orchestration_id == original.orchestration_id - assert restored.thread_id == original.thread_id class TestModelIntegration: """Test suite for integration between models.""" - def test_run_request_with_session_id(self) -> None: - """Test using RunRequest with AgentSessionId.""" + def test_run_request_with_session_id_string(self) -> None: + """AgentSessionId string can still be used by callers, but is not stored on RunRequest.""" session_id = AgentSessionId.with_random_key("AgentEntity") - request = RunRequest(message="Test message", thread_id=str(session_id)) + session_id_str = str(session_id) - assert request.thread_id is not None - assert request.thread_id == str(session_id) - assert request.thread_id.startswith("@AgentEntity@") + assert session_id_str.startswith("@AgentEntity@") if __name__ == "__main__": diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py index b0dd313b0b..98f70af95a 100644 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -300,8 +300,7 @@ def test_run_creates_entity_call(self) -> None: assert request["enable_tool_calls"] is True assert "correlationId" in request assert request["correlationId"] == "correlation-guid" - assert "thread_id" in request - assert request["thread_id"] == "thread-guid" + assert "thread_id" not in request # Verify orchestration ID is set from context.instance_id assert "orchestrationId" in request assert request["orchestrationId"] == "test-instance-001" diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 8eec13e8e6..ed6e4e9d1d 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -50,6 +50,7 @@ all = [ "agent-framework-copilotstudio", "agent-framework-declarative", "agent-framework-devui", + "agent-framework-durabletask", "agent-framework-lab", "agent-framework-mem0", "agent-framework-purview", diff --git a/python/packages/durabletask/agent_framework_durabletask/__init__.py b/python/packages/durabletask/agent_framework_durabletask/__init__.py index 7625ba6a3a..f28f4d8064 100644 --- a/python/packages/durabletask/agent_framework_durabletask/__init__.py +++ b/python/packages/durabletask/agent_framework_durabletask/__init__.py @@ -40,6 +40,7 @@ DurableAgentStateUsage, DurableAgentStateUsageContent, ) +from ._entities import AgentEntity, AgentEntityStateProviderMixin from ._models import RunRequest, serialize_response_format __all__ = [ @@ -54,6 +55,8 @@ "WAIT_FOR_RESPONSE_FIELD", "WAIT_FOR_RESPONSE_HEADER", "AgentCallbackContext", + "AgentEntity", + "AgentEntityStateProviderMixin", "AgentResponseCallbackProtocol", "ApiResponseFields", "ContentTypes", diff --git a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py index bae16e88f4..a42a6fad43 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py +++ b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py @@ -82,7 +82,7 @@ def _parse_created_at(value: Any) -> datetime: except (ValueError, TypeError): pass - logger.error( + logger.warning( f"Invalid or missing created_at value in durable agent state; defaulting to current UTC time, {value}", stack_info=True, ) diff --git a/python/packages/durabletask/agent_framework_durabletask/_entities.py b/python/packages/durabletask/agent_framework_durabletask/_entities.py new file mode 100644 index 0000000000..2f7bb8a62c --- /dev/null +++ b/python/packages/durabletask/agent_framework_durabletask/_entities.py @@ -0,0 +1,351 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Durable Task entity implementations for Microsoft Agent Framework.""" + +from __future__ import annotations + +import inspect +from collections.abc import AsyncIterable +from typing import Any, cast + +from agent_framework import ( + AgentProtocol, + AgentRunResponse, + AgentRunResponseUpdate, + ChatMessage, + ErrorContent, + Role, + get_logger, +) +from durabletask.entities import DurableEntity + +from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol +from ._durable_agent_state import ( + DurableAgentState, + DurableAgentStateEntry, + DurableAgentStateRequest, + DurableAgentStateResponse, +) +from ._models import RunRequest + +logger = get_logger("agent_framework.durabletask.entities") + + +class AgentEntityStateProviderMixin: + """Mixin implementing durable agent state caching + (de)serialization + persistence. + + Concrete classes must implement: + - _get_state_dict(): fetch raw persisted state dict (default should be {}) + - _set_state_dict(): persist raw state dict + - _get_thread_id_from_entity(): fetch the thread ID from the underlying context + """ + + _state_cache: DurableAgentState | None = None + + def _get_state_dict(self) -> dict[str, Any]: + raise NotImplementedError + + def _set_state_dict(self, state: dict[str, Any]) -> None: + raise NotImplementedError + + def _get_thread_id_from_entity(self) -> str: + raise NotImplementedError + + @property + def thread_id(self) -> str: + return self._get_thread_id_from_entity() + + @property + def state(self) -> DurableAgentState: + if self._state_cache is None: + raw_state = self._get_state_dict() + self._state_cache = DurableAgentState.from_dict(raw_state) if raw_state else DurableAgentState() + return self._state_cache + + @state.setter + def state(self, value: DurableAgentState) -> None: + self._state_cache = value + self.persist_state() + + def persist_state(self) -> None: + """Persist the current state to the underlying storage provider.""" + if self._state_cache is None: + self._state_cache = DurableAgentState() + self._set_state_dict(self._state_cache.to_dict()) + + def reset(self) -> None: + """Clear conversation history by resetting state to a fresh DurableAgentState.""" + self._state_cache = DurableAgentState() + self.persist_state() + logger.debug("[AgentEntityStateProviderMixin.reset] State reset complete") + + +class AgentEntity: + """Platform-agnostic agent execution logic. + + This class encapsulates the core logic for executing an agent within a durable entity context. + """ + + agent: AgentProtocol + callback: AgentResponseCallbackProtocol | None + + def __init__( + self, + agent: AgentProtocol, + callback: AgentResponseCallbackProtocol | None = None, + *, + state_provider: AgentEntityStateProviderMixin, + ) -> None: + self.agent = agent + self.callback = callback + self._state_provider = state_provider + + logger.debug("[AgentEntity] Initialized with agent type: %s", type(agent).__name__) + + @property + def state(self) -> DurableAgentState: + return self._state_provider.state + + @state.setter + def state(self, value: DurableAgentState) -> None: + self._state_provider.state = value + + def persist_state(self) -> None: + self._state_provider.persist_state() + + def reset(self) -> None: + self._state_provider.reset() + + def _is_error_response(self, entry: DurableAgentStateEntry) -> bool: + """Check if a conversation history entry is an error response.""" + if isinstance(entry, DurableAgentStateResponse): + return entry.is_error + return False + + async def run( + self, + request: RunRequest | dict[str, Any] | str, + ) -> AgentRunResponse: + """Execute the agent with a message.""" + if isinstance(request, str): + run_request = RunRequest(message=request, role=Role.USER) + elif isinstance(request, dict): + run_request = RunRequest.from_dict(request) + else: + run_request = request + + message = run_request.message + thread_id = self._state_provider.thread_id + correlation_id = run_request.correlation_id + if not thread_id: + raise ValueError("Entity State Provider must provide a thread_id") + if not correlation_id: + raise ValueError("RunRequest must include a correlation_id") + response_format = run_request.response_format + enable_tool_calls = run_request.enable_tool_calls + + logger.debug("[AgentEntity.run] Received Message: %s", run_request) + + state_request = DurableAgentStateRequest.from_run_request(run_request) + self.state.data.conversation_history.append(state_request) + + try: + chat_messages: list[ChatMessage] = [ + m.to_chat_message() + for entry in self.state.data.conversation_history + if not self._is_error_response(entry) + for m in entry.messages + ] + + run_kwargs: dict[str, Any] = {"messages": chat_messages} + if not enable_tool_calls: + run_kwargs["tools"] = None + if response_format: + run_kwargs["response_format"] = response_format + + agent_run_response: AgentRunResponse = await self._invoke_agent( + run_kwargs=run_kwargs, + correlation_id=correlation_id, + thread_id=thread_id, + request_message=message, + ) + + state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response) + self.state.data.conversation_history.append(state_response) + self.persist_state() + + return agent_run_response + + except Exception as exc: + logger.exception("[AgentEntity.run] Agent execution failed.") + + error_message = ChatMessage( + role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)] + ) + error_response = AgentRunResponse(messages=[error_message]) + + error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response) + error_state_response.is_error = True + self.state.data.conversation_history.append(error_state_response) + self.persist_state() + + return error_response + + async def _invoke_agent( + self, + run_kwargs: dict[str, Any], + correlation_id: str, + thread_id: str, + request_message: str, + ) -> AgentRunResponse: + """Execute the agent, preferring streaming when available.""" + callback_context: AgentCallbackContext | None = None + if self.callback is not None: + callback_context = self._build_callback_context( + correlation_id=correlation_id, + thread_id=thread_id, + request_message=request_message, + ) + + run_stream_callable = getattr(self.agent, "run_stream", None) + if callable(run_stream_callable): + try: + stream_candidate = run_stream_callable(**run_kwargs) + if inspect.isawaitable(stream_candidate): + stream_candidate = await stream_candidate + + return await self._consume_stream( + stream=cast(AsyncIterable[AgentRunResponseUpdate], stream_candidate), + callback_context=callback_context, + ) + except TypeError as type_error: + if "__aiter__" not in str(type_error): + raise + logger.debug( + "run_stream returned a non-async result; falling back to run(): %s", + type_error, + ) + except Exception as stream_error: + logger.warning( + "run_stream failed; falling back to run(): %s", + stream_error, + exc_info=True, + ) + else: + logger.debug("Agent does not expose run_stream; falling back to run().") + + agent_run_response = await self._invoke_non_stream(run_kwargs) + await self._notify_final_response(agent_run_response, callback_context) + return agent_run_response + + async def _consume_stream( + self, + stream: AsyncIterable[AgentRunResponseUpdate], + callback_context: AgentCallbackContext | None = None, + ) -> AgentRunResponse: + """Consume streaming responses and build the final AgentRunResponse.""" + updates: list[AgentRunResponseUpdate] = [] + + async for update in stream: + updates.append(update) + await self._notify_stream_update(update, callback_context) + + if updates: + response = AgentRunResponse.from_agent_run_response_updates(updates) + else: + logger.debug("[AgentEntity] No streaming updates received; creating empty response") + response = AgentRunResponse(messages=[]) + + await self._notify_final_response(response, callback_context) + return response + + async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentRunResponse: + """Invoke the agent without streaming support.""" + run_callable = getattr(self.agent, "run", None) + if run_callable is None or not callable(run_callable): + raise AttributeError("Agent does not implement run() method") + + result = run_callable(**run_kwargs) + if inspect.isawaitable(result): + result = await result + + if not isinstance(result, AgentRunResponse): + raise TypeError(f"Agent run() must return an AgentRunResponse instance; received {type(result).__name__}") + + return result + + async def _notify_stream_update( + self, + update: AgentRunResponseUpdate, + context: AgentCallbackContext | None, + ) -> None: + """Invoke the streaming callback if one is registered.""" + if self.callback is None or context is None: + return + + try: + callback_result = self.callback.on_streaming_response_update(update, context) + if inspect.isawaitable(callback_result): + await callback_result + except Exception as exc: + logger.warning( + "[AgentEntity] Streaming callback raised an exception: %s", + exc, + exc_info=True, + ) + + async def _notify_final_response( + self, + response: AgentRunResponse, + context: AgentCallbackContext | None, + ) -> None: + """Invoke the final response callback if one is registered.""" + if self.callback is None or context is None: + return + + try: + callback_result = self.callback.on_agent_response(response, context) + if inspect.isawaitable(callback_result): + await callback_result + except Exception as exc: + logger.warning( + "[AgentEntity] Response callback raised an exception: %s", + exc, + exc_info=True, + ) + + def _build_callback_context( + self, + correlation_id: str, + thread_id: str, + request_message: str, + ) -> AgentCallbackContext: + """Create the callback context provided to consumers.""" + agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__ + return AgentCallbackContext( + agent_name=agent_name, + correlation_id=correlation_id, + thread_id=thread_id, + request_message=request_message, + ) + + +class DurableTaskEntityStateProvider(DurableEntity, AgentEntityStateProviderMixin): + """DurableTask Durable Entity state provider for AgentEntity. + + This class utilizes the Durable Entity context from `durabletask` package + to get and set the state of the agent entity. + """ + + def __init__(self) -> None: + super().__init__() + + def _get_state_dict(self) -> dict[str, Any]: + raw = self.get_state(dict, default={}) + return cast(dict[str, Any], raw) + + def _set_state_dict(self, state: dict[str, Any]) -> None: + self.set_state(state) + + def _get_thread_id_from_entity(self) -> str: + return self.entity_context.entity_id.key diff --git a/python/packages/durabletask/agent_framework_durabletask/_models.py b/python/packages/durabletask/agent_framework_durabletask/_models.py index 11bdcc756f..14ca37f098 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_models.py +++ b/python/packages/durabletask/agent_framework_durabletask/_models.py @@ -101,7 +101,6 @@ class RunRequest: role: The role of the message sender (user, system, or assistant) response_format: Optional Pydantic BaseModel type describing the structured response format enable_tool_calls: Whether to enable tool calls for this request - thread_id: Optional thread ID for tracking correlation_id: Optional correlation ID for tracking the response to this specific request created_at: Optional timestamp when the request was created orchestration_id: Optional ID of the orchestration that initiated this request @@ -112,7 +111,6 @@ class RunRequest: role: Role = Role.USER response_format: type[BaseModel] | None = None enable_tool_calls: bool = True - thread_id: str | None = None correlation_id: str | None = None created_at: datetime | None = None orchestration_id: str | None = None @@ -124,7 +122,6 @@ def __init__( role: Role | str | None = Role.USER, response_format: type[BaseModel] | None = None, enable_tool_calls: bool = True, - thread_id: str | None = None, correlation_id: str | None = None, created_at: datetime | None = None, orchestration_id: str | None = None, @@ -134,7 +131,6 @@ def __init__( self.response_format = response_format self.request_response_format = request_response_format self.enable_tool_calls = enable_tool_calls - self.thread_id = thread_id self.correlation_id = correlation_id self.created_at = created_at self.orchestration_id = orchestration_id @@ -161,8 +157,6 @@ def to_dict(self) -> dict[str, Any]: } if self.response_format: result["response_format"] = serialize_response_format(self.response_format) - if self.thread_id: - result["thread_id"] = self.thread_id if self.correlation_id: result["correlationId"] = self.correlation_id if self.created_at: @@ -188,7 +182,6 @@ def from_dict(cls, data: dict[str, Any]) -> RunRequest: role=cls.coerce_role(data.get("role")), response_format=_deserialize_response_format(data.get("response_format")), enable_tool_calls=data.get("enable_tool_calls", True), - thread_id=data.get("thread_id"), correlation_id=data.get("correlationId"), created_at=created_at, orchestration_id=data.get("orchestrationId"), diff --git a/python/packages/durabletask/tests/test_entities.py b/python/packages/durabletask/tests/test_entities.py new file mode 100644 index 0000000000..386357bb4c --- /dev/null +++ b/python/packages/durabletask/tests/test_entities.py @@ -0,0 +1,695 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for AgentEntity. + +Run with: pytest tests/test_entities.py -v +""" + +from collections.abc import AsyncIterator +from datetime import datetime +from typing import Any, TypeVar +from unittest.mock import AsyncMock, Mock + +import pytest +from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, ErrorContent, Role +from pydantic import BaseModel + +from agent_framework_durabletask import ( + AgentEntity, + AgentEntityStateProviderMixin, + DurableAgentState, + DurableAgentStateData, + DurableAgentStateMessage, + DurableAgentStateRequest, + DurableAgentStateTextContent, + RunRequest, +) +from agent_framework_durabletask._entities import DurableTaskEntityStateProvider + +TState = TypeVar("TState") + + +class MockEntityContext: + """Minimal durabletask EntityContext shim for tests.""" + + def __init__(self, initial_state: Any = None) -> None: + self._state = initial_state + + def get_state( + self, + intended_type: type[TState] | None = None, + default: TState | None = None, + ) -> Any: + del intended_type + if self._state is None: + return default + return self._state + + def set_state(self, new_state: Any) -> None: + self._state = new_state + + +class _InMemoryStateProvider(AgentEntityStateProviderMixin): + """Test-only state provider for AgentEntity.""" + + def __init__(self, *, thread_id: str, initial_state: dict[str, Any] | None = None) -> None: + self._thread_id = thread_id + self._state_dict: dict[str, Any] = initial_state or {} + + def _get_state_dict(self) -> dict[str, Any]: + return self._state_dict + + def _set_state_dict(self, state: dict[str, Any]) -> None: + self._state_dict = state + + def _get_thread_id_from_entity(self) -> str: + return self._thread_id + + +def _make_entity(agent: Any, callback: Any = None, *, thread_id: str = "test-thread") -> AgentEntity: + return AgentEntity(agent, callback=callback, state_provider=_InMemoryStateProvider(thread_id=thread_id)) + + +def _role_value(chat_message: DurableAgentStateMessage) -> str: + """Helper to extract the string role from a ChatMessage.""" + role = getattr(chat_message, "role", None) + role_value = getattr(role, "value", role) + if role_value is None: + return "" + return str(role_value) + + +def _agent_response(text: str | None) -> AgentRunResponse: + """Create an AgentRunResponse with a single assistant message.""" + message = ( + ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[]) + ) + return AgentRunResponse(messages=[message]) + + +class RecordingCallback: + """Callback implementation capturing streaming and final responses for assertions.""" + + def __init__(self): + self.stream_mock = AsyncMock() + self.response_mock = AsyncMock() + + async def on_streaming_response_update( + self, + update: AgentRunResponseUpdate, + context: Any, + ) -> None: + await self.stream_mock(update, context) + + async def on_agent_response(self, response: AgentRunResponse, context: Any) -> None: + await self.response_mock(response, context) + + +class EntityStructuredResponse(BaseModel): + answer: float + + +class TestAgentEntityInit: + """Test suite for AgentEntity initialization.""" + + def test_init_creates_entity(self) -> None: + """Test that AgentEntity initializes correctly.""" + mock_agent = Mock() + + entity = _make_entity(mock_agent) + + assert entity.agent == mock_agent + assert len(entity.state.data.conversation_history) == 0 + assert entity.state.data.extension_data is None + assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION + + def test_init_stores_agent_reference(self) -> None: + """Test that the agent reference is stored correctly.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + entity = _make_entity(mock_agent) + + assert entity.agent.name == "TestAgent" + + def test_init_with_different_agent_types(self) -> None: + """Test initialization with different agent types.""" + agent1 = Mock() + agent1.__class__.__name__ = "AzureOpenAIAgent" + + agent2 = Mock() + agent2.__class__.__name__ = "CustomAgent" + + entity1 = _make_entity(agent1) + entity2 = _make_entity(agent2) + + assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent" + assert entity2.agent.__class__.__name__ == "CustomAgent" + + +class TestDurableTaskEntityStateProvider: + """Tests for DurableTaskEntityStateProvider wrapper behavior and persistence wiring.""" + + def _make_durabletask_entity_provider( + self, + agent: Any, + *, + initial_state: dict[str, Any] | None = None, + ) -> tuple[DurableTaskEntityStateProvider, MockEntityContext]: + """Create a DurableTaskEntityStateProvider wired to an in-memory durabletask context.""" + entity = DurableTaskEntityStateProvider() + ctx = MockEntityContext(initial_state) + # DurableEntity provides this hook; required for get_state/set_state to work in unit tests. + entity._initialize_entity_context(ctx) # type: ignore[attr-defined] + return entity, ctx + + def test_reset_persists_cleared_state(self) -> None: + mock_agent = Mock() + + existing_state = { + "schemaVersion": "1.0.0", + "data": { + "conversationHistory": [ + { + "$type": "request", + "correlationId": "corr-existing-1", + "createdAt": "2024-01-01T00:00:00Z", + "messages": [{"role": "user", "contents": [{"$type": "text", "text": "msg1"}]}], + } + ] + }, + } + + entity, ctx = self._make_durabletask_entity_provider(mock_agent, initial_state=existing_state) + + entity.reset() + + persisted = ctx.get_state(dict, default={}) + assert isinstance(persisted, dict) + assert persisted["data"]["conversationHistory"] == [] + + +class TestAgentEntityRunAgent: + """Test suite for the run_agent operation.""" + + async def test_run_executes_agent(self) -> None: + """Test that run executes the agent.""" + mock_agent = Mock() + mock_response = _agent_response("Test response") + mock_agent.run = AsyncMock(return_value=mock_response) + + entity = _make_entity(mock_agent) + + result = await entity.run({ + "message": "Test message", + "correlationId": "corr-entity-1", + }) + + # Verify agent.run was called + mock_agent.run.assert_called_once() + _, kwargs = mock_agent.run.call_args + sent_messages: list[Any] = kwargs.get("messages") + assert len(sent_messages) == 1 + sent_message = sent_messages[0] + assert isinstance(sent_message, ChatMessage) + assert getattr(sent_message, "text", None) == "Test message" + assert getattr(sent_message.role, "value", sent_message.role) == "user" + + # Verify result + assert isinstance(result, AgentRunResponse) + assert result.text == "Test response" + + async def test_run_agent_streaming_callbacks_invoked(self) -> None: + """Ensure streaming updates trigger callbacks and run() is not used.""" + updates = [ + AgentRunResponseUpdate(text="Hello"), + AgentRunResponseUpdate(text=" world"), + ] + + async def update_generator() -> AsyncIterator[AgentRunResponseUpdate]: + for update in updates: + yield update + + mock_agent = Mock() + mock_agent.name = "StreamingAgent" + mock_agent.run_stream = Mock(return_value=update_generator()) + mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds")) + + callback = RecordingCallback() + entity = _make_entity(mock_agent, callback=callback, thread_id="session-1") + + result = await entity.run( + { + "message": "Tell me something", + "correlationId": "corr-stream-1", + }, + ) + + assert isinstance(result, AgentRunResponse) + assert "Hello" in result.text + assert callback.stream_mock.await_count == len(updates) + assert callback.response_mock.await_count == 1 + mock_agent.run.assert_not_called() + + # Validate callback arguments + stream_calls = callback.stream_mock.await_args_list + for expected_update, recorded_call in zip(updates, stream_calls, strict=True): + assert recorded_call.args[0] is expected_update + context = recorded_call.args[1] + assert context.agent_name == "StreamingAgent" + assert context.correlation_id == "corr-stream-1" + assert context.thread_id == "session-1" + assert context.request_message == "Tell me something" + + final_call = callback.response_mock.await_args + assert final_call is not None + final_response, final_context = final_call.args + assert final_context.agent_name == "StreamingAgent" + assert final_context.correlation_id == "corr-stream-1" + assert final_context.thread_id == "session-1" + assert final_context.request_message == "Tell me something" + assert getattr(final_response, "text", "").strip() + + async def test_run_agent_final_callback_without_streaming(self) -> None: + """Ensure the final callback fires even when streaming is unavailable.""" + mock_agent = Mock() + mock_agent.name = "NonStreamingAgent" + mock_agent.run_stream = None + agent_response = _agent_response("Final response") + mock_agent.run = AsyncMock(return_value=agent_response) + + callback = RecordingCallback() + entity = _make_entity(mock_agent, callback=callback, thread_id="session-2") + + result = await entity.run( + { + "message": "Hi", + "correlationId": "corr-final-1", + }, + ) + + assert isinstance(result, AgentRunResponse) + assert result.text == "Final response" + assert callback.stream_mock.await_count == 0 + assert callback.response_mock.await_count == 1 + + final_call = callback.response_mock.await_args + assert final_call is not None + assert final_call.args[0] is agent_response + final_context = final_call.args[1] + assert final_context.agent_name == "NonStreamingAgent" + assert final_context.correlation_id == "corr-final-1" + assert final_context.thread_id == "session-2" + assert final_context.request_message == "Hi" + + async def test_run_agent_updates_conversation_history(self) -> None: + """Test that run_agent updates the conversation history.""" + mock_agent = Mock() + mock_response = _agent_response("Agent response") + mock_agent.run = AsyncMock(return_value=mock_response) + + entity = _make_entity(mock_agent) + + await entity.run({"message": "User message", "correlationId": "corr-entity-2"}) + + # Should have 2 entries: user message + assistant response + user_history = entity.state.data.conversation_history[0].messages + assistant_history = entity.state.data.conversation_history[1].messages + + assert len(user_history) == 1 + + user_msg = user_history[0] + assert _role_value(user_msg) == "user" + assert user_msg.text == "User message" + + assistant_msg = assistant_history[0] + assert _role_value(assistant_msg) == "assistant" + assert assistant_msg.text == "Agent response" + + async def test_run_agent_increments_message_count(self) -> None: + """Test that run_agent increments the message count.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + assert len(entity.state.data.conversation_history) == 0 + + await entity.run({"message": "Message 1", "correlationId": "corr-entity-3a"}) + assert len(entity.state.data.conversation_history) == 2 + + await entity.run({"message": "Message 2", "correlationId": "corr-entity-3b"}) + assert len(entity.state.data.conversation_history) == 4 + + await entity.run({"message": "Message 3", "correlationId": "corr-entity-3c"}) + assert len(entity.state.data.conversation_history) == 6 + + async def test_run_requires_entity_thread_id(self) -> None: + """Test that AgentEntity.run rejects missing entity thread identifiers.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent, thread_id="") + + with pytest.raises(ValueError, match="thread_id"): + await entity.run({"message": "Message", "correlationId": "corr-entity-5"}) + + async def test_run_agent_multiple_conversations(self) -> None: + """Test that run_agent maintains history across multiple messages.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + # Send multiple messages + await entity.run({"message": "Message 1", "correlationId": "corr-entity-8a"}) + await entity.run({"message": "Message 2", "correlationId": "corr-entity-8b"}) + await entity.run({"message": "Message 3", "correlationId": "corr-entity-8c"}) + + history = entity.state.data.conversation_history + assert len(history) == 6 + assert entity.state.message_count == 6 + + +class TestAgentEntityReset: + """Test suite for the reset operation.""" + + def test_reset_clears_conversation_history(self) -> None: + """Test that reset clears the conversation history.""" + mock_agent = Mock() + entity = _make_entity(mock_agent) + + # Add some history with proper DurableAgentStateEntry objects + entity.state.data.conversation_history = [ + DurableAgentStateRequest( + correlation_id="test-1", + created_at=datetime.now(), + messages=[ + DurableAgentStateMessage( + role="user", + contents=[DurableAgentStateTextContent(text="msg1")], + ) + ], + ), + ] + + entity.reset() + + assert entity.state.data.conversation_history == [] + + def test_reset_with_extension_data(self) -> None: + """Test that reset works when entity has extension data.""" + mock_agent = Mock() + entity = _make_entity(mock_agent) + + # Set up some initial state with conversation history + entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"}) + + entity.reset() + + assert len(entity.state.data.conversation_history) == 0 + + def test_reset_clears_message_count(self) -> None: + """Test that reset clears the message count.""" + mock_agent = Mock() + entity = _make_entity(mock_agent) + + entity.reset() + + assert len(entity.state.data.conversation_history) == 0 + + async def test_reset_after_conversation(self) -> None: + """Test reset after a full conversation.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + # Have a conversation + await entity.run({"message": "Message 1", "correlationId": "corr-entity-10a"}) + await entity.run({"message": "Message 2", "correlationId": "corr-entity-10b"}) + + # Verify state before reset + assert entity.state.message_count == 4 + assert len(entity.state.data.conversation_history) == 4 + + # Reset + entity.reset() + + # Verify state after reset + assert entity.state.message_count == 0 + assert len(entity.state.data.conversation_history) == 0 + + +class TestErrorHandling: + """Test suite for error handling in entities.""" + + async def test_run_agent_handles_agent_exception(self) -> None: + """Test that run_agent handles agent exceptions.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=Exception("Agent failed")) + + entity = _make_entity(mock_agent) + + result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-1"}) + + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert "Agent failed" in (content.message or "") + assert content.error_code == "Exception" + + async def test_run_agent_handles_value_error(self) -> None: + """Test that run_agent handles ValueError instances.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input")) + + entity = _make_entity(mock_agent) + + result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-2"}) + + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert content.error_code == "ValueError" + assert "Invalid input" in str(content.message) + + async def test_run_agent_handles_timeout_error(self) -> None: + """Test that run_agent handles TimeoutError instances.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout")) + + entity = _make_entity(mock_agent) + + result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-3"}) + + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + assert content.error_code == "TimeoutError" + + async def test_run_agent_preserves_message_on_error(self) -> None: + """Test that run_agent preserves message information on error.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(side_effect=Exception("Error")) + + entity = _make_entity(mock_agent) + + result = await entity.run( + {"message": "Test message", "correlationId": "corr-entity-error-4"}, + ) + + # Even on error, message info should be preserved + assert isinstance(result, AgentRunResponse) + assert len(result.messages) == 1 + content = result.messages[0].contents[0] + assert isinstance(content, ErrorContent) + + +class TestConversationHistory: + """Test suite for conversation history tracking.""" + + async def test_conversation_history_has_timestamps(self) -> None: + """Test that conversation history entries include timestamps.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + await entity.run({"message": "Message", "correlationId": "corr-entity-history-1"}) + + # Check both user and assistant messages have timestamps + for entry in entity.state.data.conversation_history: + timestamp = entry.created_at + assert timestamp is not None + # Verify timestamp is in ISO format + datetime.fromisoformat(str(timestamp)) + + async def test_conversation_history_ordering(self) -> None: + """Test that conversation history maintains the correct order.""" + mock_agent = Mock() + + entity = _make_entity(mock_agent) + + # Send multiple messages with different responses + mock_agent.run = AsyncMock(return_value=_agent_response("Response 1")) + await entity.run( + {"message": "Message 1", "correlationId": "corr-entity-history-2a"}, + ) + + mock_agent.run = AsyncMock(return_value=_agent_response("Response 2")) + await entity.run( + {"message": "Message 2", "correlationId": "corr-entity-history-2b"}, + ) + + mock_agent.run = AsyncMock(return_value=_agent_response("Response 3")) + await entity.run( + {"message": "Message 3", "correlationId": "corr-entity-history-2c"}, + ) + + # Verify order + history = entity.state.data.conversation_history + # Each conversation turn creates 2 entries: request and response + assert history[0].messages[0].text == "Message 1" # Request 1 + assert history[1].messages[0].text == "Response 1" # Response 1 + assert history[2].messages[0].text == "Message 2" # Request 2 + assert history[3].messages[0].text == "Response 2" # Response 2 + assert history[4].messages[0].text == "Message 3" # Request 3 + assert history[5].messages[0].text == "Response 3" # Response 3 + + async def test_conversation_history_role_alternation(self) -> None: + """Test that conversation history alternates between user and assistant roles.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + await entity.run( + {"message": "Message 1", "correlationId": "corr-entity-history-3a"}, + ) + await entity.run( + {"message": "Message 2", "correlationId": "corr-entity-history-3b"}, + ) + + # Check role alternation + history = entity.state.data.conversation_history + # Each conversation turn creates 2 entries: request and response + assert history[0].messages[0].role == "user" # Request 1 + assert history[1].messages[0].role == "assistant" # Response 1 + assert history[2].messages[0].role == "user" # Request 2 + assert history[3].messages[0].role == "assistant" # Response 2 + + +class TestRunRequestSupport: + """Test suite for RunRequest support in entities.""" + + async def test_run_agent_with_run_request_object(self) -> None: + """Test run_agent with a RunRequest object.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + request = RunRequest( + message="Test message", + role=Role.USER, + enable_tool_calls=True, + correlation_id="corr-runreq-1", + ) + + result = await entity.run(request) + + assert isinstance(result, AgentRunResponse) + assert result.text == "Response" + + async def test_run_agent_with_dict_request(self) -> None: + """Test run_agent with a dictionary request.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + request_dict = { + "message": "Test message", + "role": "system", + "enable_tool_calls": False, + "correlationId": "corr-runreq-2", + } + + result = await entity.run(request_dict) + + assert isinstance(result, AgentRunResponse) + assert result.text == "Response" + + async def test_run_agent_with_string_raises_without_correlation(self) -> None: + """Test that run_agent rejects legacy string input without correlation ID.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + with pytest.raises(ValueError): + await entity.run("Simple message") + + async def test_run_agent_stores_role_in_history(self) -> None: + """Test that run_agent stores the role in conversation history.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + # Send as system role + request = RunRequest( + message="System message", + role=Role.SYSTEM, + correlation_id="corr-runreq-3", + ) + + await entity.run(request) + + # Check that system role was stored + history = entity.state.data.conversation_history + assert history[0].messages[0].role == "system" + assert history[0].messages[0].text == "System message" + + async def test_run_agent_with_response_format(self) -> None: + """Test run_agent with a JSON response format.""" + mock_agent = Mock() + # Return JSON response + mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}')) + + entity = _make_entity(mock_agent) + + request = RunRequest( + message="What is the answer?", + response_format=EntityStructuredResponse, + correlation_id="corr-runreq-4", + ) + + result = await entity.run(request) + + assert isinstance(result, AgentRunResponse) + assert result.text == '{"answer": 42}' + assert result.value is None + + async def test_run_agent_disable_tool_calls(self) -> None: + """Test run_agent with tool calls disabled.""" + mock_agent = Mock() + mock_agent.run = AsyncMock(return_value=_agent_response("Response")) + + entity = _make_entity(mock_agent) + + request = RunRequest(message="Test", enable_tool_calls=False, correlation_id="corr-runreq-5") + + result = await entity.run(request) + + assert isinstance(result, AgentRunResponse) + # Agent should have been called (tool disabling is framework-dependent) + mock_agent.run.assert_called_once() + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/durabletask/tests/test_models.py b/python/packages/durabletask/tests/test_models.py index 2103ac69dd..f14bffcaf8 100644 --- a/python/packages/durabletask/tests/test_models.py +++ b/python/packages/durabletask/tests/test_models.py @@ -18,20 +18,18 @@ class TestRunRequest: def test_init_with_defaults(self) -> None: """Test RunRequest initialization with defaults.""" - request = RunRequest(message="Hello", thread_id="thread-default") + request = RunRequest(message="Hello") assert request.message == "Hello" assert request.role == Role.USER assert request.response_format is None assert request.enable_tool_calls is True - assert request.thread_id == "thread-default" def test_init_with_all_fields(self) -> None: """Test RunRequest initialization with all fields.""" schema = ModuleStructuredResponse request = RunRequest( message="Hello", - thread_id="thread-123", role=Role.SYSTEM, response_format=schema, enable_tool_calls=False, @@ -41,31 +39,29 @@ def test_init_with_all_fields(self) -> None: assert request.role == Role.SYSTEM assert request.response_format is schema assert request.enable_tool_calls is False - assert request.thread_id == "thread-123" def test_init_coerces_string_role(self) -> None: """Ensure string role values are coerced into Role instances.""" - request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type] + request = RunRequest(message="Hello", role="system") # type: ignore[arg-type] assert request.role == Role.SYSTEM def test_to_dict_with_defaults(self) -> None: """Test to_dict with default values.""" - request = RunRequest(message="Test message", thread_id="thread-to-dict") + request = RunRequest(message="Test message") data = request.to_dict() assert data["message"] == "Test message" assert data["enable_tool_calls"] is True assert data["role"] == "user" assert "response_format" not in data or data["response_format"] is None - assert data["thread_id"] == "thread-to-dict" + assert "thread_id" not in data def test_to_dict_with_all_fields(self) -> None: """Test to_dict with all fields.""" schema = ModuleStructuredResponse request = RunRequest( message="Hello", - thread_id="thread-456", role=Role.ASSISTANT, response_format=schema, enable_tool_calls=False, @@ -78,17 +74,22 @@ def test_to_dict_with_all_fields(self) -> None: assert data["response_format"]["module"] == schema.__module__ assert data["response_format"]["qualname"] == schema.__qualname__ assert data["enable_tool_calls"] is False - assert data["thread_id"] == "thread-456" + assert "thread_id" not in data def test_from_dict_with_defaults(self) -> None: """Test from_dict with minimal data.""" - data = {"message": "Hello", "thread_id": "thread-from-dict"} + data = {"message": "Hello"} request = RunRequest.from_dict(data) assert request.message == "Hello" assert request.role == Role.USER assert request.enable_tool_calls is True - assert request.thread_id == "thread-from-dict" + + def test_from_dict_ignores_thread_id_field(self) -> None: + """Ensure legacy thread_id input does not break RunRequest parsing.""" + request = RunRequest.from_dict({"message": "Hello", "thread_id": "ignored"}) + + assert request.message == "Hello" def test_from_dict_with_all_fields(self) -> None: """Test from_dict with all fields.""" @@ -101,7 +102,6 @@ def test_from_dict_with_all_fields(self) -> None: "qualname": ModuleStructuredResponse.__qualname__, }, "enable_tool_calls": False, - "thread_id": "thread-789", } request = RunRequest.from_dict(data) @@ -109,11 +109,10 @@ def test_from_dict_with_all_fields(self) -> None: assert request.role == Role.SYSTEM assert request.response_format is ModuleStructuredResponse assert request.enable_tool_calls is False - assert request.thread_id == "thread-789" def test_from_dict_with_unknown_role_preserves_value(self) -> None: """Test from_dict keeps custom roles intact.""" - data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"} + data = {"message": "Test", "role": "reviewer"} request = RunRequest.from_dict(data) assert request.role.value == "reviewer" @@ -121,18 +120,15 @@ def test_from_dict_with_unknown_role_preserves_value(self) -> None: def test_from_dict_empty_message(self) -> None: """Test from_dict with empty message.""" - data = {"thread_id": "thread-empty"} - request = RunRequest.from_dict(data) + request = RunRequest.from_dict({}) assert request.message == "" assert request.role == Role.USER - assert request.thread_id == "thread-empty" def test_round_trip_dict_conversion(self) -> None: """Test round-trip to_dict and from_dict.""" original = RunRequest( message="Test message", - thread_id="thread-123", role=Role.SYSTEM, response_format=ModuleStructuredResponse, enable_tool_calls=False, @@ -145,13 +141,11 @@ def test_round_trip_dict_conversion(self) -> None: assert restored.role == original.role assert restored.response_format is ModuleStructuredResponse assert restored.enable_tool_calls == original.enable_tool_calls - assert restored.thread_id == original.thread_id def test_round_trip_with_pydantic_response_format(self) -> None: """Ensure Pydantic response formats serialize and deserialize properly.""" original = RunRequest( message="Structured", - thread_id="thread-pydantic", response_format=ModuleStructuredResponse, ) @@ -166,14 +160,14 @@ def test_round_trip_with_pydantic_response_format(self) -> None: def test_init_with_correlationId(self) -> None: """Test RunRequest initialization with correlationId.""" - request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123") + request = RunRequest(message="Test message", correlation_id="corr-123") assert request.message == "Test message" assert request.correlation_id == "corr-123" def test_to_dict_with_correlationId(self) -> None: """Test to_dict includes correlationId.""" - request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456") + request = RunRequest(message="Test", correlation_id="corr-456") data = request.to_dict() assert data["message"] == "Test" @@ -181,18 +175,16 @@ def test_to_dict_with_correlationId(self) -> None: def test_from_dict_with_correlationId(self) -> None: """Test from_dict with correlationId.""" - data = {"message": "Test", "correlationId": "corr-789", "thread_id": "thread-corr-from-dict"} + data = {"message": "Test", "correlationId": "corr-789"} request = RunRequest.from_dict(data) assert request.message == "Test" assert request.correlation_id == "corr-789" - assert request.thread_id == "thread-corr-from-dict" def test_round_trip_with_correlationId(self) -> None: """Test round-trip to_dict and from_dict with correlationId.""" original = RunRequest( message="Test message", - thread_id="thread-123", role=Role.SYSTEM, correlation_id="corr-123", ) @@ -203,13 +195,11 @@ def test_round_trip_with_correlationId(self) -> None: assert restored.message == original.message assert restored.role == original.role assert restored.correlation_id == original.correlation_id - assert restored.thread_id == original.thread_id def test_init_with_orchestration_id(self) -> None: """Test RunRequest initialization with orchestration_id.""" request = RunRequest( message="Test message", - thread_id="thread-orch-init", orchestration_id="orch-123", ) @@ -220,7 +210,6 @@ def test_to_dict_with_orchestration_id(self) -> None: """Test to_dict includes orchestrationId.""" request = RunRequest( message="Test", - thread_id="thread-orch-to-dict", orchestration_id="orch-456", ) data = request.to_dict() @@ -232,7 +221,6 @@ def test_to_dict_excludes_orchestration_id_when_none(self) -> None: """Test to_dict excludes orchestrationId when not set.""" request = RunRequest( message="Test", - thread_id="thread-orch-none", ) data = request.to_dict() @@ -243,19 +231,16 @@ def test_from_dict_with_orchestration_id(self) -> None: data = { "message": "Test", "orchestrationId": "orch-789", - "thread_id": "thread-orch-from-dict", } request = RunRequest.from_dict(data) assert request.message == "Test" assert request.orchestration_id == "orch-789" - assert request.thread_id == "thread-orch-from-dict" def test_round_trip_with_orchestration_id(self) -> None: """Test round-trip to_dict and from_dict with orchestration_id.""" original = RunRequest( message="Test message", - thread_id="thread-123", role=Role.SYSTEM, correlation_id="corr-123", orchestration_id="orch-123", @@ -268,7 +253,6 @@ def test_round_trip_with_orchestration_id(self) -> None: assert restored.role == original.role assert restored.correlation_id == original.correlation_id assert restored.orchestration_id == original.orchestration_id - assert restored.thread_id == original.thread_id if __name__ == "__main__": diff --git a/python/samples/getting_started/azure_functions/03_callbacks/function_app.py b/python/samples/getting_started/azure_functions/03_callbacks/function_app.py index e6702f6586..401efd8c23 100644 --- a/python/samples/getting_started/azure_functions/03_callbacks/function_app.py +++ b/python/samples/getting_started/azure_functions/03_callbacks/function_app.py @@ -15,7 +15,7 @@ from typing import Any, DefaultDict import azure.functions as func -from agent_framework import AgentRunResponseUpdate +from agent_framework import AgentRunResponse, AgentRunResponseUpdate from agent_framework.azure import ( AgentCallbackContext, AgentFunctionApp, @@ -81,7 +81,7 @@ async def on_streaming_response_update( preview, ) - async def on_agent_response(self, response, context: AgentCallbackContext) -> None: + async def on_agent_response(self, response: AgentRunResponse, context: AgentCallbackContext) -> None: event = self._build_base_event(context) event.update( { diff --git a/python/uv.lock b/python/uv.lock index 479464e465..fcd106ccc7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -336,6 +336,7 @@ all = [ { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-purview", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -354,6 +355,7 @@ requires-dist = [ { name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" }, { name = "agent-framework-declarative", marker = "extra == 'all'", editable = "packages/declarative" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, + { name = "agent-framework-durabletask", marker = "extra == 'all'", editable = "packages/durabletask" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, { name = "agent-framework-purview", marker = "extra == 'all'", editable = "packages/purview" }, @@ -1378,7 +1380,7 @@ name = "clr-loader" version = "0.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/54/c2/da52aaf19424e3f0abec003d08dd1ccae52c88a3b41e31151a03bed18488/clr_loader-0.2.9.tar.gz", hash = "sha256:6af3d582c3de55ce9e9e676d2b3dbf6bc680c4ea8f76c58786739a5bdcf6b52d", size = 84829, upload-time = "2025-12-05T16:57:12.466Z" } wheels = [ @@ -1886,7 +1888,7 @@ name = "exceptiongroup" version = "1.3.1" 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/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1904,7 +1906,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.124.4" +version = "0.125.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1912,9 +1914,9 @@ dependencies = [ { 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/cd/21/ade3ff6745a82ea8ad88552b4139d27941549e4f19125879f848ac8f3c3d/fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763", size = 378460, upload-time = "2025-12-12T15:00:43.891Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0", size = 370550, upload-time = "2025-12-17T21:41:44.15Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/57/aa70121b5008f44031be645a61a7c4abc24e0e888ad3fc8fda916f4d188e/fastapi-0.124.4-py3-none-any.whl", hash = "sha256:6d1e703698443ccb89e50abe4893f3c84d9d6689c0cf1ca4fad6d3c15cf69f15", size = 113281, upload-time = "2025-12-12T15:00:42.44Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1", size = 112888, upload-time = "2025-12-17T21:41:41.286Z" }, ] [[package]] @@ -3006,7 +3008,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.10.6" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3020,9 +3022,9 @@ 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/e6/70/4ff19dd1085bb4d5007f008a696c8cf989a0ad76eabc512a5cd19ee4a0b7/langfuse-3.10.6.tar.gz", hash = "sha256:fced9ca0416ba7499afa45fbedf831afc0ec824cb283719b9cf429bf5713f205", size = 223656, upload-time = "2025-12-12T13:29:24.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/a4/f7c5919a1e7c26904dd0caa52dc90b75e616d94bece157429169ffce264a/langfuse-3.11.1.tar.gz", hash = "sha256:52bdb5bae2bb7c2add22777a0f88a1a5c96f90ec994935b773992153e57e94f8", size = 230854, upload-time = "2025-12-19T14:31:11.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f0/fac7d56ce1136afbbebaddd1dc119fb1b94b5a7489944d0b4c2dcee99ed7/langfuse-3.10.6-py3-none-any.whl", hash = "sha256:36ca490cd64e372b1b94c28063b3fea39b1a8446cabd20172b524d01011a34e1", size = 399347, upload-time = "2025-12-12T13:29:22.462Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ff/256e5814227373179e6c70c05ecead72b19dcda3cd2e0004bd643f64c70e/langfuse-3.11.1-py3-none-any.whl", hash = "sha256:f489c97fb2231b14e75383100158cdd6a158b87c1e9c9f96b2cdcbc015c48319", size = 413776, upload-time = "2025-12-19T14:31:10.166Z" }, ] [[package]] @@ -3356,7 +3358,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.24.0" +version = "1.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3374,9 +3376,9 @@ dependencies = [ { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/db9ae5ab1fcdd9cd2bcc7ca3b7361b712e30590b64d5151a31563af8f82d/mcp-1.24.0.tar.gz", hash = "sha256:aeaad134664ce56f2721d1abf300666a1e8348563f4d3baff361c3b652448efc", size = 604375, upload-time = "2025-12-12T14:19:38.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/0d/5cf14e177c8ae655a2fd9324a6ef657ca4cafd3fc2201c87716055e29641/mcp-1.24.0-py3-none-any.whl", hash = "sha256:db130e103cc50ddc3dffc928382f33ba3eaef0b711f7a87c05e7ded65b1ca062", size = 232896, upload-time = "2025-12-12T14:19:36.14Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, ] [package.optional-dependencies] @@ -3727,11 +3729,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/ea/f82ef99ced4d03c33bb314c9b84a08a0a86c448aaa11ffd6256b99538aa5/narwhals-2.13.0.tar.gz", hash = "sha256:ee94c97f4cf7cfeebbeca8d274784df8b3d7fd3f955ce418af998d405576fdd9", size = 594555, upload-time = "2025-12-01T13:54:05.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/84/897fe7b6406d436ef312e57e5a1a13b4a5e7e36d1844e8d934ce8880e3d3/narwhals-2.14.0.tar.gz", hash = "sha256:98be155c3599db4d5c211e565c3190c398c87e7bf5b3cdb157dece67641946e0", size = 600648, upload-time = "2025-12-16T11:29:13.458Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/0d/1861d1599571974b15b025e12b142d8e6b42ad66c8a07a89cb0fc21f1e03/narwhals-2.13.0-py3-none-any.whl", hash = "sha256:9b795523c179ca78204e3be53726da374168f906e38de2ff174c2363baaaf481", size = 426407, upload-time = "2025-12-01T13:54:03.861Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/b8ecc67e178919671695f64374a7ba916cf0adbf86efedc6054f38b5b8ae/narwhals-2.14.0-py3-none-any.whl", hash = "sha256:b56796c9a00179bd757d15282c540024e1d5c910b19b8c9944d836566c030acf", size = 430788, upload-time = "2025-12-16T11:29:11.699Z" }, ] [[package]] @@ -3929,7 +3931,7 @@ wheels = [ [[package]] name = "openai" -version = "2.12.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3941,14 +3943,14 @@ dependencies = [ { name = "tqdm", 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/86/f9/fb8abeb4cdba6f24daf3d7781f42ceb1be1ff579eb20705899e617dd95f1/openai-2.12.0.tar.gz", hash = "sha256:cc6dcbcb8bccf05976d983f6516c5c1f447b71c747720f1530b61e8f858bcbc9", size = 626183, upload-time = "2025-12-15T16:17:15.097Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938, upload-time = "2025-12-19T03:28:45.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/a1/f055214448cb4b176e89459d889af9615fe7d927634fb5a2cecfb7674bc5/openai-2.12.0-py3-none-any.whl", hash = "sha256:7177998ce49ba3f90bcce8b5769a6666d90b1f328f0518d913aaec701271485a", size = 1066590, upload-time = "2025-12-15T16:17:13.301Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" }, ] [[package]] name = "openai-agents" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3959,14 +3961,14 @@ dependencies = [ { name = "types-requests", 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/ac/0b/1bfc1f47708ce5500ad6b05ba8a0a789232ee6f5b9dd68938131c4674533/openai_agents-0.6.3.tar.gz", hash = "sha256:436479f201910cfc466893854b47d0f3acbf7b3bdafa95eedb590ed0d40393ef", size = 2016166, upload-time = "2025-12-11T18:07:47.823Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/36/826ce8ad497904a1becdedef80326c2fe932c754646b77405a8dc4cd49f7/openai_agents-0.6.4.tar.gz", hash = "sha256:07836865ed9c37946523d44b2d87ad375673b6558e783fa086db004a892331ec", size = 2022961, upload-time = "2025-12-19T06:42:55.356Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/06/d4bf0a8403ebc7d6b0fb2b45e41d6da6996b20f1dde1debffdac1b5ccb63/openai_agents-0.6.3-py3-none-any.whl", hash = "sha256:ada8b598f4db787939a62c8a291d07cbe68dae2d635955c44a0a0300746ee84f", size = 239015, upload-time = "2025-12-11T18:07:46.275Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ed/f2282debf62c52241959b111d2e35105b43b2ea6ff060833734405bb4d0f/openai_agents-0.6.4-py3-none-any.whl", hash = "sha256:d14635c1fa0ee39e79b81e5cab2f22dd5024772d3dbc0770d8307fd2548b3951", size = 241982, upload-time = "2025-12-19T06:42:53.92Z" }, ] [[package]] name = "openai-chatkit" -version = "1.4.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3975,9 +3977,9 @@ dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/89/bf2f094997c8e5cad5334e8a02e05fc458823e65fb2675f45b56b6d1ab73/openai_chatkit-1.4.0.tar.gz", hash = "sha256:e2527dffc3794a05596ad75efa66bdc4efb4ded5a77a013a55496cc989bcf2e6", size = 55269, upload-time = "2025-11-25T21:02:58.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/44/f6dc99c00343bc4b2e3a618d0f4d90ede105c297a2dc82e1eb8e39658a52/openai_chatkit-1.4.1.tar.gz", hash = "sha256:871212dce80b4c774dbb10e2c2ee11ecd13a2c8d86e95c791110a1f6c860138d", size = 57954, upload-time = "2025-12-18T23:44:05.117Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/bf/68d42561dd8a674b6f8541d879dd165b5ac4d81fcf1027462e154de66a4f/openai_chatkit-1.4.0-py3-none-any.whl", hash = "sha256:35d00ca8398908bd70d63e2284adcd836641cc11746f68d7cfa91d276e3dad3d", size = 39077, upload-time = "2025-11-25T21:02:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d7/2ba7198ebfa2a31b35b5253827f15290479b4377d4518b78173621467af4/openai_chatkit-1.4.1-py3-none-any.whl", hash = "sha256:b9e4d3c8ba708ad66a3ba577a04c1e154b1a27ab454ee312c90d886b7c61f34c", size = 41150, upload-time = "2025-12-18T23:44:03.784Z" }, ] [[package]] @@ -4519,7 +4521,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.0.1" +version = "7.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4529,9 +4531,9 @@ dependencies = [ { name = "six", 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/a2/d4/b9afe855a8a7a1bf4459c28ae4c300b40338122dc850acabefcf2c3df24d/posthog-7.0.1.tar.gz", hash = "sha256:21150562c2630a599c1d7eac94bc5c64eb6f6acbf3ff52ccf1e57345706db05a", size = 126985, upload-time = "2025-11-15T12:44:22.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/e5/5262d1604a3eb19b23d4e896bce87b4603fd39ec366a96b27e19e3299aef/posthog-7.4.0.tar.gz", hash = "sha256:1fb97b11960e24fcf0b80f0a6450b2311478e5a3ee6ea3c6f9284ff89060a876", size = 143780, upload-time = "2025-12-16T23:42:05.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/0c/8b6b20b0be71725e6e8a32dcd460cdbf62fe6df9bc656a650150dc98fedd/posthog-7.0.1-py3-none-any.whl", hash = "sha256:efe212d8d88a9ba80a20c588eab4baf4b1a5e90e40b551160a5603bb21e96904", size = 145234, upload-time = "2025-11-15T12:44:21.247Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/13066693d7a6f94fb5da3407417bbbc3f6aa8487051294d0ef766c1567fa/posthog-7.4.0-py3-none-any.whl", hash = "sha256:f9d4e32c1c0f2110256b1aae7046ed90af312c1dbb1eecc6a9cb427733b22970", size = 166079, upload-time = "2025-12-16T23:42:04.33Z" }, ] [[package]] @@ -4539,8 +4541,8 @@ name = "powerfx" version = "0.0.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/41/8f95f72f4f3b7ea54357c449bf5bd94813b6321dec31db9ffcbf578e2fa3/powerfx-0.0.33.tar.gz", hash = "sha256:85e8330bef8a7a207c3e010aa232df0ae38825e94d590c73daf3a3f44115cb09", size = 3236647, upload-time = "2025-11-20T19:31:09.414Z" } wheels = [ @@ -4549,7 +4551,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4558,9 +4560,9 @@ dependencies = [ { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "virtualenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] @@ -4679,14 +4681,14 @@ wheels = [ [[package]] name = "proto-plus" -version = "1.26.1" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, ] [[package]] @@ -5209,7 +5211,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ @@ -5333,19 +5335,19 @@ wheels = [ [[package]] name = "redis" -version = "6.4.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "(python_full_version < '3.11.3' and sys_platform == 'darwin') or (python_full_version < '3.11.3' and sys_platform == 'linux') or (python_full_version < '3.11.3' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] [[package]] name = "redisvl" -version = "0.12.1" +version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpath-ng", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -5358,9 +5360,9 @@ dependencies = [ { name = "redis", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/ac/7c527765011d07652ff9d97fd16f563d625bd1887ad09bafe2626f77f225/redisvl-0.12.1.tar.gz", hash = "sha256:c4df3f7dd2d92c71a98e54ba32bcfb4f7bd526c749e4721de0fd1f08e0ecddec", size = 689730, upload-time = "2025-11-25T19:24:04.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d6/8f3235b272e3a2370698d7524aad2dec15f53c5be5d6726ba41056844f69/redisvl-0.13.2.tar.gz", hash = "sha256:f34c4350922ac469c45d90b5db65c49950e6aa8706331931b000f631ff9a0f4a", size = 737736, upload-time = "2025-12-19T09:22:07.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/6a/f8c9f915a1d18fff2499684caff929d0c6e004ac5f6e5f9ecec88314cd2a/redisvl-0.12.1-py3-none-any.whl", hash = "sha256:c7aaea242508624b78a448362b7a33e3b411049271ce8bdc7ef95208b1095e6e", size = 176692, upload-time = "2025-11-25T19:24:03.013Z" }, + { url = "https://files.pythonhosted.org/packages/b2/93/81ea5c45637ce7fe2fdaf214d5e1b91afe96a472edeb9b659e24d3710dfb/redisvl-0.13.2-py3-none-any.whl", hash = "sha256:dd998c6acc54f13526d464ad6b6e6f0c4cf6985fb2c7a1655bdf8ed8e57a4c01", size = 192760, upload-time = "2025-12-19T09:22:06.301Z" }, ] [[package]] @@ -5662,28 +5664,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz", hash = "sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size = 5809165, upload-time = "2025-12-11T21:39:47.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl", hash = "sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size = 13576541, upload-time = "2025-12-11T21:39:14.806Z" }, - { url = "https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size = 13779363, upload-time = "2025-12-11T21:39:20.29Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size = 12925292, upload-time = "2025-12-11T21:39:38.757Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size = 13362894, upload-time = "2025-12-11T21:39:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size = 13311482, upload-time = "2025-12-11T21:39:17.51Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size = 14016100, upload-time = "2025-12-11T21:39:41.948Z" }, - { url = "https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size = 15477729, upload-time = "2025-12-11T21:39:23.279Z" }, - { url = "https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size = 15122386, upload-time = "2025-12-11T21:39:33.125Z" }, - { url = "https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size = 14497124, upload-time = "2025-12-11T21:38:59.33Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size = 14195343, upload-time = "2025-12-11T21:39:44.866Z" }, - { url = "https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size = 14346425, upload-time = "2025-12-11T21:39:05.927Z" }, - { url = "https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size = 13258768, upload-time = "2025-12-11T21:39:08.691Z" }, - { url = "https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size = 13326939, upload-time = "2025-12-11T21:39:53.842Z" }, - { url = "https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size = 13578888, upload-time = "2025-12-11T21:39:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size = 14314473, upload-time = "2025-12-11T21:39:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl", hash = "sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size = 13202651, upload-time = "2025-12-11T21:39:26.628Z" }, - { url = "https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl", hash = "sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size = 14702079, upload-time = "2025-12-11T21:39:11.954Z" }, - { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] @@ -6523,15 +6525,15 @@ wheels = [ [[package]] name = "typer-slim" -version = "0.20.0" +version = "0.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", 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/8e/45/81b94a52caed434b94da65729c03ad0fb7665fab0f7db9ee54c94e541403/typer_slim-0.20.0.tar.gz", hash = "sha256:9fc6607b3c6c20f5c33ea9590cbeb17848667c51feee27d9e314a579ab07d1a3", size = 106561, upload-time = "2025-10-20T17:03:46.642Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/3d/6a4ec47010e8de34dade20c8e7bce90502b173f62a6b41619523a3fcf562/typer_slim-0.20.1.tar.gz", hash = "sha256:bb9e4f7e6dc31551c8a201383df322b81b0ce37239a5ead302598a2ebb6f7c9c", size = 106113, upload-time = "2025-12-19T16:48:54.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f9/a273c8b57c69ac1b90509ebda204972265fdc978fbbecc25980786f8c038/typer_slim-0.20.1-py3-none-any.whl", hash = "sha256:8e89c5dbaffe87a4f86f4c7a9e2f7059b5b68c66f558f298969d42ce34f10122", size = 47440, upload-time = "2025-12-19T16:48:52.678Z" }, ] [[package]] @@ -6617,28 +6619,28 @@ wheels = [ [[package]] name = "uv" -version = "0.9.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/1a/cb0c37ae8513b253bcbc13d42392feb7d95ea696eb398b37535a28df9040/uv-0.9.17.tar.gz", hash = "sha256:6d93ab9012673e82039cfa7f9f66f69b388bc3f910f9e8a2ebee211353f620aa", size = 3815957, upload-time = "2025-12-09T23:01:21.756Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/e2/b6e2d473bdc37f4d86307151b53c0776e9925de7376ce297e92eab2e8894/uv-0.9.17-py3-none-linux_armv6l.whl", hash = "sha256:c708e6560ae5bc3cda1ba93f0094148ce773b6764240ced433acf88879e57a67", size = 21254511, upload-time = "2025-12-09T23:00:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/d5/40/75f1529a8bf33cc5c885048e64a014c3096db5ac7826c71e20f2b731b588/uv-0.9.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:233b3d90f104c59d602abf434898057876b87f64df67a37129877d6dab6e5e10", size = 20384366, upload-time = "2025-12-09T23:01:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/de/30/b3a343893681a569cbb74f8747a1c24e5f18ca9e07de0430aceaf9389ef4/uv-0.9.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4b8e5513d48a267bfa180ca7fefaf6f27b1267e191573b3dba059981143e88ef", size = 18924624, upload-time = "2025-12-09T23:01:10.291Z" }, - { url = "https://files.pythonhosted.org/packages/21/56/9daf8bbe4a9a36eb0b9257cf5e1e20f9433d0ce996778ccf1929cbe071a4/uv-0.9.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8f283488bbcf19754910cc1ae7349c567918d6367c596e5a75d4751e0080eee0", size = 20671687, upload-time = "2025-12-09T23:00:51.927Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c8/4050ff7dc692770092042fcef57223b8852662544f5981a7f6cac8fc488d/uv-0.9.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9cf8052ba669dc17bdba75dae655094d820f4044990ea95c01ec9688c182f1da", size = 20861866, upload-time = "2025-12-09T23:01:12.555Z" }, - { url = "https://files.pythonhosted.org/packages/84/d4/208e62b7db7a65cb3390a11604c59937e387d07ed9f8b63b54edb55e2292/uv-0.9.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06749461b11175a884be193120044e7f632a55e2624d9203398808907d346aad", size = 21858420, upload-time = "2025-12-09T23:01:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/86/2c/91288cd5a04db37dfc1e0dad26ead84787db5832d9836b4cc8e0fa7f3c53/uv-0.9.17-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:35eb1a519688209160e48e1bb8032d36d285948a13b4dd21afe7ec36dc2a9787", size = 23471658, upload-time = "2025-12-09T23:00:49.503Z" }, - { url = "https://files.pythonhosted.org/packages/44/ba/493eba650ffad1df9e04fd8eabfc2d0aebc23e8f378acaaee9d95ca43518/uv-0.9.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2bfb60a533e82690ab17dfe619ff7f294d053415645800d38d13062170230714", size = 23062950, upload-time = "2025-12-09T23:00:39.055Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9e/f7f679503c06843ba59451e3193f35fb7c782ff0afc697020d4718a7de46/uv-0.9.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd0f3e380ff148aff3d769e95a9743cb29c7f040d7ef2896cafe8063279a6bc1", size = 22080299, upload-time = "2025-12-09T23:00:44.026Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/76ba33c7d9efe9f17480db1b94d3393025062005e346bb8b3660554526da/uv-0.9.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2c3d25fbd8f91b30d0fac69a13b8e2c2cd8e606d7e6e924c1423e4ff84e616", size = 22087554, upload-time = "2025-12-09T23:00:41.715Z" }, - { url = "https://files.pythonhosted.org/packages/14/db/ef4aae4a6c49076db2acd2a7b0278ddf3dbf785d5172b3165018b96ba2fb/uv-0.9.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:330e7085857e4205c5196a417aca81cfbfa936a97dd2a0871f6560a88424ebf2", size = 20823225, upload-time = "2025-12-09T23:00:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/e0f816cacd802a1cb25e71de9d60e57fa1f6c659eb5599cef708668618cc/uv-0.9.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:45880faa9f6cf91e3cda4e5f947da6a1004238fdc0ed4ebc18783a12ce197312", size = 22004893, upload-time = "2025-12-09T23:01:15.011Z" }, - { url = "https://files.pythonhosted.org/packages/15/6b/700f6256ee191136eb06e40d16970a4fc687efdccf5e67c553a258063019/uv-0.9.17-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8e775a1b94c6f248e22f0ce2f86ed37c24e10ae31fb98b7e1b9f9a3189d25991", size = 20853850, upload-time = "2025-12-09T23:01:02.694Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6a/13f02e2ed6510223c40f74804586b09e5151d9319f93aab1e49d91db13bb/uv-0.9.17-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8650c894401ec96488a6fd84a5b4675e09be102f5525c902a12ba1c8ef8ff230", size = 21322623, upload-time = "2025-12-09T23:00:46.806Z" }, - { url = "https://files.pythonhosted.org/packages/d0/18/2d19780cebfbec877ea645463410c17859f8070f79c1a34568b153d78e1d/uv-0.9.17-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:673066b72d8b6c86be0dae6d5f73926bcee8e4810f1690d7b8ce5429d919cde3", size = 22290123, upload-time = "2025-12-09T23:00:54.394Z" }, - { url = "https://files.pythonhosted.org/packages/77/69/ab79bde3f7b6d2ac89f839ea40411a9cf3e67abede2278806305b6ba797e/uv-0.9.17-py3-none-win32.whl", hash = "sha256:7407d45afeae12399de048f7c8c2256546899c94bd7892dbddfae6766616f5a3", size = 20070709, upload-time = "2025-12-09T23:01:05.105Z" }, - { url = "https://files.pythonhosted.org/packages/08/a0/ab5b1850197bf407d095361b214352e40805441791fed35b891621cb1562/uv-0.9.17-py3-none-win_amd64.whl", hash = "sha256:22fcc26755abebdf366becc529b2872a831ce8bb14b36b6a80d443a1d7f84d3b", size = 22122852, upload-time = "2025-12-09T23:01:07.783Z" }, - { url = "https://files.pythonhosted.org/packages/37/ef/813cfedda3c8e49d8b59a41c14fcc652174facfd7a1caf9fee162b40ccbd/uv-0.9.17-py3-none-win_arm64.whl", hash = "sha256:6761076b27a763d0ede2f5e72455d2a46968ff334badf8312bb35988c5254831", size = 20435751, upload-time = "2025-12-09T23:01:19.732Z" }, +version = "0.9.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/03/1afff9e6362dc9d3a9e03743da0a4b4c7a0809f859c79eb52bbae31ea582/uv-0.9.18.tar.gz", hash = "sha256:17b5502f7689c4dc1fdeee9d8437a9a6664dcaa8476e70046b5f4753559533f5", size = 3824466, upload-time = "2025-12-16T15:45:11.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9c/92fad10fcee8ea170b66442d95fd2af308fe9a107909ded4b3cc384fdc69/uv-0.9.18-py3-none-linux_armv6l.whl", hash = "sha256:e9e4915bb280c1f79b9a1c16021e79f61ed7c6382856ceaa99d53258cb0b4951", size = 21345538, upload-time = "2025-12-16T15:45:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/81/b1/b0e5808e05acb54aa118c625d9f7b117df614703b0cbb89d419d03d117f3/uv-0.9.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d91abfd2649987996e3778729140c305ef0f6ff5909f55aac35c3c372544a24f", size = 20439572, upload-time = "2025-12-16T15:45:26.397Z" }, + { url = "https://files.pythonhosted.org/packages/b7/0b/9487d83adf5b7fd1e20ced33f78adf84cb18239c3d7e91f224cedba46c08/uv-0.9.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cf33f4146fd97e94cdebe6afc5122208eea8c55b65ca4127f5a5643c9717c8b8", size = 18952907, upload-time = "2025-12-16T15:44:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/58/92/c8f7ae8900eff8e4ce1f7826d2e1e2ad5a95a5f141abdb539865aff79930/uv-0.9.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:edf965e9a5c55f74020ac82285eb0dfe7fac4f325ad0a7afc816290269ecfec1", size = 20772495, upload-time = "2025-12-16T15:45:29.614Z" }, + { url = "https://files.pythonhosted.org/packages/5a/28/9831500317c1dd6cde5099e3eb3b22b88ac75e47df7b502f6aef4df5750e/uv-0.9.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae10a941bd7ca1ee69edbe3998c34dce0a9fc2d2406d98198343daf7d2078493", size = 20949623, upload-time = "2025-12-16T15:44:57.482Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ff/1fe1ffa69c8910e54dd11f01fb0765d4fd537ceaeb0c05fa584b6b635b82/uv-0.9.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1669a95b588f613b13dd10e08ced6d5bcd79169bba29a2240eee87532648790", size = 21920580, upload-time = "2025-12-16T15:44:39.009Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/eed3ec7679ee80e16316cfc95ed28ef6851700bcc66edacfc583cbd2cc47/uv-0.9.18-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:11e1e406590d3159138288203a41ff8a8904600b8628a57462f04ff87d62c477", size = 23491234, upload-time = "2025-12-16T15:45:32.59Z" }, + { url = "https://files.pythonhosted.org/packages/78/58/64b15df743c79ad03ea7fbcbd27b146ba16a116c57f557425dd4e44d6684/uv-0.9.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e82078d3c622cb4c60da87f156168ffa78b9911136db7ffeb8e5b0a040bf30e", size = 23095438, upload-time = "2025-12-16T15:45:17.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/6d/3d3dae71796961603c3871699e10d6b9de2e65a3c327b58d4750610a5f93/uv-0.9.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704abaf6e76b4d293fc1f24bef2c289021f1df0de9ed351f476cbbf67a7edae0", size = 22140992, upload-time = "2025-12-16T15:44:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/31/91/1042d0966a30e937df500daed63e1f61018714406ce4023c8a6e6d2dcf7c/uv-0.9.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3332188fd8d96a68e5001409a52156dced910bf1bc41ec3066534cffcd46eb68", size = 22229626, upload-time = "2025-12-16T15:45:20.712Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1f/0a4a979bb2bf6e1292cc57882955bf1d7757cad40b1862d524c59c2a2ad8/uv-0.9.18-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:b7295e6d505f1fd61c54b1219e3b18e11907396333a9fa61cefe489c08fc7995", size = 20896524, upload-time = "2025-12-16T15:45:06.799Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3c/24f92e56af00cac7d9bed2888d99a580f8093c8745395ccf6213bfccf20b/uv-0.9.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:62ea0e518dd4ab76e6f06c0f43a25898a6342a3ecf996c12f27f08eb801ef7f1", size = 22077340, upload-time = "2025-12-16T15:44:51.271Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3e/73163116f748800e676bf30cee838448e74ac4cc2f716c750e1705bc3fe4/uv-0.9.18-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8bd073e30030211ba01206caa57b4d63714e1adee2c76a1678987dd52f72d44d", size = 20932956, upload-time = "2025-12-16T15:45:00.3Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/a26990b51a17de1ffe41fbf2e30de3a98f0e0bce40cc60829fb9d9ed1a8a/uv-0.9.18-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f248e013d10e1fc7a41f94310628b4a8130886b6d683c7c85c42b5b36d1bcd02", size = 21357247, upload-time = "2025-12-16T15:45:23.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/20/b6ba14fdd671e9237b22060d7422aba4a34503e3e42d914dbf925eff19aa/uv-0.9.18-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:17bedf2b0791e87d889e1c7f125bd5de77e4b7579aec372fa06ba832e07c957e", size = 22443585, upload-time = "2025-12-16T15:44:42.213Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/1b3dd596964f90a122cfe94dcf5b6b89cf5670eb84434b8c23864382576f/uv-0.9.18-py3-none-win32.whl", hash = "sha256:de6f0bb3e9c18e484545bd1549ec3c956968a141a393d42e2efb25281cb62787", size = 20091088, upload-time = "2025-12-16T15:45:03.225Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/50e13ebc1eedb36d88524b7740f78351be33213073e3faf81ac8925d0c6e/uv-0.9.18-py3-none-win_amd64.whl", hash = "sha256:c82b0e2e36b33e2146fba5f0ae6906b9679b3b5fe6a712e5d624e45e441e58e9", size = 22181193, upload-time = "2025-12-16T15:44:54.394Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d4/0bf338d863a3d9e5545e268d77a8e6afdd75d26bffc939603042f2e739f9/uv-0.9.18-py3-none-win_arm64.whl", hash = "sha256:4c4ce0ed080440bbda2377488575d426867f94f5922323af6d4728a1cd4d091d", size = 20564933, upload-time = "2025-12-16T15:45:09.819Z" }, ] [[package]]