mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix missing methods on the Content class in durable tasks (#4738)
* Fix Content serialization in DurableAgentStateUnknownContent (#4719) DurableAgentStateUnknownContent.from_unknown_content() stored raw Content objects without converting them to dicts, causing json.dumps to fail in Azure Durable Functions' entity state serialization. This affected content types not explicitly handled (e.g., mcp_server_tool_call/result). The fix converts Content objects to dicts via to_dict() when storing in DurableAgentStateUnknownContent, and restores them via Content.from_dict() in to_ai_content(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add to_json and from_json methods to Content class (#4719) Add to_json() and from_json() methods to the Content class to match the serialization interface provided by SerializationMixin on other model classes. Also fix pre-existing pyright type errors in durabletask's DurableAgentStateUnknownContent.to_ai_content(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: add type guard, remove to_json, add fallback, and tests - Remove Content.to_json() per reviewer request (comment 3) - Add type guard in Content.from_json() for non-dict JSON (comments 1, 4) - Wrap json.JSONDecodeError as ValueError for consistent exception contract - Add try/except fallback in to_ai_content() for invalid Content dicts (comment 5) - Add test_content_to_dict_exclude_none and test_content_to_dict_exclude_fields (comment 2) - Add test_unknown_content_to_ai_content_fallback_on_invalid_type_dict (comment 5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Address review feedback for #4719: review comment fixes * Remove Content.from_json, move logic to consuming code (#4719) Remove the from_json convenience method from Content class per review feedback. This is the same trivial json.loads + from_dict wrapper as to_json which was already removed. Consumers should call json.loads and Content.from_dict directly. Update tests to use Content.from_dict(json.loads(...)) pattern and remove from_json-specific error handling tests (those errors are already covered by json.loads and Content.from_dict). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
192a283c9a
commit
705ed47a0b
@@ -1318,9 +1318,17 @@ class DurableAgentStateUnknownContent(DurableAgentStateContent):
|
||||
|
||||
@staticmethod
|
||||
def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent:
|
||||
if isinstance(content, Content):
|
||||
return DurableAgentStateUnknownContent(content=content.to_dict())
|
||||
return DurableAgentStateUnknownContent(content=content)
|
||||
|
||||
def to_ai_content(self) -> Content:
|
||||
if not self.content:
|
||||
raise Exception("The content is missing and cannot be converted to valid AI content.")
|
||||
content_value: Any = self.content
|
||||
if isinstance(content_value, dict) and "type" in content_value:
|
||||
try:
|
||||
return Content.from_dict(cast(dict[str, Any], content_value))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return Content(type=self.type, additional_properties={"content": self.content}) # type: ignore
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
"""Unit tests for DurableAgentState and related classes."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from agent_framework import UsageDetails
|
||||
from agent_framework import Content, Message, UsageDetails
|
||||
|
||||
from agent_framework_durabletask._durable_agent_state import (
|
||||
DurableAgentState,
|
||||
DurableAgentStateContent,
|
||||
DurableAgentStateMessage,
|
||||
DurableAgentStateRequest,
|
||||
DurableAgentStateTextContent,
|
||||
DurableAgentStateUnknownContent,
|
||||
DurableAgentStateUsage,
|
||||
)
|
||||
from agent_framework_durabletask._models import RunRequest
|
||||
@@ -373,5 +376,117 @@ class TestDurableAgentStateUsage:
|
||||
assert restored.get("total_token_count") == original.get("total_token_count")
|
||||
|
||||
|
||||
class TestDurableAgentStateUnknownContent:
|
||||
"""Test suite for DurableAgentStateUnknownContent serialization."""
|
||||
|
||||
def test_unknown_content_from_content_object_produces_serializable_dict(self) -> None:
|
||||
"""Test that from_unknown_content serializes Content objects to dicts."""
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="call-1",
|
||||
tool_name="search",
|
||||
server_name="learn-mcp",
|
||||
arguments={"query": "azure functions"},
|
||||
)
|
||||
|
||||
unknown = DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||
result = unknown.to_dict()
|
||||
|
||||
# The content field should be a dict, not a Content object
|
||||
assert isinstance(result["content"], dict)
|
||||
assert result["content"]["type"] == "mcp_server_tool_call"
|
||||
|
||||
def test_unknown_content_to_dict_is_json_serializable(self) -> None:
|
||||
"""Test that to_dict output can be passed to json.dumps without error."""
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="call-1",
|
||||
output="Azure Functions documentation...",
|
||||
)
|
||||
|
||||
unknown = DurableAgentStateUnknownContent.from_unknown_content(content)
|
||||
result = unknown.to_dict()
|
||||
|
||||
# This must not raise TypeError
|
||||
serialized = json.dumps(result)
|
||||
assert serialized is not None
|
||||
|
||||
def test_unknown_content_round_trip_preserves_content(self) -> None:
|
||||
"""Test that Content objects survive serialization and deserialization."""
|
||||
original = Content.from_mcp_server_tool_call(
|
||||
call_id="call-1",
|
||||
tool_name="fetch",
|
||||
server_name="learn-mcp",
|
||||
arguments={"url": "https://example.com"},
|
||||
)
|
||||
|
||||
unknown = DurableAgentStateUnknownContent.from_unknown_content(original)
|
||||
restored = unknown.to_ai_content()
|
||||
|
||||
assert restored.type == "mcp_server_tool_call"
|
||||
assert restored.tool_name == "fetch"
|
||||
assert restored.server_name == "learn-mcp"
|
||||
|
||||
def test_unknown_content_from_plain_dict_unchanged(self) -> None:
|
||||
"""Test that non-Content values are stored as-is."""
|
||||
plain = {"some": "data"}
|
||||
|
||||
unknown = DurableAgentStateUnknownContent.from_unknown_content(plain)
|
||||
|
||||
assert unknown.content == {"some": "data"}
|
||||
|
||||
def test_unknown_content_to_ai_content_fallback_on_invalid_type_dict(self) -> None:
|
||||
"""Test that to_ai_content falls back when dict has 'type' but is not valid Content."""
|
||||
invalid = {"type": "bogus_not_a_real_content_type", "extra": "stuff"}
|
||||
unknown = DurableAgentStateUnknownContent(content=invalid)
|
||||
|
||||
result = unknown.to_ai_content()
|
||||
|
||||
assert result.type == "unknown"
|
||||
assert result.additional_properties == {"content": invalid}
|
||||
|
||||
def test_from_ai_content_unknown_type_produces_serializable_state(self) -> None:
|
||||
"""Test that unknown content types in message conversion produce JSON-serializable state."""
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="call-1",
|
||||
tool_name="search",
|
||||
server_name="learn-mcp",
|
||||
arguments={"query": "create function app"},
|
||||
)
|
||||
|
||||
durable_content = DurableAgentStateContent.from_ai_content(content)
|
||||
data = durable_content.to_dict()
|
||||
|
||||
# Must be fully JSON-serializable
|
||||
serialized = json.dumps(data)
|
||||
assert serialized is not None
|
||||
|
||||
def test_state_with_mcp_content_is_json_serializable(self) -> None:
|
||||
"""Test that full DurableAgentState with MCP content can be serialized to JSON.
|
||||
|
||||
This reproduces the scenario from issue #4719 where agent state containing
|
||||
MCP tool content could not be serialized by Azure Durable Functions.
|
||||
"""
|
||||
state = DurableAgentState()
|
||||
mcp_content = Content.from_mcp_server_tool_call(
|
||||
call_id="call-1",
|
||||
tool_name="search",
|
||||
server_name="learn-mcp",
|
||||
arguments={"query": "azure functions"},
|
||||
)
|
||||
message = DurableAgentStateMessage.from_chat_message(Message(role="assistant", contents=[mcp_content]))
|
||||
state.data.conversation_history.append(
|
||||
DurableAgentStateRequest(
|
||||
correlation_id="test-mcp",
|
||||
created_at=datetime.now(),
|
||||
messages=[message],
|
||||
)
|
||||
)
|
||||
|
||||
state_dict = state.to_dict()
|
||||
|
||||
# This simulates what Azure Durable Functions does with entity state
|
||||
serialized = json.dumps(state_dict)
|
||||
assert serialized is not None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
|
||||
Reference in New Issue
Block a user