Skip to content

Python: entity state schema#2096

Closed
hallvictoria wants to merge 12 commits intomicrosoft:feature-azure-functionsfrom
hallvictoria:feature-azure-functions
Closed

Python: entity state schema#2096
hallvictoria wants to merge 12 commits intomicrosoft:feature-azure-functionsfrom
hallvictoria:feature-azure-functions

Conversation

@hallvictoria
Copy link
Contributor

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

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

Copilot AI review requested due to automatic review settings November 11, 2025 18:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py module with dataclass-based state representation supporting JSON serialization/deserialization
  • Updates _entities.py to use DurableAgentState instead of the previous AgentState implementation
  • 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_id is falsy and raises an error, but line 100 already assigns a UUID if correlation_id is None/empty. This validation will never trigger because correlation_id will 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")

Copilot AI review requested due to automatic review settings November 11, 2025 21:37
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 19 comments.

Comment on lines +113 to +119
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)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))].

Suggested change
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
)

Copilot uses AI. Check for mistakes.
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)),
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
created_at=min((m.created_at for m in content.message), default=datetime.now(tz=timezone.utc)),
created_at=datetime.now(tz=timezone.utc),

Copilot uses AI. Check for mistakes.
return DurableAgentStateUnknownContent.from_unknown_content(content)

# Core state classes

Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@dataclass

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +104
class DurableAgentStateEntry:
correlation_id: str
created_at: datetime
messages: List['DurableAgentStateMessage']
extension_data: Optional[Dict]
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +159
class DurableAgentStateMessage:
role: str
contents: List[DurableAgentStateContent]
author_name: Optional[str] = None
created_at: Optional[datetime] = None
extension_data: Optional[Dict]
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

@staticmethod
def from_unknown_content(content):
return DurableAgentStateUnknownContent(content=json.loads(content))
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Suggested change
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))

Copilot uses AI. Check for mistakes.
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))
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return BaseContent(content=json.loads(self.content))
return BaseContent(content=self.content)

Copilot uses AI. Check for mistakes.
Comment on lines +290 to +294
class DurableAgentStateUsage:
input_token_count: Optional[int] = None
output_token_count: Optional[int] = None
total_token_count: Optional[int] = None
extension_data: Optional[Dict]
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,339 @@
# Copyright (c) Microsoft. All rights reserved.
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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.
"""

Copilot uses AI. Check for mistakes.

import json

from dataclasses import dataclass
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'dataclass' is not used.

Suggested change
from dataclasses import dataclass

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings November 11, 2025 22:58
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DurableAgentStateRequest with 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_id is 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
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +14
extension_data: Optional[Dict]

def to_ai_content(self):
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing class docstring and parameter type annotations. The to_ai_content method should specify its return type and include a docstring explaining its purpose.

Suggested change
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.
"""

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +53
class DurableAgentStateData:
conversation_history: List['DurableAgentStateEntry']
extension_data: Optional[Dict]
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +117
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)

Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +124
class DurableAgentStateEntry:
correlation_id: str
created_at: datetime
messages: List['DurableAgentStateMessage']
extension_data: Optional[Dict]
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like DurableAgentStateData, this class defines type annotations without initialization. It should use @dataclass or provide an __init__ method.

Copilot uses AI. Check for mistakes.
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:
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
with patch.object(DurableAgentState, "data") as restore_state_mock:
with patch.object(DurableAgentState, "restore_state") as restore_state_mock:

Copilot uses AI. Check for mistakes.
@@ -1,110 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.
# # Copyright (c) Microsoft. All rights reserved.
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra hash symbol in the copyright comment. Should be # Copyright (c) Microsoft. All rights reserved.

Suggested change
# # Copyright (c) Microsoft. All rights reserved.
# Copyright (c) Microsoft. All rights reserved.

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +193
class DurableAgentStateDataContent(DurableAgentStateContent):
uri: str = ""
media_type: Optional[str] = None
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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",
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"
),

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +110
# if __name__ == "__main__":
# pytest.main([__file__, "-v", "--tb=short"])
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment appears to contain commented-out code.

Suggested change
# if __name__ == "__main__":
# pytest.main([__file__, "-v", "--tb=short"])

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings November 11, 2025 23:10
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_response which doesn't exist on the new DurableAgentState class. The last_response attribute was part of the old AgentState class 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 if correlation_id is 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_count and entity.state.conversation_history directly, which don't exist on the new DurableAgentState class. These attributes belonged to the old AgentState class that was removed. The test needs to be updated to work with the new state structure where data is stored in entity.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]

Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +259 to +274
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
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
entity = AgentEntity(mock_agent)

entity.state.message_count = 10
len(entity.state.data.conversation_history) = 10
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
len(entity.state.data.conversation_history) = 10
entity.state.data.conversation_history = [ChatMessage(role="user", text=f"msg{i}") for i in range(10)]

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +124
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]
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +101
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
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +139
@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)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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))
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
return BaseContent(content=json.loads(self.content))
return BaseContent(content=self.content)

Copilot uses AI. Check for mistakes.
enable_tool_calls = run_request.enable_tool_calls

# Store request in durable state
state_request = DurableAgentStateRequest.from_run_request(run_request)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 = []

Copilot uses AI. Check for mistakes.

import json

from typing import Any, List, Dict, Optional, cast
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +67
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
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings November 11, 2025 23:37
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]
Suggested change
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
)

Copilot uses AI. Check for mistakes.
entity.state.conversation_history = [
ChatMessage(role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"})
]
entity.state.data = DurableAgentState()
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copilot uses AI. Check for mistakes.

@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)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +117 to +121
self.conversation_history = restored_history

self.last_response = state.get("last_response")
self.message_count = state.get("message_count", 0)

Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"]

Copilot uses AI. Check for mistakes.
Comment on lines +152 to +157
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)
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
)]
Suggested change
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
)

Copilot uses AI. Check for mistakes.
entity.reset(mock_context)

assert entity.state.last_response is None
assert entity.state.data is None
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 == []
Suggested change
assert entity.state.data is None
assert entity.state.data.conversation_history == []

Copilot uses AI. Check for mistakes.
Comment on lines +427 to +435
@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))
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DurableAgentStateUnknownContent class has several issues:

  1. It declares content: dict as a class attribute but doesn't initialize it in __init__
  2. from_unknown_content tries to call json.loads(content) assuming content is a JSON string, but it's likely already an object
  3. to_ai_content calls json.loads(self.content) again, which would fail if self.content is 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
Suggested change
@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)

Copilot uses AI. Check for mistakes.
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.")
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__}")
Suggested change
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}")

Copilot uses AI. Check for mistakes.
self.extension_data = extension_data


class DurableAgentStateRequest(DurableAgentStateEntry):
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class does not call DurableAgentStateEntry.init during initialization. (DurableAgentStateRequest.init may be missing a call to a base class init)

Copilot uses AI. Check for mistakes.
response_schema=content.response_format)


class DurableAgentStateResponse(DurableAgentStateEntry):
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class does not call DurableAgentStateEntry.init during initialization. (DurableAgentStateResponse.init may be missing a call to a base class init)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants