Python: entity state schema#2096
Python: entity state schema#2096hallvictoria wants to merge 12 commits intomicrosoft:feature-azure-functionsfrom
Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR implements a durable entity state schema for the Python agent framework to persist conversation history according to a proposed schema. The changes introduce a new state serialization layer with version support.
Key changes:
- Adds new
durable_agent_state.pymodule with dataclass-based state representation supporting JSON serialization/deserialization - Updates
_entities.pyto useDurableAgentStateinstead of the previousAgentStateimplementation - Implements converters between agent framework content types and durable state content types
Reviewed Changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 23 comments.
| File | Description |
|---|---|
durable_agent_state.py |
New module defining durable state schema classes for persistence, including content type converters and version management |
_entities.py |
Updates entity state management to use DurableAgentState, adds UUID generation for correlation IDs, and integrates request/response state persistence |
Comments suppressed due to low confidence (1)
python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py:104
- The validation logic on lines 103-104 checks if
correlation_idis falsy and raises an error, but line 100 already assigns a UUID ifcorrelation_idis None/empty. This validation will never trigger becausecorrelation_idwill always have a value after line 100. Either remove this check or move it before the UUID assignment.
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
python/packages/azurefunctions/agent_framework_azurefunctions/durable_agent_state.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/durable_agent_state.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/durable_agent_state.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/durable_agent_state.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/durable_agent_state.py
Outdated
Show resolved
Hide resolved
python/packages/azurefunctions/agent_framework_azurefunctions/durable_agent_state.py
Outdated
Show resolved
Hide resolved
| from agent_framework import TextContent | ||
| return DurableAgentStateRequest(correlation_id=content.correlation_id, | ||
| messages=[DurableAgentStateMessage.from_chat_message(msg) for msg in content.message], | ||
| created_at=min((m.created_at for m in content.message), default=datetime.now(tz=timezone.utc)), | ||
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | ||
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | ||
| response_schema=content.response_schema) |
There was a problem hiding this comment.
The code attempts to iterate over content.message, but RunRequest.message is defined as str (line 294 in _models.py), not a list of messages. This will cause a runtime error when iterating character-by-character over the string. The code should create a single ChatMessage from the string message and wrap it in a list, e.g., messages=[DurableAgentStateMessage.from_chat_message(ChatMessage(role=content.role, text=content.message))].
| from agent_framework import TextContent | |
| return DurableAgentStateRequest(correlation_id=content.correlation_id, | |
| messages=[DurableAgentStateMessage.from_chat_message(msg) for msg in content.message], | |
| created_at=min((m.created_at for m in content.message), default=datetime.now(tz=timezone.utc)), | |
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | |
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | |
| response_schema=content.response_schema) | |
| from agent_framework import TextContent, ChatMessage | |
| # content.message is a string, not a list; wrap it in a single ChatMessage | |
| messages = [DurableAgentStateMessage.from_chat_message(ChatMessage(role=content.role, text=content.message))] | |
| created_at = getattr(content, "created_at", datetime.now(tz=timezone.utc)) | |
| return DurableAgentStateRequest( | |
| correlation_id=content.correlation_id, | |
| messages=messages, | |
| created_at=created_at, | |
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | |
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | |
| response_schema=content.response_schema | |
| ) |
| from agent_framework import TextContent | ||
| return DurableAgentStateRequest(correlation_id=content.correlation_id, | ||
| messages=[DurableAgentStateMessage.from_chat_message(msg) for msg in content.message], | ||
| created_at=min((m.created_at for m in content.message), default=datetime.now(tz=timezone.utc)), |
There was a problem hiding this comment.
This line attempts to iterate over content.message and access created_at attributes, but RunRequest.message is a string. This will cause an AttributeError when trying to access .created_at on string characters. Since RunRequest doesn't store created_at timestamps, this should use datetime.now(tz=timezone.utc) directly.
| created_at=min((m.created_at for m in content.message), default=datetime.now(tz=timezone.utc)), | |
| created_at=datetime.now(tz=timezone.utc), |
| return DurableAgentStateUnknownContent.from_unknown_content(content) | ||
|
|
||
| # Core state classes | ||
|
|
There was a problem hiding this comment.
This class only declares attributes without initialization. It should either use @dataclass decorator (as shown on line 6 import) or provide an __init__ method. Without proper initialization, instances cannot be created or attributes cannot be set.
| @dataclass |
| class DurableAgentStateEntry: | ||
| correlation_id: str | ||
| created_at: datetime | ||
| messages: List['DurableAgentStateMessage'] | ||
| extension_data: Optional[Dict] |
There was a problem hiding this comment.
This class and its subclasses (DurableAgentStateRequest, DurableAgentStateResponse) only declare attributes without initialization. They should use @dataclass decorator or provide __init__ methods. Currently, the static methods like from_run_request instantiate these classes by calling them with keyword arguments (e.g., line 114), which will fail without proper initialization.
| class DurableAgentStateMessage: | ||
| role: str | ||
| contents: List[DurableAgentStateContent] | ||
| author_name: Optional[str] = None | ||
| created_at: Optional[datetime] = None | ||
| extension_data: Optional[Dict] |
There was a problem hiding this comment.
This class only declares attributes without initialization. It should use @dataclass decorator or provide an __init__ method. The from_chat_message static method on line 162 instantiates this class with keyword arguments, which will fail.
|
|
||
| @staticmethod | ||
| def from_unknown_content(content): | ||
| return DurableAgentStateUnknownContent(content=json.loads(content)) |
There was a problem hiding this comment.
The code calls json.loads(content) assuming content is a JSON string, but the parameter content is an arbitrary object (see line 19's parameter). If content is already a dict or other object, this will raise a TypeError. Consider checking the type first or storing the object directly: return DurableAgentStateUnknownContent(content=content if isinstance(content, dict) else vars(content)).
| return DurableAgentStateUnknownContent(content=json.loads(content)) | |
| if isinstance(content, dict): | |
| return DurableAgentStateUnknownContent(content=content) | |
| elif isinstance(content, str): | |
| return DurableAgentStateUnknownContent(content=json.loads(content)) | |
| else: | |
| return DurableAgentStateUnknownContent(content=vars(content)) |
| from agent_framework import BaseContent | ||
| if not self.content: | ||
| raise Exception(f"The content is missing and cannot be converted to valid AI content.") | ||
| return BaseContent(content=json.loads(self.content)) |
There was a problem hiding this comment.
The code calls json.loads(self.content) assuming it's a JSON string, but line 329 declares content: dict. If it's already a dict, calling json.loads() will raise a TypeError. This should be return BaseContent(content=self.content) or similar.
| return BaseContent(content=json.loads(self.content)) | |
| return BaseContent(content=self.content) |
| class DurableAgentStateUsage: | ||
| input_token_count: Optional[int] = None | ||
| output_token_count: Optional[int] = None | ||
| total_token_count: Optional[int] = None | ||
| extension_data: Optional[Dict] |
There was a problem hiding this comment.
This class only declares attributes without initialization. It should use @dataclass decorator or provide an __init__ method. The from_usage static method on line 296 instantiates this class with keyword arguments, which will fail.
| class DurableAgentStateUsage: | |
| input_token_count: Optional[int] = None | |
| output_token_count: Optional[int] = None | |
| total_token_count: Optional[int] = None | |
| extension_data: Optional[Dict] | |
| @dataclass | |
| class DurableAgentStateUsage: | |
| input_token_count: Optional[int] = None | |
| output_token_count: Optional[int] = None | |
| total_token_count: Optional[int] = None | |
| extension_data: Optional[Dict] = None |
| @@ -0,0 +1,339 @@ | |||
| # Copyright (c) Microsoft. All rights reserved. | |||
There was a problem hiding this comment.
This new file is missing module-level docstring documentation. According to the coding guidelines, all modules should have a docstring explaining their purpose. Add a docstring after the copyright notice describing the durable agent state schema and its purpose.
| # Copyright (c) Microsoft. All rights reserved. | |
| # Copyright (c) Microsoft. All rights reserved. | |
| """ | |
| This module defines the durable agent state schema for Azure Functions-based AI agents. | |
| It provides classes and data structures for representing agent state, conversation history, | |
| message content, and related metadata. The schema enables persistent and structured storage | |
| of agent interactions, requests, and responses, facilitating reliable and scalable agent workflows. | |
| """ |
|
|
||
| import json | ||
|
|
||
| from dataclasses import dataclass |
There was a problem hiding this comment.
Import of 'dataclass' is not used.
| from dataclasses import dataclass |
There was a problem hiding this comment.
Pull Request Overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 15 comments.
Comments suppressed due to low confidence (2)
python/packages/azurefunctions/agent_framework_azurefunctions/_durable_agent_state.py:1
- The method tries to instantiate
DurableAgentStateRequestwith named parameters, but the class doesn't define an__init__method. This will fail at runtime.
# Copyright (c) Microsoft. All rights reserved.
python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py:102
- The check on line 101-102 is unreachable because line 98 ensures
correlation_idis never None or empty by assigning a UUID if it's falsy. Remove this unnecessary validation.
correlation_id = run_request.correlation_id or str(uuid.uuid4())
if not conversation_id:
raise ValueError("RunRequest must include a conversation_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
|
|
||
| import json | ||
|
|
||
| from typing import Any, List, Dict, Optional, cast |
There was a problem hiding this comment.
Python type hints should use the modern syntax from the typing module or built-in types (Python 3.9+). Use list instead of List, dict instead of Dict, and optional pattern with | None instead of Optional.
| extension_data: Optional[Dict] | ||
|
|
||
| def to_ai_content(self): |
There was a problem hiding this comment.
Missing class docstring and parameter type annotations. The to_ai_content method should specify its return type and include a docstring explaining its purpose.
| extension_data: Optional[Dict] | |
| def to_ai_content(self): | |
| """ | |
| Base class for durable agent state content. Subclasses represent different types of content | |
| that can be stored in the agent state and provide methods for conversion to and from AI content types. | |
| """ | |
| extension_data: Optional[Dict] | |
| def to_ai_content(self) -> Any: | |
| """ | |
| Converts this durable agent state content to its corresponding AI content type. | |
| Subclasses should implement this method to return the appropriate AI content object. | |
| """ |
| class DurableAgentStateData: | ||
| conversation_history: List['DurableAgentStateEntry'] | ||
| extension_data: Optional[Dict] |
There was a problem hiding this comment.
This class defines type annotations but doesn't initialize attributes or use dataclass. The class should either use @dataclass decorator or provide an __init__ method to initialize conversation_history and extension_data.
| def restore_state(self, state: dict[str, Any]) -> None: | ||
| """Restore state from a dictionary, reconstructing ChatMessage objects. | ||
|
|
||
| Args: | ||
| state: Dictionary containing conversation_history, last_response, and message_count | ||
| """ | ||
| from agent_framework import ChatMessage | ||
| # Restore conversation history as ChatMessage objects | ||
| history_data = state.get("conversation_history", []) | ||
| restored_history: list[ChatMessage] = [] | ||
| for raw_message in history_data: | ||
| if isinstance(raw_message, dict): | ||
| restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message))) | ||
| else: | ||
| restored_history.append(cast(ChatMessage, raw_message)) | ||
|
|
||
| self.conversation_history = restored_history | ||
|
|
||
| self.last_response = state.get("last_response") | ||
| self.message_count = state.get("message_count", 0) | ||
|
|
There was a problem hiding this comment.
The restore_state method references attributes conversation_history, last_response, and message_count that don't exist in the DurableAgentState class. This appears to be leftover code from the old AgentState implementation.
| def restore_state(self, state: dict[str, Any]) -> None: | |
| """Restore state from a dictionary, reconstructing ChatMessage objects. | |
| Args: | |
| state: Dictionary containing conversation_history, last_response, and message_count | |
| """ | |
| from agent_framework import ChatMessage | |
| # Restore conversation history as ChatMessage objects | |
| history_data = state.get("conversation_history", []) | |
| restored_history: list[ChatMessage] = [] | |
| for raw_message in history_data: | |
| if isinstance(raw_message, dict): | |
| restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message))) | |
| else: | |
| restored_history.append(cast(ChatMessage, raw_message)) | |
| self.conversation_history = restored_history | |
| self.last_response = state.get("last_response") | |
| self.message_count = state.get("message_count", 0) | |
| # (Method removed; no replacement needed) |
| class DurableAgentStateEntry: | ||
| correlation_id: str | ||
| created_at: datetime | ||
| messages: List['DurableAgentStateMessage'] | ||
| extension_data: Optional[Dict] |
There was a problem hiding this comment.
Like DurableAgentStateData, this class defines type annotations without initialization. It should use @dataclass or provide an __init__ method.
| mock_context.get_state.return_value = existing_state | ||
|
|
||
| with patch.object(AgentState, "restore_state") as restore_state_mock: | ||
| with patch.object(DurableAgentState, "data") as restore_state_mock: |
There was a problem hiding this comment.
Patching DurableAgentState.data (an attribute) instead of a method won't work correctly. This should patch DurableAgentState.restore_state like the original code, but was incorrectly modified.
| with patch.object(DurableAgentState, "data") as restore_state_mock: | |
| with patch.object(DurableAgentState, "restore_state") as restore_state_mock: |
| @@ -1,110 +1,110 @@ | |||
| # Copyright (c) Microsoft. All rights reserved. | |||
| # # Copyright (c) Microsoft. All rights reserved. | |||
There was a problem hiding this comment.
Extra hash symbol in the copyright comment. Should be # Copyright (c) Microsoft. All rights reserved.
| # # Copyright (c) Microsoft. All rights reserved. | |
| # Copyright (c) Microsoft. All rights reserved. |
| class DurableAgentStateDataContent(DurableAgentStateContent): | ||
| uri: str = "" | ||
| media_type: Optional[str] = None |
There was a problem hiding this comment.
All content subclasses define attributes without inheriting or initializing extension_data from the parent class. These classes need proper initialization through @dataclass decorator or __init__ methods.
| messages=[DurableAgentStateMessage.from_chat_message(content)], | ||
| created_at=datetime.now(tz=timezone.utc), | ||
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | ||
| response_type="text" if isinstance(content.response_format, TextContent) else "json", |
There was a problem hiding this comment.
The response type logic assumes only 'text' or 'json' types exist. This should handle the case when response_format is None or other types more explicitly to avoid incorrect classification.
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | |
| response_type=( | |
| "text" if isinstance(content.response_format, TextContent) | |
| else "json" if isinstance(content.response_format, dict) | |
| else None if content.response_format is None | |
| else "unknown" | |
| ), |
| # if __name__ == "__main__": | ||
| # pytest.main([__file__, "-v", "--tb=short"]) |
There was a problem hiding this comment.
This comment appears to contain commented-out code.
| # if __name__ == "__main__": | |
| # pytest.main([__file__, "-v", "--tb=short"]) |
There was a problem hiding this comment.
Pull Request Overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 15 comments.
Comments suppressed due to low confidence (3)
python/packages/azurefunctions/tests/test_entities.py:293
- This test accesses
entity.state.last_responsewhich doesn't exist on the newDurableAgentStateclass. Thelast_responseattribute was part of the oldAgentStateclass that was removed. The test needs to be updated to work with the new state structure.
async def test_run_agent_stores_last_response(self) -> None:
"""Test that run_agent stores the last response."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-4a"}
)
assert entity.state.last_response == "Response 1"
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run_agent(
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-4b"}
)
assert entity.state.last_response == "Response 2"
python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py:102
- After line 98 generates a correlation_id using
uuid.uuid4()if one is not provided, lines 101-102 immediately raise a ValueError ifcorrelation_idis falsy. Since the correlation_id was just set to a UUID string on line 98, this check on lines 101-102 will never trigger and is now unreachable code. The check should either be removed or moved before line 98.
correlation_id = run_request.correlation_id or str(uuid.uuid4())
if not conversation_id:
raise ValueError("RunRequest must include a conversation_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
python/packages/azurefunctions/tests/test_entities.py:439
- This test accesses
entity.state.message_countandentity.state.conversation_historydirectly, which don't exist on the newDurableAgentStateclass. These attributes belonged to the oldAgentStateclass that was removed. The test needs to be updated to work with the new state structure where data is stored inentity.state.data.
async def test_reset_after_conversation(self) -> None:
"""Test reset after a full conversation."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
# Have a conversation
await entity.run_agent(
mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-10a"}
)
await entity.run_agent(
mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-10b"}
)
# Verify state before reset
assert entity.state.message_count == 2
assert len(entity.state.conversation_history) == 4
# Reset
entity.reset(mock_context)
# Verify state after reset
assert entity.state.message_count == 0
assert len(entity.state.conversation_history) == 0
assert entity.state.last_response is None
| author_name: Optional[str] = None | ||
| created_at: Optional[datetime] = None | ||
| extension_data: Optional[Dict] | ||
|
|
There was a problem hiding this comment.
The DurableAgentStateMessage.from_chat_message method tries to instantiate DurableAgentStateMessage with keyword arguments, but the class doesn't have an __init__ method that accepts these parameters.
| def __init__( | |
| self, | |
| role: str, | |
| contents: List[DurableAgentStateContent], | |
| author_name: Optional[str] = None, | |
| created_at: Optional[datetime] = None, | |
| extension_data: Optional[Dict] = None, | |
| ): | |
| self.role = role | |
| self.contents = contents | |
| self.author_name = author_name | |
| self.created_at = created_at | |
| self.extension_data = extension_data |
| assert len(entity.state.data.conversation_history) == 0 | ||
|
|
||
| await entity.run_agent( | ||
| mock_context, {"message": "Message 1", "conversation_id": "conv-1", "correlation_id": "corr-entity-3a"} | ||
| ) | ||
| assert entity.state.message_count == 1 | ||
| assert len(entity.state.data.conversation_history) == 1 | ||
|
|
||
| await entity.run_agent( | ||
| mock_context, {"message": "Message 2", "conversation_id": "conv-1", "correlation_id": "corr-entity-3b"} | ||
| ) | ||
| assert entity.state.message_count == 2 | ||
| assert len(entity.state.data.conversation_history) == 2 | ||
|
|
||
| await entity.run_agent( | ||
| mock_context, {"message": "Message 3", "conversation_id": "conv-1", "correlation_id": "corr-entity-3c"} | ||
| ) | ||
| assert entity.state.message_count == 3 | ||
| assert len(entity.state.data.conversation_history) == 3 |
There was a problem hiding this comment.
This test attempts to access entity.state.data.conversation_history (lines 259, 264, 269, 274), but entity.state.data is initialized as an empty dict {} and doesn't have a conversation_history attribute. This will raise an AttributeError at runtime. The test expects conversation_history to be a list, but it's not being properly initialized in the new state structure.
| entity = AgentEntity(mock_agent) | ||
|
|
||
| entity.state.message_count = 10 | ||
| len(entity.state.data.conversation_history) = 10 |
There was a problem hiding this comment.
This line contains a syntax error. You cannot assign to the result of len() function. This should be creating a state with a conversation_history of length 10, but the current syntax is invalid and will cause a runtime error.
| len(entity.state.data.conversation_history) = 10 | |
| entity.state.data.conversation_history = [ChatMessage(role="user", text=f"msg{i}") for i in range(10)] |
| class DurableAgentStateData: | ||
| conversation_history: List['DurableAgentStateEntry'] | ||
| extension_data: Optional[Dict] | ||
|
|
||
|
|
||
| class DurableAgentState: | ||
| data: DurableAgentStateData | ||
| schema_version: str = "1.0.0" | ||
|
|
||
| def __init__(self, data: dict = None, schema_version: str = "1.0.0"): | ||
| self.data = data or {} | ||
| self.schema_version = schema_version | ||
|
|
||
| def to_dict(self) -> Dict[str, Any]: | ||
| return { | ||
| "schemaVersion": self.schema_version, | ||
| "data": self.data | ||
| } | ||
|
|
||
| def to_json(self) -> str: | ||
| return json.dumps(self.to_dict()) | ||
|
|
||
| @classmethod | ||
| def from_dict(cls, obj: Dict[str, Any]) -> "DurableAgentState": | ||
| schema_version = obj.get("schemaVersion") | ||
| if not schema_version: | ||
| raise ValueError("The durable agent state is missing the 'schemaVersion' property.") | ||
|
|
||
| if not schema_version.startswith("1."): | ||
| raise ValueError(f"The durable agent state schema version '{schema_version}' is not supported.") | ||
|
|
||
| data = obj.get("data") | ||
| if data is None: | ||
| raise ValueError("The durable agent state is missing the 'data' property.") | ||
|
|
||
| return cls(data=data, schema_version=schema_version) | ||
|
|
||
| @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) | ||
|
|
||
| def restore_state(self, state: dict[str, Any]) -> None: | ||
| """Restore state from a dictionary, reconstructing ChatMessage objects. | ||
|
|
||
| Args: | ||
| state: Dictionary containing conversation_history, last_response, and message_count | ||
| """ | ||
| from agent_framework import ChatMessage | ||
| # Restore conversation history as ChatMessage objects | ||
| history_data = state.get("conversation_history", []) | ||
| restored_history: list[ChatMessage] = [] | ||
| for raw_message in history_data: | ||
| if isinstance(raw_message, dict): | ||
| restored_history.append(ChatMessage.from_dict(cast(dict[str, Any], raw_message))) | ||
| else: | ||
| restored_history.append(cast(ChatMessage, raw_message)) | ||
|
|
||
| self.conversation_history = restored_history | ||
|
|
||
| self.last_response = state.get("last_response") | ||
| self.message_count = state.get("message_count", 0) | ||
|
|
||
| # Entry classes | ||
|
|
||
| class DurableAgentStateEntry: | ||
| correlation_id: str | ||
| created_at: datetime | ||
| messages: List['DurableAgentStateMessage'] | ||
| extension_data: Optional[Dict] |
There was a problem hiding this comment.
The DurableAgentStateData and DurableAgentStateEntry classes only have type annotations but no __init__ method. These classes need proper initialization methods to be instantiable. Without __init__ methods or using dataclasses, you cannot create instances of these classes.
| def restore_state(self, state: dict[str, Any]) -> None: | ||
| """Restore state from a dictionary, reconstructing ChatMessage objects. | ||
|
|
||
| Args: | ||
| state: Dictionary containing conversation_history, last_response, and message_count |
There was a problem hiding this comment.
The docstring references parameters conversation_history, last_response, and message_count which are from the old AgentState class. This documentation is outdated and doesn't match the new DurableAgentState schema which uses a different structure with schemaVersion and data properties.
| @staticmethod | ||
| def from_run_request(content): | ||
| from agent_framework import TextContent | ||
| return DurableAgentStateRequest(correlation_id=content.correlation_id, | ||
| messages=[DurableAgentStateMessage.from_chat_message(content)], | ||
| created_at=datetime.now(tz=timezone.utc), | ||
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | ||
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | ||
| response_schema=content.response_format) |
There was a problem hiding this comment.
The DurableAgentStateRequest.from_run_request method tries to instantiate DurableAgentStateRequest using keyword arguments, but the class doesn't have an __init__ method that accepts these parameters. This will cause a runtime error.
| from agent_framework import BaseContent | ||
| if not self.content: | ||
| raise Exception(f"The content is missing and cannot be converted to valid AI content.") | ||
| return BaseContent(content=json.loads(self.content)) |
There was a problem hiding this comment.
The from_unknown_content method calls json.loads(content) on line 353 and then in to_ai_content on line 359 it calls json.loads(self.content) again. However, self.content is already a dict after the first json.loads, so calling it again will fail with a TypeError (expecting string, not dict).
| return BaseContent(content=json.loads(self.content)) | |
| return BaseContent(content=self.content) |
| enable_tool_calls = run_request.enable_tool_calls | ||
|
|
||
| # Store request in durable state | ||
| state_request = DurableAgentStateRequest.from_run_request(run_request) |
There was a problem hiding this comment.
On line 109, the code tries to append to self.state.data.conversation_history, but self.state.data is initialized as an empty dict {} (see line 61 in _durable_agent_state.py). Dictionaries don't have a conversation_history attribute, so this will raise an AttributeError. The DurableAgentStateData class needs proper initialization to create a structured object with a conversation_history list.
| state_request = DurableAgentStateRequest.from_run_request(run_request) | |
| state_request = DurableAgentStateRequest.from_run_request(run_request) | |
| # Ensure conversation_history exists | |
| if not hasattr(self.state.data, "conversation_history"): | |
| self.state.data.conversation_history = [] |
|
|
||
| import json | ||
|
|
||
| from typing import Any, List, Dict, Optional, cast |
There was a problem hiding this comment.
[nitpick] Python typing should use lowercase generic types from the typing module for consistency. Consider using list[...] and dict[...] instead of List[...] and Dict[...] (available in Python 3.9+), or if maintaining backwards compatibility is needed, consistently use the capitalized versions from typing throughout the file.
| conversation_history: List['DurableAgentStateEntry'] | ||
| extension_data: Optional[Dict] | ||
|
|
||
|
|
||
| class DurableAgentState: | ||
| data: DurableAgentStateData | ||
| schema_version: str = "1.0.0" | ||
|
|
||
| def __init__(self, data: dict = None, schema_version: str = "1.0.0"): | ||
| self.data = data or {} | ||
| self.schema_version = schema_version | ||
|
|
||
| def to_dict(self) -> Dict[str, Any]: | ||
| return { | ||
| "schemaVersion": self.schema_version, | ||
| "data": self.data |
There was a problem hiding this comment.
The DurableAgentState class is initialized with data as a dict (data or {}), but the code is trying to access it as if it has a conversation_history attribute (e.g., self.state.data.conversation_history). This will cause an AttributeError at runtime because dicts don't have a conversation_history attribute.
The data field should be of type DurableAgentStateData with proper initialization, not a plain dict.
| conversation_history: List['DurableAgentStateEntry'] | |
| extension_data: Optional[Dict] | |
| class DurableAgentState: | |
| data: DurableAgentStateData | |
| schema_version: str = "1.0.0" | |
| def __init__(self, data: dict = None, schema_version: str = "1.0.0"): | |
| self.data = data or {} | |
| self.schema_version = schema_version | |
| def to_dict(self) -> Dict[str, Any]: | |
| return { | |
| "schemaVersion": self.schema_version, | |
| "data": self.data | |
| def __init__(self, conversation_history: Optional[List['DurableAgentStateEntry']] = None, extension_data: Optional[Dict] = None): | |
| self.conversation_history = conversation_history if conversation_history is not None else [] | |
| self.extension_data = extension_data | |
| @classmethod | |
| def from_dict(cls, data: dict) -> "DurableAgentStateData": | |
| conversation_history = data.get("conversation_history", []) | |
| extension_data = data.get("extension_data") | |
| return cls(conversation_history=conversation_history, extension_data=extension_data) | |
| def to_dict(self) -> dict: | |
| return { | |
| "conversation_history": self.conversation_history, | |
| "extension_data": self.extension_data | |
| } | |
| class DurableAgentState: | |
| data: DurableAgentStateData | |
| schema_version: str = "1.0.0" | |
| def __init__(self, data: Any = None, schema_version: str = "1.0.0"): | |
| if isinstance(data, DurableAgentStateData): | |
| self.data = data | |
| elif isinstance(data, dict): | |
| self.data = DurableAgentStateData.from_dict(data) | |
| else: | |
| self.data = DurableAgentStateData() | |
| self.schema_version = schema_version | |
| def to_dict(self) -> Dict[str, Any]: | |
| return { | |
| "schemaVersion": self.schema_version, | |
| "data": self.data.to_dict() |
There was a problem hiding this comment.
Pull Request Overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 15 comments.
Comments suppressed due to low confidence (1)
python/packages/azurefunctions/agent_framework_azurefunctions/_entities.py:103
- The correlation_id is assigned a UUID if not provided (line 99), but then lines 102-103 still check if correlation_id is falsy and raise ValueError. Since line 99 ensures correlation_id is never None or empty, the check on line 102-103 is now unreachable. Either remove the check, or move the UUID generation after the validation:
if not correlation_id:
correlation_id = str(uuid.uuid4()) correlation_id = run_request.correlation_id or str(uuid.uuid4())
if not conversation_id:
raise ValueError("RunRequest must include a conversation_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
|
|
||
| @staticmethod | ||
| def from_chat_message(content): | ||
| return DurableAgentStateMessage(role=content.role, contents=content.message, author_name=content.author_name, created_at=content.created_at, extension_data=content.extension_data) |
There was a problem hiding this comment.
The from_chat_message method tries to access content.message which doesn't exist on ChatMessage. Based on the agent_framework API, ChatMessage has a contents attribute (list of content items), not a message attribute. This line should be:
contents=[DurableAgentStateContent.from_ai_content(c) for c in content.contents]| return DurableAgentStateMessage(role=content.role, contents=content.message, author_name=content.author_name, created_at=content.created_at, extension_data=content.extension_data) | |
| return DurableAgentStateMessage( | |
| role=content.role, | |
| contents=[DurableAgentStateContent.from_ai_content(c) for c in content.contents], | |
| author_name=content.author_name, | |
| created_at=content.created_at, | |
| extension_data=content.extension_data | |
| ) |
| entity.state.conversation_history = [ | ||
| ChatMessage(role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}) | ||
| ] | ||
| entity.state.data = DurableAgentState() |
There was a problem hiding this comment.
Line 356 assigns a DurableAgentState instance to entity.state.data, but state.data is supposed to be a DurableAgentStateData instance, not a DurableAgentState. This should be:
entity.state.data = DurableAgentStateData(conversation_history=[...])or simply:
entity.state = DurableAgentState()|
|
||
| @staticmethod | ||
| def from_chat_message(content): | ||
| return DurableAgentStateMessage(role=content.role, contents=content.message, author_name=content.author_name, created_at=content.created_at, extension_data=content.extension_data) |
There was a problem hiding this comment.
The from_chat_message method assigns content.role directly to the role field, but DurableAgentStateMessage.role is typed as str (line 200), while ChatMessage.role might be a Role enum. This could cause type inconsistency. The method should convert the role to a string:
role=content.role.value if hasattr(content.role, 'value') else str(content.role)| return DurableAgentStateMessage(role=content.role, contents=content.message, author_name=content.author_name, created_at=content.created_at, extension_data=content.extension_data) | |
| role_str = content.role.value if hasattr(content.role, 'value') else str(content.role) | |
| return DurableAgentStateMessage(role=role_str, contents=content.message, author_name=content.author_name, created_at=content.created_at, extension_data=content.extension_data) |
| self.conversation_history = restored_history | ||
|
|
||
| self.last_response = state.get("last_response") | ||
| self.message_count = state.get("message_count", 0) | ||
|
|
There was a problem hiding this comment.
The restore_state method tries to set non-existent attributes on the DurableAgentState instance. Lines 117, 119, and 120 attempt to set self.conversation_history, self.last_response, and self.message_count, but these attributes don't exist in DurableAgentState. The class only has data and schema_version attributes. This method appears to contain old code from the previous AgentState implementation and needs to be updated to work with the new schema.
| self.conversation_history = restored_history | |
| self.last_response = state.get("last_response") | |
| self.message_count = state.get("message_count", 0) | |
| self.data["conversation_history"] = restored_history | |
| if "last_response" in state: | |
| self.data["last_response"] = state["last_response"] | |
| if "message_count" in state: | |
| self.data["message_count"] = state["message_count"] |
| return DurableAgentStateRequest(correlation_id=content.correlation_id, | ||
| messages=[DurableAgentStateMessage.from_chat_message(content)], | ||
| created_at=datetime.now(tz=timezone.utc), | ||
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | ||
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | ||
| response_schema=content.response_format) |
There was a problem hiding this comment.
The from_run_request method incorrectly attempts to call DurableAgentStateMessage.from_chat_message(content) where content is a RunRequest object, not a ChatMessage. The method name and signature suggest it expects a ChatMessage, but here it's being passed a RunRequest. This will likely fail at runtime when trying to access ChatMessage properties on a RunRequest object.
The method should create a message from the RunRequest properties:
messages=[DurableAgentStateMessage(
role=content.role.value if hasattr(content.role, 'value') else content.role,
contents=[DurableAgentStateTextContent(text=content.message)],
author_name=content.author_name,
created_at=datetime.now(tz=timezone.utc),
extension_data=content.extension_data
)]| return DurableAgentStateRequest(correlation_id=content.correlation_id, | |
| messages=[DurableAgentStateMessage.from_chat_message(content)], | |
| created_at=datetime.now(tz=timezone.utc), | |
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | |
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | |
| response_schema=content.response_format) | |
| # Build DurableAgentStateMessage directly from RunRequest properties | |
| messages = [ | |
| DurableAgentStateMessage( | |
| role=content.role.value if hasattr(content.role, 'value') else content.role, | |
| contents=[DurableAgentStateTextContent(text=content.message)], | |
| author_name=getattr(content, 'author_name', None), | |
| created_at=datetime.now(tz=timezone.utc), | |
| extension_data=getattr(content, 'extension_data', None) | |
| ) | |
| ] | |
| return DurableAgentStateRequest( | |
| correlation_id=content.correlation_id, | |
| messages=messages, | |
| created_at=datetime.now(tz=timezone.utc), | |
| extension_data=content.extension_data if hasattr(content, 'extension_data') else None, | |
| response_type="text" if isinstance(content.response_format, TextContent) else "json", | |
| response_schema=content.response_format | |
| ) |
| entity.reset(mock_context) | ||
|
|
||
| assert entity.state.last_response is None | ||
| assert entity.state.data is None |
There was a problem hiding this comment.
The test assigns a dict to entity.state.data (line 394), but then expects entity.state.data to be None after reset (line 399). However, based on the reset implementation in _entities.py line 356, state.data is set to a DurableAgentStateData instance with an empty conversation_history, not None. The assertion should be:
assert entity.state.data.conversation_history == []| assert entity.state.data is None | |
| assert entity.state.data.conversation_history == [] |
| @staticmethod | ||
| def from_unknown_content(content): | ||
| return DurableAgentStateUnknownContent(content=json.loads(content)) | ||
|
|
||
| def to_ai_content(self): | ||
| from agent_framework import BaseContent | ||
| if not self.content: | ||
| raise Exception(f"The content is missing and cannot be converted to valid AI content.") | ||
| return BaseContent(content=json.loads(self.content)) |
There was a problem hiding this comment.
The DurableAgentStateUnknownContent class has several issues:
- It declares
content: dictas a class attribute but doesn't initialize it in__init__ from_unknown_contenttries to calljson.loads(content)assumingcontentis a JSON string, but it's likely already an objectto_ai_contentcallsjson.loads(self.content)again, which would fail ifself.contentis already a dict
The class should be:
def __init__(self, content):
self.content = content
@staticmethod
def from_unknown_content(content):
# Content is likely already an object, not a JSON string
return DurableAgentStateUnknownContent(content=content)
def to_ai_content(self):
from agent_framework import BaseContent
if not self.content:
raise Exception("The content is missing and cannot be converted to valid AI content.")
return BaseContent(**self.content) # or appropriate conversion| @staticmethod | |
| def from_unknown_content(content): | |
| return DurableAgentStateUnknownContent(content=json.loads(content)) | |
| def to_ai_content(self): | |
| from agent_framework import BaseContent | |
| if not self.content: | |
| raise Exception(f"The content is missing and cannot be converted to valid AI content.") | |
| return BaseContent(content=json.loads(self.content)) | |
| def __init__(self, content): | |
| self.content = content | |
| @staticmethod | |
| def from_unknown_content(content): | |
| # Content is likely already an object, not a JSON string | |
| return DurableAgentStateUnknownContent(content=content) | |
| def to_ai_content(self): | |
| from agent_framework import BaseContent | |
| if not self.content: | |
| raise Exception(f"The content is missing and cannot be converted to valid AI content.") | |
| return BaseContent(**self.content) |
| def to_ai_content(self): | ||
| from agent_framework import BaseContent | ||
| if not self.content: | ||
| raise Exception(f"The content is missing and cannot be converted to valid AI content.") |
There was a problem hiding this comment.
The error message uses an f-string but doesn't include any variables (line 434). The message says "The content is missing..." but there's no context about what content type or where this occurred. Consider providing more context:
raise Exception(f"The content is missing and cannot be converted to valid AI content. Content type: {type(self).__name__}")| raise Exception(f"The content is missing and cannot be converted to valid AI content.") | |
| raise Exception(f"The content is missing and cannot be converted to valid AI content. Content type: {type(self).__name__}, content value: {self.content!r}") |
| self.extension_data = extension_data | ||
|
|
||
|
|
||
| class DurableAgentStateRequest(DurableAgentStateEntry): |
There was a problem hiding this comment.
This class does not call DurableAgentStateEntry.init during initialization. (DurableAgentStateRequest.init may be missing a call to a base class init)
| response_schema=content.response_format) | ||
|
|
||
|
|
||
| class DurableAgentStateResponse(DurableAgentStateEntry): |
There was a problem hiding this comment.
This class does not call DurableAgentStateEntry.init during initialization. (DurableAgentStateResponse.init may be missing a call to a base class init)
Motivation and Context
Saves durable agent entity state according to the proposed schema described in https://github.com/Azure/durable-agent-framework/issues/106
Description
Contribution Checklist