From 0314303f6ab3558ce771ef969f625c124e2658af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:45:57 +0000 Subject: [PATCH 1/5] Initial plan From b0534ab626439eb9d66ad9d349e7b4965ae0d844 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 18:57:08 +0000 Subject: [PATCH 2/5] Add orchestration ID to durable agent entity state for Python Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> --- .../_durable_agent_state.py | 20 ++-- .../agent_framework_azurefunctions/_models.py | 7 ++ .../_orchestration.py | 2 + .../azurefunctions/tests/test_entities.py | 95 ++++++++++++++++++- .../azurefunctions/tests/test_models.py | 65 +++++++++++++ .../tests/test_orchestration.py | 23 +++++ 6 files changed, 205 insertions(+), 7 deletions(-) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py index 73695e61f2..6e2c3fbf9a 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py @@ -227,7 +227,7 @@ class DurableAgentState: in Azure Durable Entities. It maintains the conversation history as a sequence of request and response entries, each with their messages, timestamps, and metadata. - The state follows a versioned schema (currently 1.0.0) that defines the structure for: + The state follows a versioned schema (currently 1.1.0) that defines the structure for: - Request entries: User/system messages with optional response format specifications - Response entries: Assistant messages with token usage information - Messages: Individual chat messages with role, content items, and timestamps @@ -235,7 +235,7 @@ class DurableAgentState: State is serialized to JSON with this structure: { - "schemaVersion": "1.0.0", + "schemaVersion": "1.1.0", "data": { "conversationHistory": [ {"$type": "request", "correlationId": "...", "createdAt": "...", "messages": [...]}, @@ -246,17 +246,17 @@ class DurableAgentState: Attributes: data: Container for conversation history and optional extension data - schema_version: Schema version string (defaults to "1.0.0") + schema_version: Schema version string (defaults to "1.1.0") """ data: DurableAgentStateData - schema_version: str = "1.0.0" + schema_version: str = "1.1.0" - def __init__(self, schema_version: str = "1.0.0"): + def __init__(self, schema_version: str = "1.1.0"): """Initialize a new durable agent state. Args: - schema_version: Schema version to use (defaults to "1.0.0") + schema_version: Schema version to use (defaults to "1.1.0") """ self.data = DurableAgentStateData() self.schema_version = schema_version @@ -430,6 +430,7 @@ class DurableAgentStateRequest(DurableAgentStateEntry): Attributes: response_type: Expected response type ("text" or "json") response_schema: JSON schema for structured responses (when response_type is "json") + orchestration_id: ID of the orchestration that initiated this request (if any) correlationId: Unique identifier linking this request to its response created_at: Timestamp when the request was created messages: List of messages included in this request @@ -438,6 +439,7 @@ class DurableAgentStateRequest(DurableAgentStateEntry): response_type: str | None = None response_schema: dict[str, Any] | None = None + orchestration_id: str | None = None def __init__( self, @@ -447,6 +449,7 @@ def __init__( extension_data: dict[str, Any] | None = None, response_type: str | None = None, response_schema: dict[str, Any] | None = None, + orchestration_id: str | None = None, ) -> None: super().__init__( json_type=DurableAgentStateEntryJsonType.REQUEST, @@ -457,9 +460,12 @@ def __init__( ) self.response_type = response_type self.response_schema = response_schema + self.orchestration_id = orchestration_id def to_dict(self) -> dict[str, Any]: data = super().to_dict() + if self.orchestration_id is not None: + data["orchestrationId"] = self.orchestration_id if self.response_type is not None: data["responseType"] = self.response_type if self.response_schema is not None: @@ -484,6 +490,7 @@ def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: extension_data=data.get("extensionData"), response_type=data.get("responseType"), response_schema=data.get("responseSchema"), + orchestration_id=data.get("orchestrationId"), ) @staticmethod @@ -495,6 +502,7 @@ def from_run_request(request: RunRequest) -> DurableAgentStateRequest: created_at=datetime.now(tz=timezone.utc), response_type=request.request_response_format, response_schema=_serialize_response_format(request.response_format), + orchestration_id=request.orchestration_id, ) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py index 19f175a485..c2bb2e6fd6 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_models.py @@ -287,6 +287,7 @@ class RunRequest: 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 """ message: str @@ -297,6 +298,7 @@ class RunRequest: thread_id: str | None = None correlation_id: str | None = None created_at: str | None = None + orchestration_id: str | None = None def __init__( self, @@ -308,6 +310,7 @@ def __init__( thread_id: str | None = None, correlation_id: str | None = None, created_at: str | None = None, + orchestration_id: str | None = None, ) -> None: self.message = message self.role = self.coerce_role(role) @@ -317,6 +320,7 @@ def __init__( self.thread_id = thread_id self.correlation_id = correlation_id self.created_at = created_at + self.orchestration_id = orchestration_id @staticmethod def coerce_role(value: Role | str | None) -> Role: @@ -346,6 +350,8 @@ def to_dict(self) -> dict[str, Any]: result["correlationId"] = self.correlation_id if self.created_at: result["created_at"] = self.created_at + if self.orchestration_id: + result["orchestrationId"] = self.orchestration_id return result @@ -361,6 +367,7 @@ def from_dict(cls, data: dict[str, Any]) -> RunRequest: thread_id=data.get("thread_id"), correlation_id=data.get("correlationId"), created_at=data.get("created_at"), + orchestration_id=data.get("orchestrationId"), ) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py index 2fd4522964..efe39966f1 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_orchestration.py @@ -132,12 +132,14 @@ def my_orchestration(context): correlation_id = str(self.context.new_uuid()) # Prepare the request using RunRequest model + # Include the orchestration's instance_id so it can be stored in the agent's entity state run_request = RunRequest( 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, ) logger.debug(f"[DurableAIAgent] Calling entity {entity_id} with message: {message_str[:100]}...") diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index 2f73f1daa8..ff5adbe746 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -79,7 +79,7 @@ def test_init_creates_entity(self) -> None: 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 == "1.0.0" + assert entity.state.schema_version == "1.1.0" def test_init_stores_agent_reference(self) -> None: """Test that the agent reference is stored correctly.""" @@ -929,5 +929,98 @@ async def test_entity_function_with_run_request_dict(self) -> None: assert result["message"] == "Test 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 + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_models.py b/python/packages/azurefunctions/tests/test_models.py index 5b803ead13..92a3353fc5 100644 --- a/python/packages/azurefunctions/tests/test_models.py +++ b/python/packages/azurefunctions/tests/test_models.py @@ -336,6 +336,71 @@ def test_round_trip_with_correlationId(self) -> None: 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", + ) + + assert request.message == "Test message" + assert request.orchestration_id == "orch-123" + + 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() + + assert data["message"] == "Test" + assert data["orchestrationId"] == "orch-456" + + 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() + + assert "orchestrationId" not in data + + def test_from_dict_with_orchestration_id(self) -> None: + """Test from_dict with orchestrationId.""" + 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", + ) + + data = original.to_dict() + restored = RunRequest.from_dict(data) + + assert restored.message == original.message + 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 TestAgentResponse: """Test suite for AgentResponse.""" diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py index 93201a64e9..6bc96b1918 100644 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -140,6 +140,29 @@ def test_run_creates_entity_call(self) -> None: assert request["correlationId"] == "correlation-guid" assert "thread_id" in request assert request["thread_id"] == "thread-guid" + # Verify orchestration ID is set from context.instance_id + assert "orchestrationId" in request + assert request["orchestrationId"] == "test-instance-001" + + def test_run_sets_orchestration_id(self) -> None: + """Test that run() sets the orchestration_id from context.instance_id.""" + mock_context = Mock() + mock_context.instance_id = "my-orchestration-123" + mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) + + mock_task = Mock() + mock_task._is_scheduled = False + mock_context.call_entity = Mock(return_value=mock_task) + + agent = DurableAIAgent(mock_context, "TestAgent") + thread = agent.get_new_thread() + + agent.run(messages="Test", thread=thread) + + call_args = mock_context.call_entity.call_args + request = call_args[0][2] + + assert request["orchestrationId"] == "my-orchestration-123" def test_run_without_thread(self) -> None: """Test that run() works without explicit thread (creates unique session key).""" From ed842631ee1fd23b5db08261bc512665b47dd20d Mon Sep 17 00:00:00 2001 From: Laveesh Rohra Date: Wed, 26 Nov 2025 12:10:38 -0800 Subject: [PATCH 3/5] Fix type safety checks --- .../agent_framework_azurefunctions/_app.py | 16 +- .../_durable_agent_state.py | 262 ++++++++++-------- .../azurefunctions/tests/test_entities.py | 5 +- python/uv.lock | 64 ++--- 4 files changed, 187 insertions(+), 160 deletions(-) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index e83a9244a7..7d8ebe0264 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -562,6 +562,7 @@ async def mcp_tool_handler(context: str, client: df.DurableOrchestrationClient) logger.debug("[MCP Tool Trigger] Received invocation for agent: %s", agent_name) return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) + _ = mcp_tool_handler logger.debug("[AgentFunctionApp] Registered MCP tool trigger for agent: %s", agent_name) async def _handle_mcp_tool_invocation( @@ -587,15 +588,17 @@ async def _handle_mcp_tool_invocation( # Parse JSON context string try: - parsed_context = json.loads(context) + parsed_context: Any = json.loads(context) except json.JSONDecodeError as e: raise ValueError(f"Invalid MCP context format: {e}") from e + parsed_context = cast(Mapping[str, Any], parsed_context) if isinstance(parsed_context, dict) else {} + # Extract arguments from MCP context - arguments = parsed_context.get("arguments", {}) if isinstance(parsed_context, dict) else {} + arguments: dict[str, Any] = parsed_context.get("arguments", {}) # Validate required 'query' argument - query = arguments.get("query") + query: Any = arguments.get("query") if not query or not isinstance(query, str): raise ValueError("MCP Tool invocation is missing required 'query' argument of type string.") @@ -951,10 +954,9 @@ def _extract_normalized_headers(self, req: func.HttpRequest) -> dict[str, str]: """Create a lowercase header mapping from the incoming request.""" headers: dict[str, str] = {} raw_headers = req.headers - if isinstance(raw_headers, Mapping): - for key, value in raw_headers.items(): - if value is not None: - headers[str(key).lower()] = str(value) + for key, value in cast(Mapping[str, str], raw_headers).items(): + headers[key.lower()] = value + return headers @staticmethod diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py index 6e2c3fbf9a..f870a135e1 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py @@ -32,7 +32,7 @@ import json from datetime import datetime, timezone from enum import Enum -from typing import Any +from typing import Any, cast from agent_framework import ( AgentRunResponse, @@ -74,6 +74,130 @@ def _parse_created_at(value: Any) -> datetime: return datetime.now(tz=timezone.utc) +def _parse_messages(data: dict[str, Any]) -> list[DurableAgentStateMessage]: + """Parse messages from a dictionary, converting dicts to DurableAgentStateMessage objects. + + Args: + data: Dictionary containing a 'messages' key with a list of message data + + Returns: + List of DurableAgentStateMessage objects + """ + messages: list[DurableAgentStateMessage] = [] + raw_messages: list[Any] = data.get("messages", []) + for raw_msg in raw_messages: + if isinstance(raw_msg, dict): + messages.append(DurableAgentStateMessage.from_dict(cast(dict[str, Any], raw_msg))) + elif isinstance(raw_msg, DurableAgentStateMessage): + messages.append(raw_msg) + return messages + + +def _parse_history_entries(data_dict: dict[str, Any]) -> list[DurableAgentStateEntry]: + """Parse conversation history entries from a dictionary. + + Args: + data_dict: Dictionary containing a 'conversationHistory' key with a list of entry data + + Returns: + List of DurableAgentStateEntry objects (requests and responses) + """ + history_data: list[Any] = data_dict.get("conversationHistory", []) + deserialized_history: list[DurableAgentStateEntry] = [] + for raw_entry in history_data: + if isinstance(raw_entry, dict): + entry_dict = cast(dict[str, Any], raw_entry) + entry_type = entry_dict.get("$type") or entry_dict.get("json_type") + if entry_type == DurableAgentStateEntryJsonType.RESPONSE: + deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) + elif entry_type == DurableAgentStateEntryJsonType.REQUEST: + deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict)) + else: + deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict)) + elif isinstance(raw_entry, DurableAgentStateEntry): + deserialized_history.append(raw_entry) + return deserialized_history + + +def _parse_contents(data: dict[str, Any]) -> list[DurableAgentStateContent]: + """Parse content items from a dictionary. + + Args: + data: Dictionary containing a 'contents' key with a list of content data + + Returns: + List of DurableAgentStateContent objects + """ + contents: list[DurableAgentStateContent] = [] + raw_contents: list[Any] = data.get("contents", []) + for raw_content in raw_contents: + if isinstance(raw_content, dict): + content_dict = cast(dict[str, Any], raw_content) + content_type: str | None = content_dict.get("$type") + if content_type == DurableAgentStateTextContent.type: + contents.append(DurableAgentStateTextContent(text=content_dict.get("text"))) + elif content_type == DurableAgentStateDataContent.type: + contents.append( + DurableAgentStateDataContent( + uri=str(content_dict.get("uri", "")), + media_type=content_dict.get("mediaType"), + ) + ) + elif content_type == DurableAgentStateErrorContent.type: + contents.append( + DurableAgentStateErrorContent( + message=content_dict.get("message"), + error_code=content_dict.get("errorCode"), + details=content_dict.get("details"), + ) + ) + elif content_type == DurableAgentStateFunctionCallContent.type: + contents.append( + DurableAgentStateFunctionCallContent( + call_id=str(content_dict.get("callId", "")), + name=str(content_dict.get("name", "")), + arguments=content_dict.get("arguments", {}), + ) + ) + elif content_type == DurableAgentStateFunctionResultContent.type: + contents.append( + DurableAgentStateFunctionResultContent( + call_id=str(content_dict.get("callId", "")), + result=content_dict.get("result"), + ) + ) + elif content_type == DurableAgentStateHostedFileContent.type: + contents.append(DurableAgentStateHostedFileContent(file_id=str(content_dict.get("fileId", "")))) + elif content_type == DurableAgentStateHostedVectorStoreContent.type: + contents.append( + DurableAgentStateHostedVectorStoreContent( + vector_store_id=str(content_dict.get("vectorStoreId", "")) + ) + ) + elif content_type == DurableAgentStateTextReasoningContent.type: + contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get("text"))) + elif content_type == DurableAgentStateUriContent.type: + contents.append( + DurableAgentStateUriContent( + uri=str(content_dict.get("uri", "")), + media_type=str(content_dict.get("mediaType", "")), + ) + ) + elif content_type == DurableAgentStateUsageContent.type: + usage_data = content_dict.get("usage") + if usage_data and isinstance(usage_data, dict): + contents.append( + DurableAgentStateUsageContent( + usage=DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_data)) + ) + ) + elif content_type == DurableAgentStateUnknownContent.type: + contents.append(DurableAgentStateUnknownContent(content=content_dict.get("content", {}))) + elif isinstance(raw_content, DurableAgentStateContent): + contents.append(raw_content) + return contents + + class DurableAgentStateContent: """Base class for all content types in durable agent state messages. @@ -197,25 +321,8 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data_dict: dict[str, Any]) -> DurableAgentStateData: - # Restore the conversation history - deserialize entries from dicts to objects - history_data = data_dict.get("conversationHistory", []) - deserialized_history: list[DurableAgentStateEntry] = [] - for entry_dict in history_data: - if isinstance(entry_dict, dict): - # Deserialize based on $type discriminator - entry_type = entry_dict.get("$type") or entry_dict.get("json_type") - if entry_type == DurableAgentStateEntryJsonType.RESPONSE: - deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) - elif entry_type == DurableAgentStateEntryJsonType.REQUEST: - deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict)) - else: - deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict)) - else: - # Already an object - deserialized_history.append(entry_dict) - return cls( - conversation_history=deserialized_history, + conversation_history=_parse_history_entries(data_dict), extension_data=data_dict.get("extensionData"), ) @@ -227,7 +334,7 @@ class DurableAgentState: in Azure Durable Entities. It maintains the conversation history as a sequence of request and response entries, each with their messages, timestamps, and metadata. - The state follows a versioned schema (currently 1.1.0) that defines the structure for: + The state follows a versioned schema (see SCHEMA_VERSION class constant) that defines the structure for: - Request entries: User/system messages with optional response format specifications - Response entries: Assistant messages with token usage information - Messages: Individual chat messages with role, content items, and timestamps @@ -235,7 +342,7 @@ class DurableAgentState: State is serialized to JSON with this structure: { - "schemaVersion": "1.1.0", + "schemaVersion": "", "data": { "conversationHistory": [ {"$type": "request", "correlationId": "...", "createdAt": "...", "messages": [...]}, @@ -246,17 +353,20 @@ class DurableAgentState: Attributes: data: Container for conversation history and optional extension data - schema_version: Schema version string (defaults to "1.1.0") + schema_version: Schema version string (defaults to SCHEMA_VERSION) """ + # Durable Agent Schema version + SCHEMA_VERSION: str = "1.1.0" + data: DurableAgentStateData - schema_version: str = "1.1.0" + schema_version: str = SCHEMA_VERSION - def __init__(self, schema_version: str = "1.1.0"): + def __init__(self, schema_version: str = SCHEMA_VERSION): """Initialize a new durable agent state. Args: - schema_version: Schema version to use (defaults to "1.1.0") + schema_version: Schema version to use (defaults to SCHEMA_VERSION) """ self.data = DurableAgentStateData() self.schema_version = schema_version @@ -325,7 +435,7 @@ def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse): # Found the entry, extract response data # Get the text content from assistant messages only - content = "\n".join(message.text for message in entry.messages if message.text is not None) + content = "\n".join(message.text for message in entry.messages if message.text) return {"content": content, "message_count": self.message_count, "correlationId": correlation_id} return None @@ -388,28 +498,17 @@ def __init__( self.extension_data = extension_data def to_dict(self) -> dict[str, Any]: - # Ensure createdAt is never null - created_at_value = self.created_at - if created_at_value is None: - created_at_value = datetime.now(tz=timezone.utc) - return { "$type": self.json_type, "correlationId": self.correlation_id, - "createdAt": created_at_value.isoformat() if isinstance(created_at_value, datetime) else created_at_value, + "createdAt": self.created_at.isoformat(), "messages": [m.to_dict() for m in self.messages], } @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateEntry: created_at = _parse_created_at(data.get("created_at")) - - messages = [] - for msg_dict in data.get("messages", []): - if isinstance(msg_dict, dict): - messages.append(DurableAgentStateMessage.from_dict(msg_dict)) - else: - messages.append(msg_dict) + messages = _parse_messages(data) return cls( json_type=DurableAgentStateEntryJsonType(data.get("$type", "entry")), @@ -475,13 +574,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: created_at = _parse_created_at(data.get("created_at")) - - messages = [] - for msg_dict in data.get("messages", []): - if isinstance(msg_dict, dict): - messages.append(DurableAgentStateMessage.from_dict(msg_dict)) - else: - messages.append(msg_dict) + messages = _parse_messages(data) return cls( correlation_id=data.get("correlationId", ""), @@ -553,20 +646,12 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateResponse: created_at = _parse_created_at(data.get("created_at")) - - messages = [] - for msg_dict in data.get("messages", []): - if isinstance(msg_dict, dict): - messages.append(DurableAgentStateMessage.from_dict(msg_dict)) - else: - messages.append(msg_dict) + messages = _parse_messages(data) usage_dict = data.get("usage") - usage = None + usage: DurableAgentStateUsage | None = None if usage_dict and isinstance(usage_dict, dict): - usage = DurableAgentStateUsage.from_dict(usage_dict) - elif usage_dict: - usage = usage_dict + usage = DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_dict)) return cls( correlation_id=data.get("correlationId", ""), @@ -647,68 +732,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: - contents: list[DurableAgentStateContent] = [] - for content_dict in data.get("contents", []): - if isinstance(content_dict, dict): - content_type = content_dict.get("$type") - if content_type == DurableAgentStateTextContent.type: - contents.append(DurableAgentStateTextContent(text=content_dict.get("text"))) - elif content_type == DurableAgentStateDataContent.type: - contents.append( - DurableAgentStateDataContent( - uri=content_dict.get("uri", ""), media_type=content_dict.get("mediaType") - ) - ) - elif content_type == DurableAgentStateErrorContent.type: - contents.append( - DurableAgentStateErrorContent( - message=content_dict.get("message"), - error_code=content_dict.get("errorCode"), - details=content_dict.get("details"), - ) - ) - elif content_type == DurableAgentStateFunctionCallContent.type: - contents.append( - DurableAgentStateFunctionCallContent( - call_id=content_dict.get("callId", ""), - name=content_dict.get("name", ""), - arguments=content_dict.get("arguments", {}), - ) - ) - elif content_type == DurableAgentStateFunctionResultContent.type: - contents.append( - DurableAgentStateFunctionResultContent( - call_id=content_dict.get("callId", ""), result=content_dict.get("result") - ) - ) - elif content_type == DurableAgentStateHostedFileContent.type: - contents.append(DurableAgentStateHostedFileContent(file_id=content_dict.get("fileId", ""))) - elif content_type == DurableAgentStateHostedVectorStoreContent.type: - contents.append( - DurableAgentStateHostedVectorStoreContent(vector_store_id=content_dict.get("vectorStoreId", "")) - ) - elif content_type == DurableAgentStateTextReasoningContent.type: - contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get("text"))) - elif content_type == DurableAgentStateUriContent.type: - contents.append( - DurableAgentStateUriContent( - uri=content_dict.get("uri", ""), media_type=content_dict.get("mediaType", "") - ) - ) - elif content_type == DurableAgentStateUsageContent.type: - usage_data = content_dict.get("usage") - if usage_data and isinstance(usage_data, dict): - contents.append( - DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_dict(usage_data)) - ) - elif content_type == DurableAgentStateUnknownContent.type: - contents.append(DurableAgentStateUnknownContent(content=content_dict.get("content", {}))) - else: - contents.append(content_dict) # type: ignore - return cls( role=data.get("role", ""), - contents=contents, + contents=_parse_contents(data), author_name=data.get("authorName"), created_at=_parse_created_at(data.get("createdAt")), extension_data=data.get("extensionData"), @@ -717,7 +743,7 @@ def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: @property def text(self) -> str: """Extract text from the contents list.""" - text_parts = [] + text_parts: list[str] = [] for content in self.contents: if isinstance(content, DurableAgentStateTextContent): text_parts.append(content.text or "") diff --git a/python/packages/azurefunctions/tests/test_entities.py b/python/packages/azurefunctions/tests/test_entities.py index ff5adbe746..07eef65e54 100644 --- a/python/packages/azurefunctions/tests/test_entities.py +++ b/python/packages/azurefunctions/tests/test_entities.py @@ -79,7 +79,7 @@ def test_init_creates_entity(self) -> None: 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 == "1.1.0" + assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION def test_init_stores_agent_reference(self) -> None: """Test that the agent reference is stored correctly.""" @@ -124,8 +124,7 @@ async def test_run_agent_executes_agent(self) -> None: # Verify agent.run was called mock_agent.run.assert_called_once() _, kwargs = mock_agent.run.call_args - sent_messages = kwargs.get("messages") - assert isinstance(sent_messages, list) + sent_messages: list[Any] = kwargs.get("messages") assert len(sent_messages) == 1 sent_message = sent_messages[0] assert isinstance(sent_message, ChatMessage) diff --git a/python/uv.lock b/python/uv.lock index 87081e2bc5..eba6d09b0d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -47,7 +47,7 @@ overrides = [ [[package]] name = "a2a-sdk" -version = "0.3.17" +version = "0.3.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -56,9 +56,9 @@ dependencies = [ { name = "protobuf", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/c1/695e724ca9fa88933625f694dd371257375d25b9567919d86eedf8530a05/a2a_sdk-0.3.17.tar.gz", hash = "sha256:c39bb5731d7386c323efe6b01d15fd82fb0e65d512d1b0caaa46ce180d4cd4df", size = 229014, upload-time = "2025-11-24T12:37:41.261Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/74/db61ee9d2663b291a7eec03bbc7685bec72b1ceb113001350766c03f20de/a2a_sdk-0.3.19.tar.gz", hash = "sha256:ecf526d1d7781228d8680292f913bad1099ba3335a7f0ea6811543c2bd3e601d", size = 229184, upload-time = "2025-11-25T13:48:05.185Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/75/d7254295a2073747d34aa4dba20fe6791f761781d718b88539bb3d4524c4/a2a_sdk-0.3.17-py3-none-any.whl", hash = "sha256:d3e04db524d6cfd087af0b1aede9cb790155ca8770b1d651a30df514dd2c056e", size = 141527, upload-time = "2025-11-24T12:37:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cd/14c1242d171b9739770be35223f1cbc1fb0244ebea2c704f8ae0d9e6abf7/a2a_sdk-0.3.19-py3-none-any.whl", hash = "sha256:314123f84524259313ec0cd9826a34bae5de769dea44b8eb9a0eca79b8935772", size = 141519, upload-time = "2025-11-25T13:48:02.622Z" }, ] [[package]] @@ -1799,7 +1799,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 = [ @@ -4583,7 +4583,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.4" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -4591,9 +4591,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [package.optional-dependencies] @@ -5107,7 +5107,7 @@ wheels = [ [[package]] name = "redisvl" -version = "0.12.0" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpath-ng", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -5120,9 +5120,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/ff/6b/10fe769e1102d99cd9e47633bacd01ab71fb416958e77469cc55f032f471/redisvl-0.12.0.tar.gz", hash = "sha256:205db9eb9639b78a9e479b012f6db64a12aa47129fdfaf3ad59623b5736e00d2", size = 683456, upload-time = "2025-11-21T23:20:57.218Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/24/417f7c171caa460e45b688ee94e67788bd63544a90c3fdc411f248fce795/redisvl-0.12.0-py3-none-any.whl", hash = "sha256:406695793681c1f46f61b6a1141a6b6f86261bf690caf0de00595c511700012d", size = 175071, upload-time = "2025-11-21T23:20:55.605Z" }, + { 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" }, ] [[package]] @@ -6303,28 +6303,28 @@ wheels = [ [[package]] name = "uv" -version = "0.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/08/3bf76403ea7c22feef634849137fab10b28ab5ba5bbf08a53390763d5448/uv-0.9.11.tar.gz", hash = "sha256:605a7a57f508aabd029fc0c5ef5c60a556f8c50d32e194f1a300a9f4e87f18d4", size = 3744387, upload-time = "2025-11-20T23:20:00.95Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/26/8f917e9faddd9cb49abcbc8c7dac5343b0f61d04c6ac36873d2a324fee1a/uv-0.9.11-py3-none-linux_armv6l.whl", hash = "sha256:803f85cf25ab7f1fca10fe2e40a1b9f5b1d48efc25efd6651ba3c9668db6a19e", size = 20787588, upload-time = "2025-11-20T23:18:53.738Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1f/eafd39c719ddee19fc25884f68c1a7e736c0fca63c1cbef925caf8ebd739/uv-0.9.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6a31b0bd4eaec59bf97816aefbcd75cae4fcc8875c4b19ef1846b7bff3d67c70", size = 19922144, upload-time = "2025-11-20T23:18:57.569Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f3/6b9fac39e5b65fa47dba872dcf171f1470490cd645343e8334f20f73885b/uv-0.9.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48548a23fb5a103b8955dfafff7d79d21112b8e25ce5ff25e3468dc541b20e83", size = 18380643, upload-time = "2025-11-20T23:19:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/9a/d4080e95950a4fc6fdf20d67b9a43ffb8e3d6d6b7c8dda460ae73ddbecd9/uv-0.9.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:cb680948e678590b5960744af2ecea6f2c0307dbb74ac44daf5c00e84ad8c09f", size = 20310262, upload-time = "2025-11-20T23:19:04.914Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b4/86d9c881bd6accf2b766f7193b50e9d5815f2b34806191d90ea24967965e/uv-0.9.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ef1982295e5aaf909a9668d6fb6abfc5089666c699f585a36f3a67f1a22916a", size = 20392988, upload-time = "2025-11-20T23:19:08.258Z" }, - { url = "https://files.pythonhosted.org/packages/a3/1d/6a227b7ca1829442c1419ba1db856d176b6e0861f9bf9355a8790a5d02b5/uv-0.9.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92ff773aa4193148019533c55382c2f9c661824bbf0c2e03f12aeefc800ede57", size = 21394892, upload-time = "2025-11-20T23:19:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/5a/8f/df45b8409923121de8c4081c9d6d8ba3273eaa450645e1e542d83179c7b5/uv-0.9.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70137a46675bbecf3a8b43d292a61767f1b944156af3d0f8d5986292bd86f6cf", size = 22987735, upload-time = "2025-11-20T23:19:16.27Z" }, - { url = "https://files.pythonhosted.org/packages/89/51/bbf3248a619c9f502d310a11362da5ed72c312d354fb8f9667c5aa3be9dd/uv-0.9.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5af9117bab6c4b3a1cacb0cddfb3cd540d0adfb13c7b8a9a318873cf2d07e52", size = 22617321, upload-time = "2025-11-20T23:19:20.1Z" }, - { url = "https://files.pythonhosted.org/packages/3f/cd/a158ec989c5433dc86ebd9fea800f2aed24255b84ab65b6d7407251e5e31/uv-0.9.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8cc86940d9b3a425575f25dc45247be2fb31f7fed7bf3394ae9daadd466e5b80", size = 21615712, upload-time = "2025-11-20T23:19:23.71Z" }, - { url = "https://files.pythonhosted.org/packages/73/da/2597becbc0fcbb59608d38fda5db79969e76dedf5b072f0e8564c8f0628b/uv-0.9.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e97906ca1b90dac91c23af20e282e2e37c8eb80c3721898733928a295f2defda", size = 21661022, upload-time = "2025-11-20T23:19:27.385Z" }, - { url = "https://files.pythonhosted.org/packages/52/66/9b8f3b3529b23c2a6f5b9612da70ea53117935ec999757b4f1d640f63d63/uv-0.9.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d901269e1db72abc974ba61d37be6e56532e104922329e0b553d9df07ba224be", size = 20440548, upload-time = "2025-11-20T23:19:31.051Z" }, - { url = "https://files.pythonhosted.org/packages/72/b2/683afdb83e96dd966eb7cf3688af56a1b826c8bc1e8182fb10ec35b3e391/uv-0.9.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8abfb7d4b136de3e92dd239ea9a51d4b7bbb970dc1b33bec84d08facf82b9a6e", size = 21493758, upload-time = "2025-11-20T23:19:34.688Z" }, - { url = "https://files.pythonhosted.org/packages/f4/00/99848bc9834aab104fa74aa1a60b1ca478dee824d2e4aacb15af85673572/uv-0.9.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:1f8afc13b3b94bce1e72514c598d41623387b2b61b68d7dbce9a01a0d8874860", size = 20332324, upload-time = "2025-11-20T23:19:38.376Z" }, - { url = "https://files.pythonhosted.org/packages/6c/94/8cfd1bb1cc5d768cb334f976ba2686c6327e4ac91c16b8469b284956d4d9/uv-0.9.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7d414cfa410f1850a244d87255f98d06ca61cc13d82f6413c4f03e9e0c9effc7", size = 20845062, upload-time = "2025-11-20T23:19:42.006Z" }, - { url = "https://files.pythonhosted.org/packages/a0/42/43f66bfc621464dabe9cfe3cbf69cddc36464da56ab786c94fc9ccf99cc7/uv-0.9.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:edc14143d0ba086a7da4b737a77746bb36bc00e3d26466f180ea99e3bf795171", size = 21857559, upload-time = "2025-11-20T23:19:46.026Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/bfd41bf087522601c724d712c3727aeb62f51b1f67c4ab86a078c3947525/uv-0.9.11-py3-none-win32.whl", hash = "sha256:af5fd91eecaa04b4799f553c726307200f45da844d5c7c5880d64db4debdd5dc", size = 19639246, upload-time = "2025-11-20T23:19:50.254Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2f/d51c02627de68a7ca5b82f0a5d61d753beee3fe696366d1a1c5d5e40cd58/uv-0.9.11-py3-none-win_amd64.whl", hash = "sha256:c65a024ad98547e32168f3a52360fe73ff39cd609a8fb9dd2509aac91483cfc8", size = 21626822, upload-time = "2025-11-20T23:19:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/af/d8/e07e866ee328d3c9f27a6d57a018d8330f47be95ef4654a178779c968a66/uv-0.9.11-py3-none-win_arm64.whl", hash = "sha256:4907a696c745703542ed2559bdf5380b92c8b1d4bf290ebfed45bf9a2a2c6690", size = 20046856, upload-time = "2025-11-20T23:19:58.517Z" }, +version = "0.9.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/10/ad3dc22d0cabe7c335a1d7fc079ceda73236c0984da8d8446de3d2d30c9b/uv-0.9.13.tar.gz", hash = "sha256:105a6f4ff91480425d1b61917e89ac5635b8e58a79267e2be103338ab448ccd6", size = 3761269, upload-time = "2025-11-26T16:17:30.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/ae/94ec7111b006bc7212bf727907a35510a37928c15302ecc3757cfd7d6d7f/uv-0.9.13-py3-none-linux_armv6l.whl", hash = "sha256:7be41bdeb82c246f8ef1421cf4d1dd6ab3e5f46e4235eb22c8f5bf095debc069", size = 20830010, upload-time = "2025-11-26T16:17:13.147Z" }, + { url = "https://files.pythonhosted.org/packages/8a/53/5eb0eb0ca7ed41c10447d6c859b4d81efc5b76de14d01fd900af7d7bd1be/uv-0.9.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1d4c624bb2b81f885b7182d99ebdd5c2842219d2ac355626a4a2b6c1e3e6f8c1", size = 19961915, upload-time = "2025-11-26T16:17:15.587Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d1/0f0c8dc2125709a8e072b73e5e89da9f016d492ca88b909b23b3006c2b51/uv-0.9.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:318d0b9a39fa26f95a428a551d44cbefdfd58178954a831669248a42f39d3c75", size = 18426731, upload-time = "2025-11-26T16:17:31.855Z" }, + { url = "https://files.pythonhosted.org/packages/36/ee/f9db8cb69d584b8326b3e0e60e5a639469cdebac76e7f4ff5ba7c2c6fe6c/uv-0.9.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6a641ed7bcc8d317d22a7cb1ad0dfa41078c8e392f6f9248b11451abff0ccf50", size = 20315156, upload-time = "2025-11-26T16:17:08.125Z" }, + { url = "https://files.pythonhosted.org/packages/8a/49/045bbfe264fc1add3e238e0e11dec62725c931946dbcda3780d15ca3591b/uv-0.9.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e797ae9d283ee129f33157d84742607547939ca243d7a8c17710dc857a7808bd", size = 20430487, upload-time = "2025-11-26T16:17:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/18a14dbaedfd2492de5cca50b46a238d5199e9f0291f027f63a03f2ebdd4/uv-0.9.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48fa9cf568c481c150f957a2f9285d1c3ad2c1d50c904b03bcebd5c9669c5668", size = 21378284, upload-time = "2025-11-26T16:16:48.696Z" }, + { url = "https://files.pythonhosted.org/packages/08/04/d0fc5fb25e3f90740913b1c028e1556515e4e1fea91e1f58e7c18c1712a3/uv-0.9.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a66817a416c1c79303fd5e40c319ed9c8e59b46fb04cf3eac4447e95b9ec8763", size = 23016232, upload-time = "2025-11-26T16:16:46.149Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bc/cef461a47cddeb99c2a3b31f3946d38cbca7923b0f2fb6666756ba63a84a/uv-0.9.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05eb7e941c54666e8c52519a79ff46d15b5206967645652d3dfb2901fd982493", size = 22657140, upload-time = "2025-11-26T16:17:03.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/0f/5c9de65279480b1922c51aae409bbfa1d90ff108f8b81688022499f2c3e2/uv-0.9.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fe5ac5b0a98a876da8f4c08e03217589a89ea96883cfdc9c4b397bf381ef7b9", size = 21644453, upload-time = "2025-11-26T16:16:43.228Z" }, + { url = "https://files.pythonhosted.org/packages/da/e5/148ab5edb339f5833d04f0bcb8380a53e8b19bd5f091ae67222ed188b393/uv-0.9.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6627d0abbaf58f9ff6e07e3f8522d65421230969aa2d7b10421339f0cb30dec4", size = 21655007, upload-time = "2025-11-26T16:16:51.36Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/a77587e4608af6efc5a72d3a937573eb5d08052550a3f248821b50898626/uv-0.9.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6cca7671efacf6e2950eb86273ecce4a9a3f8bfa6ac04e8a17be9499bb3bb882", size = 20448163, upload-time = "2025-11-26T16:16:53.768Z" }, + { url = "https://files.pythonhosted.org/packages/81/ad/e3bb28d175f22edf1779a81b76910e842dcde012859556b28e9f4b630f26/uv-0.9.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2e00a4f8404000e86074d7d2fe5734078126a65aefed1e9a39f10c390c4c15dc", size = 21477072, upload-time = "2025-11-26T16:16:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/32/b6/9231365ab2495107a9e23aa36bb5400a4b697baaa0e5367f009072e88752/uv-0.9.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:3a4c16906e78f148c295a2e4f2414b843326a0f48ae68f7742149fd2d5dafbf7", size = 20421263, upload-time = "2025-11-26T16:17:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/8c/83/d83eeee9cea21b9a9e053d4a2ec752a3b872e22116851317da04681cc27e/uv-0.9.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f254cb60576a3ae17f8824381f0554120b46e2d31a1c06fc61432c55d976892d", size = 20855418, upload-time = "2025-11-26T16:17:05.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/88/70102f374cfbbb284c6fe385e35978bff25a70b8e6afa871886af8963595/uv-0.9.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d50cea327b994786866b2d12c073097b9c8d883d42f0c0408b2d968492f571a4", size = 21871073, upload-time = "2025-11-26T16:17:00.213Z" }, + { url = "https://files.pythonhosted.org/packages/01/05/00c90367db0c81379c9d2b1fb458a09a0704ecd89821c071cb0d8a917752/uv-0.9.13-py3-none-win32.whl", hash = "sha256:a80296b1feb61bac36aee23ea79be33cd9aa545236d0780fbffaac113a17a090", size = 19607949, upload-time = "2025-11-26T16:17:23.337Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e0/718b433acf811388e309936524be5786b8e0cc8ff23128f9cc29a34c075b/uv-0.9.13-py3-none-win_amd64.whl", hash = "sha256:5732cd0fe09365fa5ad2c0a2d0c007bb152a2aa3c48e79f570eec13fc235d59d", size = 21722341, upload-time = "2025-11-26T16:17:20.764Z" }, + { url = "https://files.pythonhosted.org/packages/9f/31/142457b7c9d5edcdd8d4853c740c397ec83e3688b69d0ef55da60f7ab5b5/uv-0.9.13-py3-none-win_arm64.whl", hash = "sha256:edfc3d53b6adefae766a67672e533d7282431f0deb2570186d1c3dd0d0e3c0a3", size = 20042030, upload-time = "2025-11-26T16:17:18.058Z" }, ] [[package]] From 3e3fb559e667794db72c51698dbd93155531ca54 Mon Sep 17 00:00:00 2001 From: Laveesh Rohra Date: Wed, 26 Nov 2025 13:52:05 -0800 Subject: [PATCH 4/5] Make constants for fields --- .../_constants.py | 122 ++++++++- .../_durable_agent_state.py | 255 ++++++++++-------- .../host.json | 8 +- 3 files changed, 265 insertions(+), 120 deletions(-) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py index 8c4cded196..111ebb4773 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py @@ -1,6 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. -"""Constants for Azure Functions Agent Framework integration.""" +"""Constants for Azure Functions Agent Framework integration. + +This module contains: +- Runtime configuration constants (polling, MIME types, headers) +- JSON field name mappings for camelCase (JSON) ↔ snake_case (Python) serialization + +For serialization constants, use the Fields, ContentTypes, and EntryTypes classes +to ensure consistent field naming between to_dict() and from_dict() methods. +""" + +from typing import Final # Supported request/response formats and MIME types REQUEST_RESPONSE_FORMAT_JSON: str = "json" @@ -17,3 +27,113 @@ # Polling configuration DEFAULT_MAX_POLL_RETRIES: int = 30 DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0 + + +# ============================================================================= +# JSON Field Name Constants for Durable Agent State Serialization +# ============================================================================= +# These constants ensure consistent camelCase field names in JSON serialization. +# Use these in both to_dict() and from_dict() methods to prevent mismatches. + + +class Fields: + """JSON field name constants for durable agent state serialization. + + All field names are in camelCase to match the JSON schema. + Use these constants in both to_dict() and from_dict() methods. + """ + + # Schema-level fields + SCHEMA_VERSION: Final[str] = "schemaVersion" + DATA: Final[str] = "data" + + # Entry discriminator + TYPE: Final[str] = "$type" + + # Legacy/internal field names (for backward compatibility) + JSON_TYPE_LEGACY: Final[str] = "json_type" # Legacy field name for entry type + TYPE_INTERNAL: Final[str] = "type" # Internal key in content dicts + + # Common entry fields + CORRELATION_ID: Final[str] = "correlationId" + CREATED_AT: Final[str] = "createdAt" + MESSAGES: Final[str] = "messages" + EXTENSION_DATA: Final[str] = "extensionData" + + # Request-specific fields + RESPONSE_TYPE: Final[str] = "responseType" + RESPONSE_SCHEMA: Final[str] = "responseSchema" + ORCHESTRATION_ID: Final[str] = "orchestrationId" + + # Response-specific fields + USAGE: Final[str] = "usage" + + # Message fields + ROLE: Final[str] = "role" + CONTENTS: Final[str] = "contents" + AUTHOR_NAME: Final[str] = "authorName" + + # Content fields + TEXT: Final[str] = "text" + URI: Final[str] = "uri" + MEDIA_TYPE: Final[str] = "mediaType" + MESSAGE: Final[str] = "message" + ERROR_CODE: Final[str] = "errorCode" + DETAILS: Final[str] = "details" + CALL_ID: Final[str] = "callId" + NAME: Final[str] = "name" + ARGUMENTS: Final[str] = "arguments" + RESULT: Final[str] = "result" + FILE_ID: Final[str] = "fileId" + VECTOR_STORE_ID: Final[str] = "vectorStoreId" + CONTENT: Final[str] = "content" + + # Usage fields (noqa: S105 - these are JSON field names, not passwords) + INPUT_TOKEN_COUNT: Final[str] = "inputTokenCount" # noqa: S105 + OUTPUT_TOKEN_COUNT: Final[str] = "outputTokenCount" # noqa: S105 + TOTAL_TOKEN_COUNT: Final[str] = "totalTokenCount" # noqa: S105 + + # History field + CONVERSATION_HISTORY: Final[str] = "conversationHistory" + + +class ContentTypes: + """Content type discriminator values for the $type field. + + These values are used in the JSON $type field to identify content types. + """ + + TEXT: Final[str] = "text" + DATA: Final[str] = "data" + ERROR: Final[str] = "error" + FUNCTION_CALL: Final[str] = "functionCall" + FUNCTION_RESULT: Final[str] = "functionResult" + HOSTED_FILE: Final[str] = "hostedFile" + HOSTED_VECTOR_STORE: Final[str] = "hostedVectorStore" + REASONING: Final[str] = "reasoning" + URI: Final[str] = "uri" + USAGE: Final[str] = "usage" + UNKNOWN: Final[str] = "unknown" + + +class ApiResponseFields: + """Field names for HTTP API responses (not part of persisted schema). + + These are used in try_get_agent_response() for backward compatibility + with the HTTP API response format. + """ + + CONTENT: Final[str] = "content" + MESSAGE_COUNT: Final[str] = "message_count" + CORRELATION_ID: Final[str] = "correlationId" + + +class Defaults: + """Default values and fallback constants.""" + + SCHEMA_VERSION_FALLBACK: Final[str] = "1.0.0" + ENTRY_TYPE_FALLBACK: Final[str] = "entry" + + +# Note: Entry types (request/response) are defined as DurableAgentStateEntryJsonType enum +# in _durable_agent_state.py for better type safety and exhaustiveness checking. diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py index e0287b128a..4808cc35a2 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py @@ -53,11 +53,22 @@ ) from dateutil import parser as date_parser +from ._constants import ApiResponseFields, ContentTypes, Defaults, Fields from ._models import RunRequest, serialize_response_format logger = get_logger("agent_framework.azurefunctions.durable_agent_state") +class DurableAgentStateEntryJsonType(str, Enum): + """Enum for conversation history entry types. + + Discriminator values for the $type field in DurableAgentStateEntry objects. + """ + + REQUEST = "request" + RESPONSE = "response" + + def _parse_created_at(value: Any) -> datetime: """Normalize created_at values coming from persisted durable state.""" if isinstance(value, datetime): @@ -84,7 +95,7 @@ def _parse_messages(data: dict[str, Any]) -> list[DurableAgentStateMessage]: List of DurableAgentStateMessage objects """ messages: list[DurableAgentStateMessage] = [] - raw_messages: list[Any] = data.get("messages", []) + raw_messages: list[Any] = data.get(Fields.MESSAGES, []) for raw_msg in raw_messages: if isinstance(raw_msg, dict): messages.append(DurableAgentStateMessage.from_dict(cast(dict[str, Any], raw_msg))) @@ -102,12 +113,12 @@ def _parse_history_entries(data_dict: dict[str, Any]) -> list[DurableAgentStateE Returns: List of DurableAgentStateEntry objects (requests and responses) """ - history_data: list[Any] = data_dict.get("conversationHistory", []) + history_data: list[Any] = data_dict.get(Fields.CONVERSATION_HISTORY, []) deserialized_history: list[DurableAgentStateEntry] = [] for raw_entry in history_data: if isinstance(raw_entry, dict): entry_dict = cast(dict[str, Any], raw_entry) - entry_type = entry_dict.get("$type") or entry_dict.get("json_type") + entry_type = entry_dict.get(Fields.TYPE) or entry_dict.get(Fields.JSON_TYPE_LEGACY) if entry_type == DurableAgentStateEntryJsonType.RESPONSE: deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) elif entry_type == DurableAgentStateEntryJsonType.REQUEST: @@ -129,70 +140,70 @@ def _parse_contents(data: dict[str, Any]) -> list[DurableAgentStateContent]: List of DurableAgentStateContent objects """ contents: list[DurableAgentStateContent] = [] - raw_contents: list[Any] = data.get("contents", []) + raw_contents: list[Any] = data.get(Fields.CONTENTS, []) for raw_content in raw_contents: if isinstance(raw_content, dict): content_dict = cast(dict[str, Any], raw_content) - content_type: str | None = content_dict.get("$type") - if content_type == DurableAgentStateTextContent.type: - contents.append(DurableAgentStateTextContent(text=content_dict.get("text"))) - elif content_type == DurableAgentStateDataContent.type: + content_type: str | None = content_dict.get(Fields.TYPE) + if content_type == ContentTypes.TEXT: + contents.append(DurableAgentStateTextContent(text=content_dict.get(Fields.TEXT))) + elif content_type == ContentTypes.DATA: contents.append( DurableAgentStateDataContent( - uri=str(content_dict.get("uri", "")), - media_type=content_dict.get("mediaType"), + uri=str(content_dict.get(Fields.URI, "")), + media_type=content_dict.get(Fields.MEDIA_TYPE), ) ) - elif content_type == DurableAgentStateErrorContent.type: + elif content_type == ContentTypes.ERROR: contents.append( DurableAgentStateErrorContent( - message=content_dict.get("message"), - error_code=content_dict.get("errorCode"), - details=content_dict.get("details"), + message=content_dict.get(Fields.MESSAGE), + error_code=content_dict.get(Fields.ERROR_CODE), + details=content_dict.get(Fields.DETAILS), ) ) - elif content_type == DurableAgentStateFunctionCallContent.type: + elif content_type == ContentTypes.FUNCTION_CALL: contents.append( DurableAgentStateFunctionCallContent( - call_id=str(content_dict.get("callId", "")), - name=str(content_dict.get("name", "")), - arguments=content_dict.get("arguments", {}), + call_id=str(content_dict.get(Fields.CALL_ID, "")), + name=str(content_dict.get(Fields.NAME, "")), + arguments=content_dict.get(Fields.ARGUMENTS, {}), ) ) - elif content_type == DurableAgentStateFunctionResultContent.type: + elif content_type == ContentTypes.FUNCTION_RESULT: contents.append( DurableAgentStateFunctionResultContent( - call_id=str(content_dict.get("callId", "")), - result=content_dict.get("result"), + call_id=str(content_dict.get(Fields.CALL_ID, "")), + result=content_dict.get(Fields.RESULT), ) ) - elif content_type == DurableAgentStateHostedFileContent.type: - contents.append(DurableAgentStateHostedFileContent(file_id=str(content_dict.get("fileId", "")))) - elif content_type == DurableAgentStateHostedVectorStoreContent.type: + elif content_type == ContentTypes.HOSTED_FILE: + contents.append(DurableAgentStateHostedFileContent(file_id=str(content_dict.get(Fields.FILE_ID, "")))) + elif content_type == ContentTypes.HOSTED_VECTOR_STORE: contents.append( DurableAgentStateHostedVectorStoreContent( - vector_store_id=str(content_dict.get("vectorStoreId", "")) + vector_store_id=str(content_dict.get(Fields.VECTOR_STORE_ID, "")) ) ) - elif content_type == DurableAgentStateTextReasoningContent.type: - contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get("text"))) - elif content_type == DurableAgentStateUriContent.type: + elif content_type == ContentTypes.REASONING: + contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get(Fields.TEXT))) + elif content_type == ContentTypes.URI: contents.append( DurableAgentStateUriContent( - uri=str(content_dict.get("uri", "")), - media_type=str(content_dict.get("mediaType", "")), + uri=str(content_dict.get(Fields.URI, "")), + media_type=str(content_dict.get(Fields.MEDIA_TYPE, "")), ) ) - elif content_type == DurableAgentStateUsageContent.type: - usage_data = content_dict.get("usage") + elif content_type == ContentTypes.USAGE: + usage_data = content_dict.get(Fields.USAGE) if usage_data and isinstance(usage_data, dict): contents.append( DurableAgentStateUsageContent( usage=DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_data)) ) ) - elif content_type == DurableAgentStateUnknownContent.type: - contents.append(DurableAgentStateUnknownContent(content=content_dict.get("content", {}))) + elif content_type == ContentTypes.UNKNOWN: + contents.append(DurableAgentStateUnknownContent(content=content_dict.get(Fields.CONTENT, {}))) elif isinstance(raw_content, DurableAgentStateContent): contents.append(raw_content) return contents @@ -313,17 +324,17 @@ def __init__( def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = { - "conversationHistory": [entry.to_dict() for entry in self.conversation_history], + Fields.CONVERSATION_HISTORY: [entry.to_dict() for entry in self.conversation_history], } if self.extension_data is not None: - result["extensionData"] = self.extension_data + result[Fields.EXTENSION_DATA] = self.extension_data return result @classmethod def from_dict(cls, data_dict: dict[str, Any]) -> DurableAgentStateData: return cls( conversation_history=_parse_history_entries(data_dict), - extension_data=data_dict.get("extensionData"), + extension_data=data_dict.get(Fields.EXTENSION_DATA), ) @@ -374,8 +385,8 @@ def __init__(self, schema_version: str = SCHEMA_VERSION): def to_dict(self) -> dict[str, Any]: return { - "schemaVersion": self.schema_version, - "data": self.data.to_dict(), + Fields.SCHEMA_VERSION: self.schema_version, + Fields.DATA: self.data.to_dict(), } def to_json(self) -> str: @@ -388,13 +399,13 @@ def from_dict(cls, state: dict[str, Any]) -> DurableAgentState: Args: state: Dictionary containing schemaVersion and data (full state structure) """ - schema_version = state.get("schemaVersion") + schema_version = state.get(Fields.SCHEMA_VERSION) if schema_version is None: logger.warning("Resetting state as it is incompatible with the current schema, all history will be lost") return cls() - instance = cls(schema_version=state.get("schemaVersion", "1.0.0")) - instance.data = DurableAgentStateData.from_dict(state.get("data", {})) + instance = cls(schema_version=state.get(Fields.SCHEMA_VERSION, Defaults.SCHEMA_VERSION_FALLBACK)) + instance.data = DurableAgentStateData.from_dict(state.get(Fields.DATA, {})) return instance @@ -437,20 +448,14 @@ def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: # Get the text content from assistant messages only content = "\n".join(message.text for message in entry.messages if message.text) - return {"content": content, "message_count": self.message_count, "correlationId": correlation_id} + return { + ApiResponseFields.CONTENT: content, + ApiResponseFields.MESSAGE_COUNT: self.message_count, + ApiResponseFields.CORRELATION_ID: correlation_id, + } return None -class DurableAgentStateEntryJsonType(str, Enum): - """Enum for conversation history entry types. - - Discriminator values for the $type field in DurableAgentStateEntry objects. - """ - - REQUEST = "request" - RESPONSE = "response" - - class DurableAgentStateEntry: """Base class for conversation history entries (requests and responses). @@ -499,23 +504,23 @@ def __init__( def to_dict(self) -> dict[str, Any]: return { - "$type": self.json_type, - "correlationId": self.correlation_id, - "createdAt": self.created_at.isoformat(), - "messages": [m.to_dict() for m in self.messages], + Fields.TYPE: self.json_type, + Fields.CORRELATION_ID: self.correlation_id, + Fields.CREATED_AT: self.created_at.isoformat(), + Fields.MESSAGES: [m.to_dict() for m in self.messages], } @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateEntry: - created_at = _parse_created_at(data.get("created_at")) + created_at = _parse_created_at(data.get(Fields.CREATED_AT)) messages = _parse_messages(data) return cls( - json_type=DurableAgentStateEntryJsonType(data.get("$type", "entry")), - correlation_id=data.get("correlationId", ""), + json_type=DurableAgentStateEntryJsonType(data.get(Fields.TYPE, Defaults.ENTRY_TYPE_FALLBACK)), + correlation_id=data.get(Fields.CORRELATION_ID, ""), created_at=created_at, messages=messages, - extension_data=data.get("extensionData"), + extension_data=data.get(Fields.EXTENSION_DATA), ) @@ -564,26 +569,26 @@ def __init__( def to_dict(self) -> dict[str, Any]: data = super().to_dict() if self.orchestration_id is not None: - data["orchestrationId"] = self.orchestration_id + data[Fields.ORCHESTRATION_ID] = self.orchestration_id if self.response_type is not None: - data["responseType"] = self.response_type + data[Fields.RESPONSE_TYPE] = self.response_type if self.response_schema is not None: - data["responseSchema"] = self.response_schema + data[Fields.RESPONSE_SCHEMA] = self.response_schema return data @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: - created_at = _parse_created_at(data.get("created_at")) + created_at = _parse_created_at(data.get(Fields.CREATED_AT)) messages = _parse_messages(data) return cls( - correlation_id=data.get("correlationId", ""), + correlation_id=data.get(Fields.CORRELATION_ID, ""), created_at=created_at, messages=messages, - extension_data=data.get("extensionData"), - response_type=data.get("responseType"), - response_schema=data.get("responseSchema"), - orchestration_id=data.get("orchestrationId"), + extension_data=data.get(Fields.EXTENSION_DATA), + response_type=data.get(Fields.RESPONSE_TYPE), + response_schema=data.get(Fields.RESPONSE_SCHEMA), + orchestration_id=data.get(Fields.ORCHESTRATION_ID), ) @staticmethod @@ -640,24 +645,24 @@ def __init__( def to_dict(self) -> dict[str, Any]: data = super().to_dict() if self.usage is not None: - data["usage"] = self.usage.to_dict() + data[Fields.USAGE] = self.usage.to_dict() return data @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateResponse: - created_at = _parse_created_at(data.get("created_at")) + created_at = _parse_created_at(data.get(Fields.CREATED_AT)) messages = _parse_messages(data) - usage_dict = data.get("usage") + usage_dict = data.get(Fields.USAGE) usage: DurableAgentStateUsage | None = None if usage_dict and isinstance(usage_dict, dict): usage = DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_dict)) return cls( - correlation_id=data.get("correlationId", ""), + correlation_id=data.get(Fields.CORRELATION_ID, ""), created_at=created_at, messages=messages, - extension_data=data.get("extensionData"), + extension_data=data.get(Fields.EXTENSION_DATA), usage=usage, ) @@ -717,27 +722,30 @@ def __init__( def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = { - "role": self.role, - "contents": [ - {"$type": c.to_dict().get("type", "text"), **{k: v for k, v in c.to_dict().items() if k != "type"}} + Fields.ROLE: self.role, + Fields.CONTENTS: [ + { + Fields.TYPE: c.to_dict().get(Fields.TYPE_INTERNAL, ContentTypes.TEXT), + **{k: v for k, v in c.to_dict().items() if k != Fields.TYPE_INTERNAL}, + } for c in self.contents ], } # Only include optional fields if they have values if self.created_at is not None: - result["createdAt"] = self.created_at.isoformat() + result[Fields.CREATED_AT] = self.created_at.isoformat() if self.author_name is not None: - result["authorName"] = self.author_name + result[Fields.AUTHOR_NAME] = self.author_name return result @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: return cls( - role=data.get("role", ""), + role=data.get(Fields.ROLE, ""), contents=_parse_contents(data), - author_name=data.get("authorName"), - created_at=_parse_created_at(data.get("createdAt")), - extension_data=data.get("extensionData"), + author_name=data.get(Fields.AUTHOR_NAME), + created_at=_parse_created_at(data.get(Fields.CREATED_AT)), + extension_data=data.get(Fields.EXTENSION_DATA), ) @property @@ -823,14 +831,14 @@ class DurableAgentStateDataContent(DurableAgentStateContent): uri: str = "" media_type: str | None = None - type: str = "data" + type: str = ContentTypes.DATA def __init__(self, uri: str, media_type: str | None = None) -> None: self.uri = uri self.media_type = media_type def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "uri": self.uri, "mediaType": self.media_type} + return {Fields.TYPE: self.type, Fields.URI: self.uri, Fields.MEDIA_TYPE: self.media_type} @staticmethod def from_data_content(content: DataContent) -> DurableAgentStateDataContent: @@ -856,7 +864,7 @@ class DurableAgentStateErrorContent(DurableAgentStateContent): error_code: str | None = None details: str | None = None - type: str = "error" + type: str = ContentTypes.ERROR def __init__(self, message: str | None = None, error_code: str | None = None, details: str | None = None) -> None: self.message = message @@ -864,7 +872,12 @@ def __init__(self, message: str | None = None, error_code: str | None = None, de self.details = details def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "message": self.message, "errorCode": self.error_code, "details": self.details} + return { + Fields.TYPE: self.type, + Fields.MESSAGE: self.message, + Fields.ERROR_CODE: self.error_code, + Fields.DETAILS: self.details, + } @staticmethod def from_error_content(content: ErrorContent) -> DurableAgentStateErrorContent: @@ -893,7 +906,7 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent): name: str arguments: dict[str, Any] - type: str = "functionCall" + type: str = ContentTypes.FUNCTION_CALL def __init__(self, call_id: str, name: str, arguments: dict[str, Any]) -> None: self.call_id = call_id @@ -901,7 +914,12 @@ def __init__(self, call_id: str, name: str, arguments: dict[str, Any]) -> None: self.arguments = arguments def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "callId": self.call_id, "name": self.name, "arguments": self.arguments} + return { + Fields.TYPE: self.type, + Fields.CALL_ID: self.call_id, + Fields.NAME: self.name, + Fields.ARGUMENTS: self.arguments, + } @staticmethod def from_function_call_content(content: FunctionCallContent) -> DurableAgentStateFunctionCallContent: @@ -938,14 +956,14 @@ class DurableAgentStateFunctionResultContent(DurableAgentStateContent): call_id: str result: object | None = None - type: str = "functionResult" + type: str = ContentTypes.FUNCTION_RESULT def __init__(self, call_id: str, result: Any | None = None) -> None: self.call_id = call_id self.result = result def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "callId": self.call_id, "result": self.result} + return {Fields.TYPE: self.type, Fields.CALL_ID: self.call_id, Fields.RESULT: self.result} @staticmethod def from_function_result_content(content: FunctionResultContent) -> DurableAgentStateFunctionResultContent: @@ -967,13 +985,13 @@ class DurableAgentStateHostedFileContent(DurableAgentStateContent): file_id: str - type: str = "hostedFile" + type: str = ContentTypes.HOSTED_FILE def __init__(self, file_id: str) -> None: self.file_id = file_id def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "fileId": self.file_id} + return {Fields.TYPE: self.type, Fields.FILE_ID: self.file_id} @staticmethod def from_hosted_file_content(content: HostedFileContent) -> DurableAgentStateHostedFileContent: @@ -996,13 +1014,13 @@ class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent): vector_store_id: str - type: str = "hostedVectorStore" + type: str = ContentTypes.HOSTED_VECTOR_STORE def __init__(self, vector_store_id: str) -> None: self.vector_store_id = vector_store_id def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "vectorStoreId": self.vector_store_id} + return {Fields.TYPE: self.type, Fields.VECTOR_STORE_ID: self.vector_store_id} @staticmethod def from_hosted_vector_store_content( @@ -1024,13 +1042,13 @@ class DurableAgentStateTextContent(DurableAgentStateContent): text: The text content of the message """ - type: str = "text" + type: str = ContentTypes.TEXT def __init__(self, text: str | None) -> None: self.text = text def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "text": self.text} + return {Fields.TYPE: self.type, Fields.TEXT: self.text} @staticmethod def from_text_content(content: TextContent) -> DurableAgentStateTextContent: @@ -1050,13 +1068,13 @@ class DurableAgentStateTextReasoningContent(DurableAgentStateContent): text: The reasoning or thought process text """ - type: str = "reasoning" + type: str = ContentTypes.REASONING def __init__(self, text: str | None) -> None: self.text = text def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "text": self.text} + return {Fields.TYPE: self.type, Fields.TEXT: self.text} @staticmethod def from_text_reasoning_content(content: TextReasoningContent) -> DurableAgentStateTextReasoningContent: @@ -1080,14 +1098,14 @@ class DurableAgentStateUriContent(DurableAgentStateContent): uri: str media_type: str - type: str = "uri" + type: str = ContentTypes.URI def __init__(self, uri: str, media_type: str) -> None: self.uri = uri self.media_type = media_type def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "uri": self.uri, "mediaType": self.media_type} + return {Fields.TYPE: self.type, Fields.URI: self.uri, Fields.MEDIA_TYPE: self.media_type} @staticmethod def from_uri_content(content: UriContent) -> DurableAgentStateUriContent: @@ -1130,21 +1148,21 @@ def __init__( def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = { - "inputTokenCount": self.input_token_count, - "outputTokenCount": self.output_token_count, - "totalTokenCount": self.total_token_count, + Fields.INPUT_TOKEN_COUNT: self.input_token_count, + Fields.OUTPUT_TOKEN_COUNT: self.output_token_count, + Fields.TOTAL_TOKEN_COUNT: self.total_token_count, } if self.extensionData is not None: - result["extensionData"] = self.extensionData + result[Fields.EXTENSION_DATA] = self.extensionData return result @classmethod def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateUsage: return cls( - input_token_count=data.get("inputTokenCount"), - output_token_count=data.get("outputTokenCount"), - total_token_count=data.get("totalTokenCount"), - extensionData=data.get("extensionData"), + input_token_count=data.get(Fields.INPUT_TOKEN_COUNT), + output_token_count=data.get(Fields.OUTPUT_TOKEN_COUNT), + total_token_count=data.get(Fields.TOTAL_TOKEN_COUNT), + extensionData=data.get(Fields.EXTENSION_DATA), ) @staticmethod @@ -1179,17 +1197,20 @@ class DurableAgentStateUsageContent(DurableAgentStateContent): usage: DurableAgentStateUsage = DurableAgentStateUsage() - type: str = "usage" + type: str = ContentTypes.USAGE - def __init__(self, usage: DurableAgentStateUsage) -> None: - self.usage = usage + def __init__(self, usage: DurableAgentStateUsage | None) -> None: + self.usage = usage if usage is not None else DurableAgentStateUsage() def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "usage": self.usage.to_dict() if hasattr(self.usage, "to_dict") else self.usage} + return { + Fields.TYPE: self.type, + Fields.USAGE: self.usage.to_dict(), + } @staticmethod def from_usage_content(content: UsageContent) -> DurableAgentStateUsageContent: - return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.details)) # type: ignore + return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.details)) def to_ai_content(self) -> UsageContent: return UsageContent(details=self.usage.to_usage_details()) @@ -1208,13 +1229,13 @@ class DurableAgentStateUnknownContent(DurableAgentStateContent): content: Any - type: str = "unknown" + type: str = ContentTypes.UNKNOWN def __init__(self, content: Any) -> None: self.content = content def to_dict(self) -> dict[str, Any]: - return {"$type": self.type, "content": self.content} + return {Fields.TYPE: self.type, Fields.CONTENT: self.content} @staticmethod def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent: diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json b/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json index 9e7fd873dd..66485988da 100644 --- a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json +++ b/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json @@ -1,12 +1,16 @@ { "version": "2.0", "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle", + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", "version": "[4.*, 5.0.0)" }, "extensions": { "durableTask": { - "hubName": "%TASKHUB_NAME%" + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } } } } From 1655f577787af9585db8db201727e32a7773401e Mon Sep 17 00:00:00 2001 From: Laveesh Rohra Date: Mon, 1 Dec 2025 17:11:54 -0800 Subject: [PATCH 5/5] Use pydantic models for serialization --- .../_constants.py | 106 +- .../_durable_agent_state.py | 975 +++--------------- .../tests/test_orchestration.py | 5 +- .../host.json | 8 +- 4 files changed, 139 insertions(+), 955 deletions(-) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py index 111ebb4773..105566f6c4 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_constants.py @@ -2,12 +2,8 @@ """Constants for Azure Functions Agent Framework integration. -This module contains: -- Runtime configuration constants (polling, MIME types, headers) -- JSON field name mappings for camelCase (JSON) ↔ snake_case (Python) serialization - -For serialization constants, use the Fields, ContentTypes, and EntryTypes classes -to ensure consistent field naming between to_dict() and from_dict() methods. +This module contains runtime configuration constants (polling, MIME types, headers) +and API response field names. """ from typing import Final @@ -29,93 +25,6 @@ DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0 -# ============================================================================= -# JSON Field Name Constants for Durable Agent State Serialization -# ============================================================================= -# These constants ensure consistent camelCase field names in JSON serialization. -# Use these in both to_dict() and from_dict() methods to prevent mismatches. - - -class Fields: - """JSON field name constants for durable agent state serialization. - - All field names are in camelCase to match the JSON schema. - Use these constants in both to_dict() and from_dict() methods. - """ - - # Schema-level fields - SCHEMA_VERSION: Final[str] = "schemaVersion" - DATA: Final[str] = "data" - - # Entry discriminator - TYPE: Final[str] = "$type" - - # Legacy/internal field names (for backward compatibility) - JSON_TYPE_LEGACY: Final[str] = "json_type" # Legacy field name for entry type - TYPE_INTERNAL: Final[str] = "type" # Internal key in content dicts - - # Common entry fields - CORRELATION_ID: Final[str] = "correlationId" - CREATED_AT: Final[str] = "createdAt" - MESSAGES: Final[str] = "messages" - EXTENSION_DATA: Final[str] = "extensionData" - - # Request-specific fields - RESPONSE_TYPE: Final[str] = "responseType" - RESPONSE_SCHEMA: Final[str] = "responseSchema" - ORCHESTRATION_ID: Final[str] = "orchestrationId" - - # Response-specific fields - USAGE: Final[str] = "usage" - - # Message fields - ROLE: Final[str] = "role" - CONTENTS: Final[str] = "contents" - AUTHOR_NAME: Final[str] = "authorName" - - # Content fields - TEXT: Final[str] = "text" - URI: Final[str] = "uri" - MEDIA_TYPE: Final[str] = "mediaType" - MESSAGE: Final[str] = "message" - ERROR_CODE: Final[str] = "errorCode" - DETAILS: Final[str] = "details" - CALL_ID: Final[str] = "callId" - NAME: Final[str] = "name" - ARGUMENTS: Final[str] = "arguments" - RESULT: Final[str] = "result" - FILE_ID: Final[str] = "fileId" - VECTOR_STORE_ID: Final[str] = "vectorStoreId" - CONTENT: Final[str] = "content" - - # Usage fields (noqa: S105 - these are JSON field names, not passwords) - INPUT_TOKEN_COUNT: Final[str] = "inputTokenCount" # noqa: S105 - OUTPUT_TOKEN_COUNT: Final[str] = "outputTokenCount" # noqa: S105 - TOTAL_TOKEN_COUNT: Final[str] = "totalTokenCount" # noqa: S105 - - # History field - CONVERSATION_HISTORY: Final[str] = "conversationHistory" - - -class ContentTypes: - """Content type discriminator values for the $type field. - - These values are used in the JSON $type field to identify content types. - """ - - TEXT: Final[str] = "text" - DATA: Final[str] = "data" - ERROR: Final[str] = "error" - FUNCTION_CALL: Final[str] = "functionCall" - FUNCTION_RESULT: Final[str] = "functionResult" - HOSTED_FILE: Final[str] = "hostedFile" - HOSTED_VECTOR_STORE: Final[str] = "hostedVectorStore" - REASONING: Final[str] = "reasoning" - URI: Final[str] = "uri" - USAGE: Final[str] = "usage" - UNKNOWN: Final[str] = "unknown" - - class ApiResponseFields: """Field names for HTTP API responses (not part of persisted schema). @@ -126,14 +35,3 @@ class ApiResponseFields: CONTENT: Final[str] = "content" MESSAGE_COUNT: Final[str] = "message_count" CORRELATION_ID: Final[str] = "correlationId" - - -class Defaults: - """Default values and fallback constants.""" - - SCHEMA_VERSION_FALLBACK: Final[str] = "1.0.0" - ENTRY_TYPE_FALLBACK: Final[str] = "entry" - - -# Note: Entry types (request/response) are defined as DurableAgentStateEntryJsonType enum -# in _durable_agent_state.py for better type safety and exhaustiveness checking. diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py index 4808cc35a2..3b9bd2a4b7 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py @@ -1,38 +1,30 @@ # Copyright (c) Microsoft. All rights reserved. -"""Durable agent state management conforming to the durable-agent-entity-state.json schema. +"""Durable agent state management for Azure Durable Functions agents. -This module provides classes for managing conversation state in Azure Durable Functions agents. -It implements the versioned schema that defines how agent conversations are persisted and restored -across invocations, enabling stateful, long-running agent sessions. +Implements the versioned durable-agent-entity-state.json schema using Pydantic models +for automatic serialization (to_dict) and deserialization (from_dict). -The module includes: -- DurableAgentState: Root state container with schema version and conversation history -- DurableAgentStateEntry and subclasses: Request and response entries in conversation history -- DurableAgentStateMessage: Individual messages with role, content items, and metadata -- Content type classes: Specialized types for text, function calls, errors, and other content -- Serialization/deserialization: Conversion between Python objects and JSON schema format +Key Features: +- Pydantic-based models with automatic camelCase ↔ snake_case conversion +- Polymorphic content types via $type discriminators +- Bidirectional conversion between durable state JSON and agent framework objects -The state structure follows this hierarchy: - DurableAgentState +State Hierarchy: + DurableAgentState (root) └── DurableAgentStateData └── conversationHistory: List[DurableAgentStateEntry] ├── DurableAgentStateRequest (user/system messages) - └── DurableAgentStateResponse (assistant messages with usage stats) + └── DurableAgentStateResponse (assistant messages + usage) └── messages: List[DurableAgentStateMessage] - └── contents: List[DurableAgentStateContent subclasses] - -All classes support bidirectional conversion between: -- Durable state format (JSON with camelCase, $type discriminators) -- Agent framework objects (Python objects with snake_case) + └── contents: List[DurableAgentStateContent] """ from __future__ import annotations import json from datetime import datetime, timezone -from enum import Enum -from typing import Any, cast +from typing import Annotated, Any, ClassVar, Literal, Self from agent_framework import ( AgentRunResponse, @@ -52,217 +44,72 @@ get_logger, ) from dateutil import parser as date_parser +from pydantic import BaseModel, ConfigDict, Field, Tag, field_validator +from pydantic.alias_generators import to_camel -from ._constants import ApiResponseFields, ContentTypes, Defaults, Fields +from ._constants import ApiResponseFields from ._models import RunRequest, serialize_response_format logger = get_logger("agent_framework.azurefunctions.durable_agent_state") -class DurableAgentStateEntryJsonType(str, Enum): - """Enum for conversation history entry types. - - Discriminator values for the $type field in DurableAgentStateEntry objects. - """ - - REQUEST = "request" - RESPONSE = "response" - - def _parse_created_at(value: Any) -> datetime: """Normalize created_at values coming from persisted durable state.""" if isinstance(value, datetime): - return value + return value.astimezone(timezone.utc) if isinstance(value, str): try: parsed = date_parser.parse(value) if isinstance(parsed, datetime): - return parsed + return parsed.astimezone(timezone.utc) except (ValueError, TypeError): pass return datetime.now(tz=timezone.utc) -def _parse_messages(data: dict[str, Any]) -> list[DurableAgentStateMessage]: - """Parse messages from a dictionary, converting dicts to DurableAgentStateMessage objects. +class DurableAgentStateModel(BaseModel): + """Base Pydantic model for durable agent state classes. - Args: - data: Dictionary containing a 'messages' key with a list of message data - - Returns: - List of DurableAgentStateMessage objects + Provides: + - Automatic camelCase ↔ snake_case field conversion + - Forward compatibility (ignores unknown fields) + - Inherited to_dict/from_dict methods """ - messages: list[DurableAgentStateMessage] = [] - raw_messages: list[Any] = data.get(Fields.MESSAGES, []) - for raw_msg in raw_messages: - if isinstance(raw_msg, dict): - messages.append(DurableAgentStateMessage.from_dict(cast(dict[str, Any], raw_msg))) - elif isinstance(raw_msg, DurableAgentStateMessage): - messages.append(raw_msg) - return messages - -def _parse_history_entries(data_dict: dict[str, Any]) -> list[DurableAgentStateEntry]: - """Parse conversation history entries from a dictionary. + model_config = ConfigDict( + alias_generator=to_camel, # Auto-convert snake_case fields to camelCase in JSON + populate_by_name=True, # Allow using snake_case names in constructor + extra="ignore", # Ignore unknown fields for forward compatibility + use_enum_values=True, # Serialize enums as their values, not names + ) - Args: - data_dict: Dictionary containing a 'conversationHistory' key with a list of entry data + def to_dict(self) -> dict[str, Any]: + """Serialize to dict with camelCase keys, excluding None values.""" + return self.model_dump(mode="json", by_alias=True, exclude_none=True) - Returns: - List of DurableAgentStateEntry objects (requests and responses) - """ - history_data: list[Any] = data_dict.get(Fields.CONVERSATION_HISTORY, []) - deserialized_history: list[DurableAgentStateEntry] = [] - for raw_entry in history_data: - if isinstance(raw_entry, dict): - entry_dict = cast(dict[str, Any], raw_entry) - entry_type = entry_dict.get(Fields.TYPE) or entry_dict.get(Fields.JSON_TYPE_LEGACY) - if entry_type == DurableAgentStateEntryJsonType.RESPONSE: - deserialized_history.append(DurableAgentStateResponse.from_dict(entry_dict)) - elif entry_type == DurableAgentStateEntryJsonType.REQUEST: - deserialized_history.append(DurableAgentStateRequest.from_dict(entry_dict)) - else: - deserialized_history.append(DurableAgentStateEntry.from_dict(entry_dict)) - elif isinstance(raw_entry, DurableAgentStateEntry): - deserialized_history.append(raw_entry) - return deserialized_history - - -def _parse_contents(data: dict[str, Any]) -> list[DurableAgentStateContent]: - """Parse content items from a dictionary. - - Args: - data: Dictionary containing a 'contents' key with a list of content data - - Returns: - List of DurableAgentStateContent objects - """ - contents: list[DurableAgentStateContent] = [] - raw_contents: list[Any] = data.get(Fields.CONTENTS, []) - for raw_content in raw_contents: - if isinstance(raw_content, dict): - content_dict = cast(dict[str, Any], raw_content) - content_type: str | None = content_dict.get(Fields.TYPE) - if content_type == ContentTypes.TEXT: - contents.append(DurableAgentStateTextContent(text=content_dict.get(Fields.TEXT))) - elif content_type == ContentTypes.DATA: - contents.append( - DurableAgentStateDataContent( - uri=str(content_dict.get(Fields.URI, "")), - media_type=content_dict.get(Fields.MEDIA_TYPE), - ) - ) - elif content_type == ContentTypes.ERROR: - contents.append( - DurableAgentStateErrorContent( - message=content_dict.get(Fields.MESSAGE), - error_code=content_dict.get(Fields.ERROR_CODE), - details=content_dict.get(Fields.DETAILS), - ) - ) - elif content_type == ContentTypes.FUNCTION_CALL: - contents.append( - DurableAgentStateFunctionCallContent( - call_id=str(content_dict.get(Fields.CALL_ID, "")), - name=str(content_dict.get(Fields.NAME, "")), - arguments=content_dict.get(Fields.ARGUMENTS, {}), - ) - ) - elif content_type == ContentTypes.FUNCTION_RESULT: - contents.append( - DurableAgentStateFunctionResultContent( - call_id=str(content_dict.get(Fields.CALL_ID, "")), - result=content_dict.get(Fields.RESULT), - ) - ) - elif content_type == ContentTypes.HOSTED_FILE: - contents.append(DurableAgentStateHostedFileContent(file_id=str(content_dict.get(Fields.FILE_ID, "")))) - elif content_type == ContentTypes.HOSTED_VECTOR_STORE: - contents.append( - DurableAgentStateHostedVectorStoreContent( - vector_store_id=str(content_dict.get(Fields.VECTOR_STORE_ID, "")) - ) - ) - elif content_type == ContentTypes.REASONING: - contents.append(DurableAgentStateTextReasoningContent(text=content_dict.get(Fields.TEXT))) - elif content_type == ContentTypes.URI: - contents.append( - DurableAgentStateUriContent( - uri=str(content_dict.get(Fields.URI, "")), - media_type=str(content_dict.get(Fields.MEDIA_TYPE, "")), - ) - ) - elif content_type == ContentTypes.USAGE: - usage_data = content_dict.get(Fields.USAGE) - if usage_data and isinstance(usage_data, dict): - contents.append( - DurableAgentStateUsageContent( - usage=DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_data)) - ) - ) - elif content_type == ContentTypes.UNKNOWN: - contents.append(DurableAgentStateUnknownContent(content=content_dict.get(Fields.CONTENT, {}))) - elif isinstance(raw_content, DurableAgentStateContent): - contents.append(raw_content) - return contents - - -class DurableAgentStateContent: - """Base class for all content types in durable agent state messages. - - This abstract base class defines the interface for content items that can be - stored in conversation history. Content types include text, function calls, - function results, errors, and other specialized content types defined by the - agent framework. - - Subclasses must implement to_dict() and to_ai_content() to handle conversion - between the durable state representation and the agent framework's content objects. - - Attributes: - extensionData: Optional additional metadata (not serialized per schema) - """ + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Deserialize from dict.""" + return cls.model_validate(data) - extensionData: dict[str, Any] | None = None - type: str = "" - def to_dict(self) -> dict[str, Any]: - """Serialize this content to a dictionary for JSON storage. +class DurableAgentStateContent(DurableAgentStateModel): + """Base class for message content types. - Returns: - Dictionary representation including $type discriminator and content-specific fields + Subclasses must override `type` with a Literal value for $type discrimination. + """ - Raises: - NotImplementedError: Must be implemented by subclasses - """ - raise NotImplementedError + type: str = Field(alias="$type") def to_ai_content(self) -> Any: - """Convert this durable state content back to an agent framework content object. - - Returns: - An agent framework content object (TextContent, FunctionCallContent, etc.) - - Raises: - NotImplementedError: Must be implemented by subclasses - """ + """Convert to agent framework content object. Must be implemented by subclasses.""" raise NotImplementedError @staticmethod def from_ai_content(content: Any) -> DurableAgentStateContent: - """Create a durable state content object from an agent framework content object. - - This factory method maps agent framework content types (TextContent, FunctionCallContent, - etc.) to their corresponding durable state representations. Unknown content types are - wrapped in DurableAgentStateUnknownContent. - - Args: - content: An agent framework content object (TextContent, FunctionCallContent, etc.) - - Returns: - The corresponding DurableAgentStateContent subclass instance - """ + """Factory: convert agent framework content to durable state content.""" # Map AI content type to appropriate DurableAgentStateContent subclass if isinstance(content, DataContent): return DurableAgentStateDataContent.from_data_content(content) @@ -287,136 +134,42 @@ def from_ai_content(content: Any) -> DurableAgentStateContent: return DurableAgentStateUnknownContent.from_unknown_content(content) -# Core state classes - - -class DurableAgentStateData: - """Container for the core data within durable agent state. - - This class holds the primary data structures for agent conversation state, - including the conversation history (a sequence of request and response entries) - and optional extension data for custom metadata. +class DurableAgentStateData(DurableAgentStateModel): + """Container for conversation history and extension data within DurableAgentState.""" - The data structure is nested within DurableAgentState under the "data" property, - conforming to the durable-agent-entity-state.json schema structure. - - Attributes: - conversation_history: Ordered list of conversation entries (requests and responses) - extension_data: Optional dictionary for custom metadata (not part of core schema) - """ - - conversation_history: list[DurableAgentStateEntry] - extension_data: dict[str, Any] | None - - def __init__( - self, - conversation_history: list[DurableAgentStateEntry] | None = None, - extension_data: dict[str, Any] | None = None, - ) -> None: - """Initialize the data container. - - Args: - conversation_history: Initial conversation history (defaults to empty list) - extension_data: Optional custom metadata - """ - self.conversation_history = conversation_history or [] - self.extension_data = extension_data - - def to_dict(self) -> dict[str, Any]: - result: dict[str, Any] = { - Fields.CONVERSATION_HISTORY: [entry.to_dict() for entry in self.conversation_history], - } - if self.extension_data is not None: - result[Fields.EXTENSION_DATA] = self.extension_data - return result - - @classmethod - def from_dict(cls, data_dict: dict[str, Any]) -> DurableAgentStateData: - return cls( - conversation_history=_parse_history_entries(data_dict), - extension_data=data_dict.get(Fields.EXTENSION_DATA), - ) - - -class DurableAgentState: - """Manages durable agent state conforming to the durable-agent-entity-state.json schema. - - This class provides the root container for agent conversation state that can be persisted - in Azure Durable Entities. It maintains the conversation history as a sequence of request - and response entries, each with their messages, timestamps, and metadata. + conversation_history: list[ + Annotated[ + ( + Annotated[DurableAgentStateRequest, Tag("request")] + | Annotated[DurableAgentStateResponse, Tag("response")] + ), + Field(discriminator="type"), + ] + ] = Field(default_factory=list) + extension_data: dict[str, Any] | None = None - The state follows a versioned schema (see SCHEMA_VERSION class constant) that defines the structure for: - - Request entries: User/system messages with optional response format specifications - - Response entries: Assistant messages with token usage information - - Messages: Individual chat messages with role, content items, and timestamps - - Content items: Text, function calls, function results, errors, and other content types - State is serialized to JSON with this structure: - { - "schemaVersion": "", - "data": { - "conversationHistory": [ - {"$type": "request", "correlationId": "...", "createdAt": "...", "messages": [...]}, - {"$type": "response", "correlationId": "...", "createdAt": "...", "messages": [...], "usage": {...}} - ] - } - } +class DurableAgentState(DurableAgentStateModel): + """Root container for durable agent state, persisted in Azure Durable Entities. - Attributes: - data: Container for conversation history and optional extension data - schema_version: Schema version string (defaults to SCHEMA_VERSION) + Serializes to: {"schemaVersion": "...", "data": {"conversationHistory": [...]}} """ - # Durable Agent Schema version - SCHEMA_VERSION: str = "1.1.0" - - data: DurableAgentStateData - schema_version: str = SCHEMA_VERSION - - def __init__(self, schema_version: str = SCHEMA_VERSION): - """Initialize a new durable agent state. - - Args: - schema_version: Schema version to use (defaults to SCHEMA_VERSION) - """ - self.data = DurableAgentStateData() - self.schema_version = schema_version - - def to_dict(self) -> dict[str, Any]: - - return { - Fields.SCHEMA_VERSION: self.schema_version, - Fields.DATA: self.data.to_dict(), - } + # Durable Agent Schema version (ClassVar to prevent Pydantic from treating it as a field) + SCHEMA_VERSION: ClassVar[str] = "1.1.0" - def to_json(self) -> str: - return json.dumps(self.to_dict()) + schema_version: str = Field(default=SCHEMA_VERSION) + data: DurableAgentStateData = Field(default_factory=DurableAgentStateData) @classmethod - def from_dict(cls, state: dict[str, Any]) -> DurableAgentState: - """Restore state from a dictionary. - - Args: - state: Dictionary containing schemaVersion and data (full state structure) - """ - schema_version = state.get(Fields.SCHEMA_VERSION) + def from_dict(cls, state: dict[str, Any]) -> Self: + """Restore state from dict. Returns empty state if schema version is missing.""" + schema_version = state.get("schemaVersion") if schema_version is None: logger.warning("Resetting state as it is incompatible with the current schema, all history will be lost") return cls() - instance = cls(schema_version=state.get(Fields.SCHEMA_VERSION, Defaults.SCHEMA_VERSION_FALLBACK)) - instance.data = DurableAgentStateData.from_dict(state.get(Fields.DATA, {})) - - return instance - - @classmethod - def from_json(cls, json_str: str) -> DurableAgentState: - try: - obj = json.loads(json_str) - except json.JSONDecodeError as e: - raise ValueError("The durable agent state is not valid JSON.") from e - - return cls.from_dict(obj) + return super().from_dict(state) @property def message_count(self) -> int: @@ -424,23 +177,7 @@ def message_count(self) -> int: return len(self.data.conversation_history) def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: - """Try to get an agent response by correlation ID. - - This method searches the conversation history for a response entry matching the given - correlation ID and returns a dictionary suitable for HTTP API responses. - - Note: The returned dictionary includes computed properties (message_count) that are - NOT part of the persisted state schema. These are derived values included for backward - compatibility with the HTTP API response format and should not be considered part of - the durable state structure. - - Args: - correlation_id: The correlation ID to search for - - Returns: - Response data dict with 'content', 'message_count', and 'correlationId' if found, - None otherwise - """ + """Find response by correlation_id. Returns API-formatted dict or None.""" # Search through conversation history for a response with this correlationId for entry in self.data.conversation_history: if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse): @@ -456,144 +193,33 @@ def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: return None -class DurableAgentStateEntry: - """Base class for conversation history entries (requests and responses). - - This class represents a single entry in the conversation history. Each entry can be - either a request (user/system messages sent to the agent) or a response (assistant - messages from the agent). The $type discriminator field determines which type of entry - it represents. +class DurableAgentStateEntry(DurableAgentStateModel): + """Base class for conversation history entries. Discriminated by $type field.""" - Entries are linked together using correlation IDs, allowing responses to be matched - with their originating requests. - - Common Attributes: - json_type: Discriminator for entry type ("request" or "response") - correlationId: Unique identifier linking requests and responses - created_at: Timestamp when the entry was created - messages: List of messages in this entry - extensionData: Optional additional metadata (not serialized per schema) - - Request-only Attributes: - responseType: Expected response type ("text" or "json") - only for request entries - responseSchema: JSON schema for structured responses - only for request entries - - Response-only Attributes: - usage: Token usage statistics - only for response entries - """ - - json_type: DurableAgentStateEntryJsonType - correlation_id: str | None - created_at: datetime - messages: list[DurableAgentStateMessage] - extension_data: dict[str, Any] | None - - def __init__( - self, - json_type: DurableAgentStateEntryJsonType, - correlation_id: str | None, - created_at: datetime, - messages: list[DurableAgentStateMessage], - extension_data: dict[str, Any] | None = None, - ) -> None: - self.json_type = json_type - self.correlation_id = correlation_id - self.created_at = created_at - self.messages = messages - self.extension_data = extension_data - - def to_dict(self) -> dict[str, Any]: - return { - Fields.TYPE: self.json_type, - Fields.CORRELATION_ID: self.correlation_id, - Fields.CREATED_AT: self.created_at.isoformat(), - Fields.MESSAGES: [m.to_dict() for m in self.messages], - } + type: str = Field(alias="$type") + correlation_id: str | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) + messages: list[DurableAgentStateMessage] = Field(default_factory=list) + extension_data: dict[str, Any] | None = None + @field_validator("created_at", mode="before") @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateEntry: - created_at = _parse_created_at(data.get(Fields.CREATED_AT)) - messages = _parse_messages(data) - - return cls( - json_type=DurableAgentStateEntryJsonType(data.get(Fields.TYPE, Defaults.ENTRY_TYPE_FALLBACK)), - correlation_id=data.get(Fields.CORRELATION_ID, ""), - created_at=created_at, - messages=messages, - extension_data=data.get(Fields.EXTENSION_DATA), - ) + def parse_datetime(cls, v: Any) -> datetime: + """Parse datetime from string or return existing datetime.""" + return _parse_created_at(v) class DurableAgentStateRequest(DurableAgentStateEntry): - """Represents a request entry in the durable agent conversation history. - - A request entry captures a user or system message sent to the agent, along with - optional response format specifications. Each request is stored as a separate - entry in the conversation history with a unique correlation ID. - - Attributes: - response_type: Expected response type ("text" or "json") - response_schema: JSON schema for structured responses (when response_type is "json") - orchestration_id: ID of the orchestration that initiated this request (if any) - correlationId: Unique identifier linking this request to its response - created_at: Timestamp when the request was created - messages: List of messages included in this request - json_type: Always "request" for this class - """ + """Request entry: user/system messages with optional response format specs.""" + type: Literal["request"] = Field(default="request", alias="$type") response_type: str | None = None response_schema: dict[str, Any] | None = None orchestration_id: str | None = None - def __init__( - self, - correlation_id: str | None, - created_at: datetime, - messages: list[DurableAgentStateMessage], - extension_data: dict[str, Any] | None = None, - response_type: str | None = None, - response_schema: dict[str, Any] | None = None, - orchestration_id: str | None = None, - ) -> None: - super().__init__( - json_type=DurableAgentStateEntryJsonType.REQUEST, - correlation_id=correlation_id, - created_at=created_at, - messages=messages, - extension_data=extension_data, - ) - self.response_type = response_type - self.response_schema = response_schema - self.orchestration_id = orchestration_id - - def to_dict(self) -> dict[str, Any]: - data = super().to_dict() - if self.orchestration_id is not None: - data[Fields.ORCHESTRATION_ID] = self.orchestration_id - if self.response_type is not None: - data[Fields.RESPONSE_TYPE] = self.response_type - if self.response_schema is not None: - data[Fields.RESPONSE_SCHEMA] = self.response_schema - return data - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateRequest: - created_at = _parse_created_at(data.get(Fields.CREATED_AT)) - messages = _parse_messages(data) - - return cls( - correlation_id=data.get(Fields.CORRELATION_ID, ""), - created_at=created_at, - messages=messages, - extension_data=data.get(Fields.EXTENSION_DATA), - response_type=data.get(Fields.RESPONSE_TYPE), - response_schema=data.get(Fields.RESPONSE_SCHEMA), - orchestration_id=data.get(Fields.ORCHESTRATION_ID), - ) - @staticmethod def from_run_request(request: RunRequest) -> DurableAgentStateRequest: - # Determine response_type based on response_format + """Create a DurableAgentStateRequest from a RunRequest.""" return DurableAgentStateRequest( correlation_id=request.correlation_id, messages=[DurableAgentStateMessage.from_run_request(request)], @@ -605,70 +231,16 @@ def from_run_request(request: RunRequest) -> DurableAgentStateRequest: class DurableAgentStateResponse(DurableAgentStateEntry): - """Represents a response entry in the durable agent conversation history. - - A response entry captures the agent's reply to a user request, including any - assistant messages, tool calls, and token usage information. Each response is - linked to its originating request via a correlation ID. - - Attributes: - usage: Token usage statistics for this response (input, output, and total tokens) - is_error: Flag indicating if this response represents an error (not persisted in schema) - correlation_id: Unique identifier linking this response to its request - created_at: Timestamp when the response was created - messages: List of assistant messages in this response - json_type: Always "response" for this class - """ + """Response entry: assistant messages with token usage statistics.""" + type: Literal["response"] = Field(default="response", alias="$type") usage: DurableAgentStateUsage | None = None is_error: bool = False - def __init__( - self, - correlation_id: str, - created_at: datetime, - messages: list[DurableAgentStateMessage], - extension_data: dict[str, Any] | None = None, - usage: DurableAgentStateUsage | None = None, - is_error: bool = False, - ) -> None: - super().__init__( - json_type=DurableAgentStateEntryJsonType.RESPONSE, - correlation_id=correlation_id, - created_at=created_at, - messages=messages, - extension_data=extension_data, - ) - self.usage = usage - self.is_error = is_error - - def to_dict(self) -> dict[str, Any]: - data = super().to_dict() - if self.usage is not None: - data[Fields.USAGE] = self.usage.to_dict() - return data - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateResponse: - created_at = _parse_created_at(data.get(Fields.CREATED_AT)) - messages = _parse_messages(data) - - usage_dict = data.get(Fields.USAGE) - usage: DurableAgentStateUsage | None = None - if usage_dict and isinstance(usage_dict, dict): - usage = DurableAgentStateUsage.from_dict(cast(dict[str, Any], usage_dict)) - - return cls( - correlation_id=data.get(Fields.CORRELATION_ID, ""), - created_at=created_at, - messages=messages, - extension_data=data.get(Fields.EXTENSION_DATA), - usage=usage, - ) - @staticmethod def from_run_response(correlation_id: str, response: AgentRunResponse) -> DurableAgentStateResponse: - """Creates a DurableAgentStateResponse from an AgentRunResponse.""" + """Create a DurableAgentStateResponse from an AgentRunResponse.""" + logger.warning("Received Agent Run Response response: %s", json.dumps(response.to_dict(), indent=2)) return DurableAgentStateResponse( correlation_id=correlation_id, created_at=_parse_created_at(response.created_at), @@ -677,7 +249,7 @@ def from_run_response(correlation_id: str, response: AgentRunResponse) -> Durabl ) def to_run_response(self) -> Any: - """Converts this DurableAgentStateResponse back to an AgentRunResponse.""" + """Convert this DurableAgentStateResponse back to an AgentRunResponse.""" return AgentRunResponse( created_at=self.created_at.isoformat() if self.created_at else None, messages=[m.to_chat_message() for m in self.messages], @@ -685,69 +257,29 @@ def to_run_response(self) -> Any: ) -class DurableAgentStateMessage: - """Represents a message within a conversation history entry. - - A message contains the role (user, assistant, system), content items (text, function calls, - tool results, etc.), and optional metadata. Messages are the building blocks of both - request and response entries in the conversation history. - - Attributes: - role: The sender role ("user", "assistant", or "system") - contents: List of content items (text, function calls, errors, etc.) - author_name: Optional name of the message author (typically set for assistant messages) - created_at: Optional timestamp when the message was created - extension_data: Optional additional metadata (not serialized per schema) - """ +class DurableAgentStateMessage(DurableAgentStateModel): + """A message with role, content items, and optional metadata.""" role: str - contents: list[DurableAgentStateContent] + contents: list[ + ( + DurableAgentStateTextContent + | DurableAgentStateDataContent + | DurableAgentStateErrorContent + | DurableAgentStateFunctionCallContent + | DurableAgentStateFunctionResultContent + | DurableAgentStateHostedFileContent + | DurableAgentStateHostedVectorStoreContent + | DurableAgentStateTextReasoningContent + | DurableAgentStateUriContent + | DurableAgentStateUsageContent + | DurableAgentStateUnknownContent + ) + ] author_name: str | None = None created_at: datetime | None = None extension_data: dict[str, Any] | None = None - def __init__( - self, - role: str, - contents: list[DurableAgentStateContent], - author_name: str | None = None, - created_at: datetime | None = None, - extension_data: dict[str, Any] | None = None, - ) -> None: - self.role = role - self.contents = contents - self.author_name = author_name - self.created_at = created_at - self.extension_data = extension_data - - def to_dict(self) -> dict[str, Any]: - result: dict[str, Any] = { - Fields.ROLE: self.role, - Fields.CONTENTS: [ - { - Fields.TYPE: c.to_dict().get(Fields.TYPE_INTERNAL, ContentTypes.TEXT), - **{k: v for k, v in c.to_dict().items() if k != Fields.TYPE_INTERNAL}, - } - for c in self.contents - ], - } - # Only include optional fields if they have values - if self.created_at is not None: - result[Fields.CREATED_AT] = self.created_at.isoformat() - if self.author_name is not None: - result[Fields.AUTHOR_NAME] = self.author_name - return result - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateMessage: - return cls( - role=data.get(Fields.ROLE, ""), - contents=_parse_contents(data), - author_name=data.get(Fields.AUTHOR_NAME), - created_at=_parse_created_at(data.get(Fields.CREATED_AT)), - extension_data=data.get(Fields.EXTENSION_DATA), - ) - @property def text(self) -> str: """Extract text from the contents list.""" @@ -759,13 +291,7 @@ def text(self) -> str: @staticmethod def from_run_request(request: RunRequest) -> DurableAgentStateMessage: - """Converts a RunRequest from the agent framework to a DurableAgentStateMessage. - - Args: - request: RunRequest object with role, message/contents, and metadata - Returns: - DurableAgentStateMessage with converted content items and metadata - """ + """Convert RunRequest to DurableAgentStateMessage.""" return DurableAgentStateMessage( role=request.role.value, contents=[DurableAgentStateTextContent(text=request.message)], @@ -774,14 +300,7 @@ def from_run_request(request: RunRequest) -> DurableAgentStateMessage: @staticmethod def from_chat_message(chat_message: ChatMessage) -> DurableAgentStateMessage: - """Converts an Agent Framework chat message to a durable state message. - - Args: - chat_message: ChatMessage object with role, contents, and metadata to convert - - Returns: - DurableAgentStateMessage with converted content items and metadata - """ + """Convert ChatMessage to DurableAgentStateMessage.""" contents_list: list[DurableAgentStateContent] = [ DurableAgentStateContent.from_ai_content(c) for c in chat_message.contents ] @@ -794,11 +313,7 @@ def from_chat_message(chat_message: ChatMessage) -> DurableAgentStateMessage: ) def to_chat_message(self) -> Any: - """Converts this DurableAgentStateMessage back to an agent framework ChatMessage. - - Returns: - ChatMessage object with role, contents, and metadata converted back to agent framework types - """ + """Convert to agent framework ChatMessage.""" # Convert DurableAgentStateContent objects back to agent_framework content objects ai_contents = [c.to_ai_content() for c in self.contents] @@ -818,27 +333,11 @@ def to_chat_message(self) -> Any: class DurableAgentStateDataContent(DurableAgentStateContent): - """Represents data content with a URI reference. - - This content type is used to reference data stored at a specific URI location, - optionally with a media type specification. Common use cases include referencing - files, documents, or other data resources. - - Attributes: - uri: URI pointing to the data resource - media_type: Optional MIME type of the data (e.g., "application/json", "text/plain") - """ + """Data content referencing a URI with optional media type.""" + type: Literal["data"] = Field(default="data", alias="$type") uri: str = "" media_type: str | None = None - type: str = ContentTypes.DATA - - def __init__(self, uri: str, media_type: str | None = None) -> None: - self.uri = uri - self.media_type = media_type - - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.URI: self.uri, Fields.MEDIA_TYPE: self.media_type} @staticmethod def from_data_content(content: DataContent) -> DurableAgentStateDataContent: @@ -849,36 +348,13 @@ def to_ai_content(self) -> DataContent: class DurableAgentStateErrorContent(DurableAgentStateContent): - """Represents error content in agent responses. - - This content type is used to communicate errors that occurred during agent execution, - including error messages, error codes, and additional details for debugging. - - Attributes: - message: Human-readable error message - error_code: Machine-readable error code or exception type - details: Additional error details or stack trace information - """ + """Error content with message, code, and details.""" + type: Literal["error"] = Field(default="error", alias="$type") message: str | None = None error_code: str | None = None details: str | None = None - type: str = ContentTypes.ERROR - - def __init__(self, message: str | None = None, error_code: str | None = None, details: str | None = None) -> None: - self.message = message - self.error_code = error_code - self.details = details - - def to_dict(self) -> dict[str, Any]: - return { - Fields.TYPE: self.type, - Fields.MESSAGE: self.message, - Fields.ERROR_CODE: self.error_code, - Fields.DETAILS: self.details, - } - @staticmethod def from_error_content(content: ErrorContent) -> DurableAgentStateErrorContent: return DurableAgentStateErrorContent( @@ -890,37 +366,13 @@ def to_ai_content(self) -> ErrorContent: class DurableAgentStateFunctionCallContent(DurableAgentStateContent): - """Represents a function/tool call request from the agent. - - This content type is used when the agent requests execution of a function or tool, - including the function name, arguments, and a unique call identifier for tracking - the call-result pair. - - Attributes: - call_id: Unique identifier for this function call (used to match with results) - name: Name of the function/tool to execute - arguments: Dictionary of argument names to values for the function call - """ + """Function/tool call with call_id, name, and arguments.""" + type: Literal["functionCall"] = Field(default="functionCall", alias="$type") call_id: str name: str arguments: dict[str, Any] - type: str = ContentTypes.FUNCTION_CALL - - def __init__(self, call_id: str, name: str, arguments: dict[str, Any]) -> None: - self.call_id = call_id - self.name = name - self.arguments = arguments - - def to_dict(self) -> dict[str, Any]: - return { - Fields.TYPE: self.type, - Fields.CALL_ID: self.call_id, - Fields.NAME: self.name, - Fields.ARGUMENTS: self.arguments, - } - @staticmethod def from_function_call_content(content: FunctionCallContent) -> DurableAgentStateFunctionCallContent: # Ensure arguments is a dict; parse string if needed @@ -942,29 +394,12 @@ def to_ai_content(self) -> FunctionCallContent: class DurableAgentStateFunctionResultContent(DurableAgentStateContent): - """Represents the result of a function/tool call execution. - - This content type is used to communicate the result of executing a function or tool - that was previously requested by the agent. The call_id links this result back to - the original function call request. - - Attributes: - call_id: Unique identifier matching the original function call - result: The return value from the function execution (can be any serializable type) - """ + """Function/tool result linked to original call via call_id.""" + type: Literal["functionResult"] = Field(default="functionResult", alias="$type") call_id: str result: object | None = None - type: str = ContentTypes.FUNCTION_RESULT - - def __init__(self, call_id: str, result: Any | None = None) -> None: - self.call_id = call_id - self.result = result - - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.CALL_ID: self.call_id, Fields.RESULT: self.result} - @staticmethod def from_function_result_content(content: FunctionResultContent) -> DurableAgentStateFunctionResultContent: return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result) @@ -974,25 +409,11 @@ def to_ai_content(self) -> FunctionResultContent: class DurableAgentStateHostedFileContent(DurableAgentStateContent): - """Represents a reference to a hosted file resource. - - This content type is used to reference files that are hosted by the agent platform - or a file storage service, identified by a unique file ID. - - Attributes: - file_id: Unique identifier for the hosted file - """ + """Reference to a hosted file by file_id.""" + type: Literal["hostedFile"] = Field(default="hostedFile", alias="$type") file_id: str - type: str = ContentTypes.HOSTED_FILE - - def __init__(self, file_id: str) -> None: - self.file_id = file_id - - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.FILE_ID: self.file_id} - @staticmethod def from_hosted_file_content(content: HostedFileContent) -> DurableAgentStateHostedFileContent: return DurableAgentStateHostedFileContent(file_id=content.file_id) @@ -1002,26 +423,11 @@ def to_ai_content(self) -> HostedFileContent: class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent): - """Represents a reference to a hosted vector store resource. - - This content type is used to reference vector stores (used for semantic search - and retrieval-augmented generation) that are hosted by the agent platform, - identified by a unique vector store ID. - - Attributes: - vector_store_id: Unique identifier for the hosted vector store - """ + """Reference to a hosted vector store by vector_store_id.""" + type: Literal["hostedVectorStore"] = Field(default="hostedVectorStore", alias="$type") vector_store_id: str - type: str = ContentTypes.HOSTED_VECTOR_STORE - - def __init__(self, vector_store_id: str) -> None: - self.vector_store_id = vector_store_id - - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.VECTOR_STORE_ID: self.vector_store_id} - @staticmethod def from_hosted_vector_store_content( content: HostedVectorStoreContent, @@ -1033,22 +439,10 @@ def to_ai_content(self) -> HostedVectorStoreContent: class DurableAgentStateTextContent(DurableAgentStateContent): - """Represents plain text content in messages. - - This is the most common content type, used for regular text messages from users - and text responses from the agent. - - Attributes: - text: The text content of the message - """ - - type: str = ContentTypes.TEXT - - def __init__(self, text: str | None) -> None: - self.text = text + """Plain text content.""" - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.TEXT: self.text} + type: Literal["text"] = Field(default="text", alias="$type") + text: str | None = None @staticmethod def from_text_content(content: TextContent) -> DurableAgentStateTextContent: @@ -1059,22 +453,10 @@ def to_ai_content(self) -> TextContent: class DurableAgentStateTextReasoningContent(DurableAgentStateContent): - """Represents reasoning or thought process text from the agent. - - This content type is used to capture the agent's internal reasoning, chain of thought, - or explanation of its decision-making process, separate from the final response text. - - Attributes: - text: The reasoning or thought process text - """ - - type: str = ContentTypes.REASONING - - def __init__(self, text: str | None) -> None: - self.text = text + """Agent reasoning/chain-of-thought text, separate from final response.""" - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.TEXT: self.text} + type: Literal["reasoning"] = Field(default="reasoning", alias="$type") + text: str | None = None @staticmethod def from_text_reasoning_content(content: TextReasoningContent) -> DurableAgentStateTextReasoningContent: @@ -1085,28 +467,12 @@ def to_ai_content(self) -> TextReasoningContent: class DurableAgentStateUriContent(DurableAgentStateContent): - """Represents content referenced by a URI with media type. - - This content type is used to reference external content via a URI, with an associated - media type to indicate how the content should be interpreted. - - Attributes: - uri: URI pointing to the content resource - media_type: MIME type of the content (e.g., "image/png", "application/pdf") - """ + """URI content with required media type.""" + type: Literal["uri"] = Field(default="uri", alias="$type") uri: str media_type: str - type: str = ContentTypes.URI - - def __init__(self, uri: str, media_type: str) -> None: - self.uri = uri - self.media_type = media_type - - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.URI: self.uri, Fields.MEDIA_TYPE: self.media_type} - @staticmethod def from_uri_content(content: UriContent) -> DurableAgentStateUriContent: return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type) @@ -1115,55 +481,13 @@ def to_ai_content(self) -> UriContent: return UriContent(uri=self.uri, media_type=self.media_type) -class DurableAgentStateUsage: - """Represents token usage statistics for agent responses. - - This class tracks the number of tokens consumed during agent execution, - including input tokens (from the request), output tokens (in the response), - and the total token count. - - Attributes: - input_token_count: Number of tokens in the input/request - output_token_count: Number of tokens in the output/response - total_token_count: Total number of tokens consumed (input + output) - extensionData: Optional additional metadata - """ +class DurableAgentStateUsage(DurableAgentStateModel): + """Token usage statistics: input, output, and total counts.""" input_token_count: int | None = None output_token_count: int | None = None total_token_count: int | None = None - extensionData: dict[str, Any] | None = None - - def __init__( - self, - input_token_count: int | None = None, - output_token_count: int | None = None, - total_token_count: int | None = None, - extensionData: dict[str, Any] | None = None, - ) -> None: - self.input_token_count = input_token_count - self.output_token_count = output_token_count - self.total_token_count = total_token_count - self.extensionData = extensionData - - def to_dict(self) -> dict[str, Any]: - result: dict[str, Any] = { - Fields.INPUT_TOKEN_COUNT: self.input_token_count, - Fields.OUTPUT_TOKEN_COUNT: self.output_token_count, - Fields.TOTAL_TOKEN_COUNT: self.total_token_count, - } - if self.extensionData is not None: - result[Fields.EXTENSION_DATA] = self.extensionData - return result - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateUsage: - return cls( - input_token_count=data.get(Fields.INPUT_TOKEN_COUNT), - output_token_count=data.get(Fields.OUTPUT_TOKEN_COUNT), - total_token_count=data.get(Fields.TOTAL_TOKEN_COUNT), - extensionData=data.get(Fields.EXTENSION_DATA), - ) + extension_data: dict[str, Any] | None = None @staticmethod def from_usage(usage: UsageDetails | None) -> DurableAgentStateUsage | None: @@ -1185,29 +509,11 @@ def to_usage_details(self) -> UsageDetails: class DurableAgentStateUsageContent(DurableAgentStateContent): - """Represents token usage information as message content. - - This content type is used to communicate token usage statistics as part of - message content, allowing usage information to be tracked alongside other - content types in the conversation history. - - Attributes: - usage: DurableAgentStateUsage object containing token counts - """ + """Token usage as message content.""" + type: Literal["usage"] = Field(default="usage", alias="$type") usage: DurableAgentStateUsage = DurableAgentStateUsage() - type: str = ContentTypes.USAGE - - def __init__(self, usage: DurableAgentStateUsage | None) -> None: - self.usage = usage if usage is not None else DurableAgentStateUsage() - - def to_dict(self) -> dict[str, Any]: - return { - Fields.TYPE: self.type, - Fields.USAGE: self.usage.to_dict(), - } - @staticmethod def from_usage_content(content: UsageContent) -> DurableAgentStateUsageContent: return DurableAgentStateUsageContent(usage=DurableAgentStateUsage.from_usage(content.details)) @@ -1217,26 +523,11 @@ def to_ai_content(self) -> UsageContent: class DurableAgentStateUnknownContent(DurableAgentStateContent): - """Represents unknown or unrecognized content types. - - This content type serves as a fallback for content that doesn't match any of the - known content type classes. It preserves the original content object for later - inspection or processing. - - Attributes: - content: The unknown content object - """ + """Fallback for unrecognized content types. Preserves original content.""" + type: Literal["unknown"] = Field(default="unknown", alias="$type") content: Any - type: str = ContentTypes.UNKNOWN - - def __init__(self, content: Any) -> None: - self.content = content - - def to_dict(self) -> dict[str, Any]: - return {Fields.TYPE: self.type, Fields.CONTENT: self.content} - @staticmethod def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent: return DurableAgentStateUnknownContent(content=content) diff --git a/python/packages/azurefunctions/tests/test_orchestration.py b/python/packages/azurefunctions/tests/test_orchestration.py index c763f19757..0f845d4105 100644 --- a/python/packages/azurefunctions/tests/test_orchestration.py +++ b/python/packages/azurefunctions/tests/test_orchestration.py @@ -312,9 +312,8 @@ def test_run_sets_orchestration_id(self) -> None: mock_context.instance_id = "my-orchestration-123" mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"]) - mock_task = Mock() - mock_task._is_scheduled = False - mock_context.call_entity = Mock(return_value=mock_task) + entity_task = _create_entity_task() + mock_context.call_entity = Mock(return_value=entity_task) agent = DurableAIAgent(mock_context, "TestAgent") thread = agent.get_new_thread() diff --git a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json b/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json index 66485988da..9e7fd873dd 100644 --- a/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json +++ b/python/samples/getting_started/azure_functions/05_multi_agent_orchestration_concurrency/host.json @@ -1,16 +1,12 @@ { "version": "2.0", "extensionBundle": { - "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)" }, "extensions": { "durableTask": { - "hubName": "%TASKHUB_NAME%", - "storageProvider": { - "type": "azureManaged", - "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" - } + "hubName": "%TASKHUB_NAME%" } } }