Skip to content

Commit 705ed47

Browse files
eavanvalkenburgCopilotCopilot
authored
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>
1 parent 192a283 commit 705ed47

3 files changed

Lines changed: 166 additions & 1 deletion

File tree

python/packages/core/tests/core/test_types.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import base64
4+
import json
45
from collections.abc import AsyncIterable, Sequence
56
from dataclasses import dataclass
67
from datetime import datetime, timezone
@@ -1710,6 +1711,47 @@ def test_content_roundtrip_preserves_compaction_annotation_dict() -> None:
17101711
assert annotation[GROUP_TOKEN_COUNT_KEY] is None
17111712

17121713

1714+
def test_content_from_dict_via_json() -> None:
1715+
"""Test Content.from_dict with data parsed from a JSON string."""
1716+
data = json.loads(json.dumps({"type": "text", "text": "Hello world"}))
1717+
content = Content.from_dict(data)
1718+
assert content.type == "text"
1719+
assert content.text == "Hello world"
1720+
1721+
1722+
def test_content_from_dict_roundtrip_via_json() -> None:
1723+
"""Test Content.from_dict roundtrip via to_dict and json.dumps."""
1724+
original = Content.from_function_call(call_id="call1", name="my_func", arguments={"key": "value"})
1725+
data = json.loads(json.dumps(original.to_dict()))
1726+
restored = Content.from_dict(data)
1727+
assert restored.type == "function_call"
1728+
assert restored.call_id == "call1"
1729+
assert restored.name == "my_func"
1730+
assert restored.arguments == {"key": "value"}
1731+
1732+
1733+
def test_content_to_dict_exclude_none() -> None:
1734+
"""Test Content.to_dict excludes None fields by default."""
1735+
content = Content.from_text("Hello")
1736+
d = content.to_dict()
1737+
parsed = json.loads(json.dumps(d))
1738+
assert "uri" not in parsed
1739+
1740+
d_with_none = content.to_dict(exclude_none=False)
1741+
parsed_with_none = json.loads(json.dumps(d_with_none))
1742+
assert "uri" in parsed_with_none
1743+
assert parsed_with_none["uri"] is None
1744+
1745+
1746+
def test_content_to_dict_exclude_fields() -> None:
1747+
"""Test Content.to_dict with explicit field exclusion."""
1748+
content = Content.from_text("Hello")
1749+
d = content.to_dict(exclude={"text"})
1750+
parsed = json.loads(json.dumps(d))
1751+
assert "text" not in parsed
1752+
assert parsed["type"] == "text"
1753+
1754+
17131755
def test_chat_response_roundtrip_preserves_compaction_annotation_dict() -> None:
17141756
response = ChatResponse(
17151757
messages=[

python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,9 +1318,17 @@ def to_dict(self) -> dict[str, Any]:
13181318

13191319
@staticmethod
13201320
def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent:
1321+
if isinstance(content, Content):
1322+
return DurableAgentStateUnknownContent(content=content.to_dict())
13211323
return DurableAgentStateUnknownContent(content=content)
13221324

13231325
def to_ai_content(self) -> Content:
13241326
if not self.content:
13251327
raise Exception("The content is missing and cannot be converted to valid AI content.")
1328+
content_value: Any = self.content
1329+
if isinstance(content_value, dict) and "type" in content_value:
1330+
try:
1331+
return Content.from_dict(cast(dict[str, Any], content_value))
1332+
except (ValueError, TypeError):
1333+
pass
13261334
return Content(type=self.type, additional_properties={"content": self.content}) # type: ignore

python/packages/durabletask/tests/test_durable_agent_state.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
"""Unit tests for DurableAgentState and related classes."""
44

5+
import json
56
from datetime import datetime
67

78
import pytest
8-
from agent_framework import UsageDetails
9+
from agent_framework import Content, Message, UsageDetails
910

1011
from agent_framework_durabletask._durable_agent_state import (
1112
DurableAgentState,
13+
DurableAgentStateContent,
1214
DurableAgentStateMessage,
1315
DurableAgentStateRequest,
1416
DurableAgentStateTextContent,
17+
DurableAgentStateUnknownContent,
1518
DurableAgentStateUsage,
1619
)
1720
from agent_framework_durabletask._models import RunRequest
@@ -373,5 +376,117 @@ def test_usage_round_trip(self) -> None:
373376
assert restored.get("total_token_count") == original.get("total_token_count")
374377

375378

379+
class TestDurableAgentStateUnknownContent:
380+
"""Test suite for DurableAgentStateUnknownContent serialization."""
381+
382+
def test_unknown_content_from_content_object_produces_serializable_dict(self) -> None:
383+
"""Test that from_unknown_content serializes Content objects to dicts."""
384+
content = Content.from_mcp_server_tool_call(
385+
call_id="call-1",
386+
tool_name="search",
387+
server_name="learn-mcp",
388+
arguments={"query": "azure functions"},
389+
)
390+
391+
unknown = DurableAgentStateUnknownContent.from_unknown_content(content)
392+
result = unknown.to_dict()
393+
394+
# The content field should be a dict, not a Content object
395+
assert isinstance(result["content"], dict)
396+
assert result["content"]["type"] == "mcp_server_tool_call"
397+
398+
def test_unknown_content_to_dict_is_json_serializable(self) -> None:
399+
"""Test that to_dict output can be passed to json.dumps without error."""
400+
content = Content.from_mcp_server_tool_result(
401+
call_id="call-1",
402+
output="Azure Functions documentation...",
403+
)
404+
405+
unknown = DurableAgentStateUnknownContent.from_unknown_content(content)
406+
result = unknown.to_dict()
407+
408+
# This must not raise TypeError
409+
serialized = json.dumps(result)
410+
assert serialized is not None
411+
412+
def test_unknown_content_round_trip_preserves_content(self) -> None:
413+
"""Test that Content objects survive serialization and deserialization."""
414+
original = Content.from_mcp_server_tool_call(
415+
call_id="call-1",
416+
tool_name="fetch",
417+
server_name="learn-mcp",
418+
arguments={"url": "https://example.com"},
419+
)
420+
421+
unknown = DurableAgentStateUnknownContent.from_unknown_content(original)
422+
restored = unknown.to_ai_content()
423+
424+
assert restored.type == "mcp_server_tool_call"
425+
assert restored.tool_name == "fetch"
426+
assert restored.server_name == "learn-mcp"
427+
428+
def test_unknown_content_from_plain_dict_unchanged(self) -> None:
429+
"""Test that non-Content values are stored as-is."""
430+
plain = {"some": "data"}
431+
432+
unknown = DurableAgentStateUnknownContent.from_unknown_content(plain)
433+
434+
assert unknown.content == {"some": "data"}
435+
436+
def test_unknown_content_to_ai_content_fallback_on_invalid_type_dict(self) -> None:
437+
"""Test that to_ai_content falls back when dict has 'type' but is not valid Content."""
438+
invalid = {"type": "bogus_not_a_real_content_type", "extra": "stuff"}
439+
unknown = DurableAgentStateUnknownContent(content=invalid)
440+
441+
result = unknown.to_ai_content()
442+
443+
assert result.type == "unknown"
444+
assert result.additional_properties == {"content": invalid}
445+
446+
def test_from_ai_content_unknown_type_produces_serializable_state(self) -> None:
447+
"""Test that unknown content types in message conversion produce JSON-serializable state."""
448+
content = Content.from_mcp_server_tool_call(
449+
call_id="call-1",
450+
tool_name="search",
451+
server_name="learn-mcp",
452+
arguments={"query": "create function app"},
453+
)
454+
455+
durable_content = DurableAgentStateContent.from_ai_content(content)
456+
data = durable_content.to_dict()
457+
458+
# Must be fully JSON-serializable
459+
serialized = json.dumps(data)
460+
assert serialized is not None
461+
462+
def test_state_with_mcp_content_is_json_serializable(self) -> None:
463+
"""Test that full DurableAgentState with MCP content can be serialized to JSON.
464+
465+
This reproduces the scenario from issue #4719 where agent state containing
466+
MCP tool content could not be serialized by Azure Durable Functions.
467+
"""
468+
state = DurableAgentState()
469+
mcp_content = Content.from_mcp_server_tool_call(
470+
call_id="call-1",
471+
tool_name="search",
472+
server_name="learn-mcp",
473+
arguments={"query": "azure functions"},
474+
)
475+
message = DurableAgentStateMessage.from_chat_message(Message(role="assistant", contents=[mcp_content]))
476+
state.data.conversation_history.append(
477+
DurableAgentStateRequest(
478+
correlation_id="test-mcp",
479+
created_at=datetime.now(),
480+
messages=[message],
481+
)
482+
)
483+
484+
state_dict = state.to_dict()
485+
486+
# This simulates what Azure Durable Functions does with entity state
487+
serialized = json.dumps(state_dict)
488+
assert serialized is not None
489+
490+
376491
if __name__ == "__main__":
377492
pytest.main([__file__, "-v", "--tb=short"])

0 commit comments

Comments
 (0)