Skip to content

Commit a3ad4a9

Browse files
committed
Merge feature branch into this
2 parents b183daa + 172423a commit a3ad4a9

File tree

15 files changed

+143
-88
lines changed

15 files changed

+143
-88
lines changed

python/packages/azurefunctions/tests/test_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,7 @@ def test_entity_function_handles_exception(self) -> None:
650650

651651
mock_context = Mock()
652652
mock_context.operation_name = "run"
653+
mock_context.operation_name = "run"
653654
mock_context.get_input.side_effect = Exception("Input error")
654655
mock_context.get_state.return_value = None
655656

python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py

Lines changed: 79 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@
3030
from __future__ import annotations
3131

3232
import json
33+
from collections.abc import MutableMapping
3334
from datetime import datetime, timezone
3435
from enum import Enum
35-
from typing import Any, cast
36+
from typing import Any, ClassVar, cast
3637

3738
from agent_framework import (
3839
AgentResponse,
@@ -261,7 +262,7 @@ def to_ai_content(self) -> Any:
261262
"""Convert this durable state content back to an agent framework content object.
262263
263264
Returns:
264-
An agent framework content object (TextContent, FunctionCallContent, etc.)
265+
An agent framework content object (Content of type `text`, `function_call`, etc.)
265266
266267
Raises:
267268
NotImplementedError: Must be implemented by subclasses
@@ -272,38 +273,42 @@ def to_ai_content(self) -> Any:
272273
def from_ai_content(content: Any) -> DurableAgentStateContent:
273274
"""Create a durable state content object from an agent framework content object.
274275
275-
This factory method maps agent framework content types (TextContent, FunctionCallContent,
276-
etc.) to their corresponding durable state representations. Unknown content types are
277-
wrapped in DurableAgentStateUnknownContent.
276+
This factory method maps agent framework content types to their corresponding durable state representations.
277+
Unknown content types are wrapped in DurableAgentStateUnknownContent.
278278
279279
Args:
280-
content: An agent framework content object (TextContent, FunctionCallContent, etc.)
280+
content: An agent framework content object (Content of type `text`, `function_call`, etc.)
281281
282282
Returns:
283283
The corresponding DurableAgentStateContent subclass instance
284284
"""
285285
# Map AI content type to appropriate DurableAgentStateContent subclass
286-
if isinstance(content, Content) and content.type == "data":
287-
return DurableAgentStateDataContent.from_data_content(content)
288-
if isinstance(content, Content) and content.type == "error":
289-
return DurableAgentStateErrorContent.from_error_content(content)
290-
if isinstance(content, Content) and content.type == "function_call":
291-
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
292-
if isinstance(content, Content) and content.type == "function_result":
293-
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
294-
if isinstance(content, Content) and content.type == "hosted_file":
295-
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
296-
if isinstance(content, Content) and content.type == "hosted_vector_store":
297-
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
298-
if isinstance(content, Content) and content.type == "text":
299-
return DurableAgentStateTextContent.from_text_content(content)
300-
if isinstance(content, Content) and content.type == "text_reasoning":
301-
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
302-
if isinstance(content, Content) and content.type == "uri":
303-
return DurableAgentStateUriContent.from_uri_content(content)
304-
if isinstance(content, Content) and content.type == "usage":
305-
return DurableAgentStateUsageContent.from_usage_content(content)
306-
return DurableAgentStateUnknownContent.from_unknown_content(content)
286+
if not isinstance(content, Content):
287+
return DurableAgentStateUnknownContent.from_unknown_content(content)
288+
289+
match content.type:
290+
case "data":
291+
return DurableAgentStateDataContent.from_data_content(content)
292+
case "error":
293+
return DurableAgentStateErrorContent.from_error_content(content)
294+
case "function_call":
295+
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
296+
case "function_result":
297+
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
298+
case "hosted_file":
299+
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
300+
case "hosted_vector_store":
301+
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
302+
case "text":
303+
return DurableAgentStateTextContent.from_text_content(content)
304+
case "reasoning":
305+
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
306+
case "uri":
307+
return DurableAgentStateUriContent.from_uri_content(content)
308+
case "usage":
309+
return DurableAgentStateUsageContent.from_usage_content(content)
310+
case _:
311+
return DurableAgentStateUnknownContent.from_unknown_content(content)
307312

308313

309314
# Core state classes
@@ -871,7 +876,9 @@ def to_dict(self) -> dict[str, Any]:
871876

872877
@staticmethod
873878
def from_data_content(content: Content) -> DurableAgentStateDataContent:
874-
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
879+
if content.uri is None:
880+
raise ValueError("uri is required for data content")
881+
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type)
875882

876883
def to_ai_content(self) -> Content:
877884
return Content.from_uri(uri=self.uri, media_type=self.media_type)
@@ -952,6 +959,10 @@ def to_dict(self) -> dict[str, Any]:
952959

953960
@staticmethod
954961
def from_function_call_content(content: Content) -> DurableAgentStateFunctionCallContent:
962+
if content.call_id is None:
963+
raise ValueError("call_id is required for function call content")
964+
if content.name is None:
965+
raise ValueError("name is required for function call content")
955966
# Ensure arguments is a dict; parse string if needed
956967
arguments: dict[str, Any] = {}
957968
if content.arguments:
@@ -1000,7 +1011,9 @@ def to_dict(self) -> dict[str, Any]:
10001011

10011012
@staticmethod
10021013
def from_function_result_content(content: Content) -> DurableAgentStateFunctionResultContent:
1003-
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result) # type: ignore[arg-type]
1014+
if content.call_id is None:
1015+
raise ValueError("call_id is required for function result content")
1016+
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result)
10041017

10051018
def to_ai_content(self) -> Content:
10061019
return Content.from_function_result(call_id=self.call_id, result=self.result)
@@ -1028,7 +1041,9 @@ def to_dict(self) -> dict[str, Any]:
10281041

10291042
@staticmethod
10301043
def from_hosted_file_content(content: Content) -> DurableAgentStateHostedFileContent:
1031-
return DurableAgentStateHostedFileContent(file_id=content.file_id) # type: ignore[arg-type]
1044+
if content.file_id is None:
1045+
raise ValueError("file_id is required for hosted file content")
1046+
return DurableAgentStateHostedFileContent(file_id=content.file_id)
10321047

10331048
def to_ai_content(self) -> Content:
10341049
return Content.from_hosted_file(file_id=self.file_id)
@@ -1062,7 +1077,9 @@ def to_dict(self) -> dict[str, Any]:
10621077
def from_hosted_vector_store_content(
10631078
content: Content,
10641079
) -> DurableAgentStateHostedVectorStoreContent:
1065-
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id) # type: ignore[arg-type]
1080+
if content.vector_store_id is None:
1081+
raise ValueError("vector_store_id is required for hosted vector store content")
1082+
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id)
10661083

10671084
def to_ai_content(self) -> Content:
10681085
return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id)
@@ -1149,7 +1166,11 @@ def to_dict(self) -> dict[str, Any]:
11491166

11501167
@staticmethod
11511168
def from_uri_content(content: Content) -> DurableAgentStateUriContent:
1152-
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
1169+
if content.uri is None:
1170+
raise ValueError("uri is required for uri content")
1171+
if content.media_type is None:
1172+
raise ValueError("media_type is required for uri content")
1173+
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type)
11531174

11541175
def to_ai_content(self) -> Content:
11551176
return Content.from_uri(uri=self.uri, media_type=self.media_type)
@@ -1169,6 +1190,14 @@ class DurableAgentStateUsage:
11691190
extensionData: Optional additional metadata
11701191
"""
11711192

1193+
# UsageDetails field name constants (snake_case keys from agent_framework.UsageDetails)
1194+
_INPUT_TOKEN_COUNT = "input_token_count" # noqa: S105 # nosec B105
1195+
_OUTPUT_TOKEN_COUNT = "output_token_count" # noqa: S105 # nosec B105
1196+
_TOTAL_TOKEN_COUNT = "total_token_count" # noqa: S105 # nosec B105
1197+
1198+
# Standard fields in UsageDetails that are mapped to dedicated attributes
1199+
_STANDARD_USAGE_FIELDS: ClassVar[set[str]] = {_INPUT_TOKEN_COUNT, _OUTPUT_TOKEN_COUNT, _TOTAL_TOKEN_COUNT}
1200+
11721201
input_token_count: int | None = None
11731202
output_token_count: int | None = None
11741203
total_token_count: int | None = None
@@ -1206,22 +1235,31 @@ def from_dict(cls, data: dict[str, Any]) -> DurableAgentStateUsage:
12061235
)
12071236

12081237
@staticmethod
1209-
def from_usage(usage: UsageDetails | dict[str, int] | None) -> DurableAgentStateUsage | None:
1238+
def from_usage(usage: UsageDetails | MutableMapping[str, int] | None) -> DurableAgentStateUsage | None:
12101239
if usage is None:
12111240
return None
1241+
1242+
# Collect all non-standard fields into extension_data
1243+
extension_data: dict[str, Any] = {
1244+
k: v for k, v in usage.items() if k not in DurableAgentStateUsage._STANDARD_USAGE_FIELDS
1245+
}
1246+
12121247
return DurableAgentStateUsage(
1213-
input_token_count=usage.get("input_token_count"),
1214-
output_token_count=usage.get("output_token_count"),
1215-
total_token_count=usage.get("total_token_count"),
1248+
input_token_count=usage.get(DurableAgentStateUsage._INPUT_TOKEN_COUNT),
1249+
output_token_count=usage.get(DurableAgentStateUsage._OUTPUT_TOKEN_COUNT),
1250+
total_token_count=usage.get(DurableAgentStateUsage._TOTAL_TOKEN_COUNT),
1251+
extensionData=extension_data if extension_data else None,
12161252
)
12171253

12181254
def to_usage_details(self) -> UsageDetails:
12191255
# Convert back to AI SDK UsageDetails
1220-
return {
1221-
"input_token_count": self.input_token_count,
1222-
"output_token_count": self.output_token_count,
1223-
"total_token_count": self.total_token_count,
1224-
}
1256+
1257+
return UsageDetails(
1258+
input_token_count=self.input_token_count,
1259+
output_token_count=self.output_token_count,
1260+
total_token_count=self.total_token_count,
1261+
**self.extensionData if self.extensionData else {},
1262+
)
12251263

12261264

12271265
class DurableAgentStateUsageContent(DurableAgentStateContent):

python/packages/durabletask/agent_framework_durabletask/_entities.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
AgentResponse,
1414
AgentResponseUpdate,
1515
ChatMessage,
16-
ErrorContent,
16+
Content,
1717
Role,
1818
get_logger,
1919
)
@@ -176,7 +176,7 @@ async def run(
176176
logger.exception("[AgentEntity.run] Agent execution failed.")
177177

178178
error_message = ChatMessage(
179-
role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)]
179+
role=Role.ASSISTANT, contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
180180
)
181181
error_response = AgentResponse(messages=[error_message])
182182

python/packages/durabletask/agent_framework_durabletask/_executors.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from datetime import datetime, timezone
1717
from typing import Any, Generic, TypeVar
1818

19-
from agent_framework import AgentResponse, AgentThread, ChatMessage, ErrorContent, Role, TextContent, get_logger
19+
from agent_framework import AgentResponse, AgentThread, ChatMessage, Content, Role, get_logger
2020
from durabletask.client import TaskHubGrpcClient
2121
from durabletask.entities import EntityInstanceId
2222
from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task
@@ -182,7 +182,7 @@ def _create_acceptance_response(self, correlation_id: str) -> AgentResponse:
182182
acceptance_message = ChatMessage(
183183
role=Role.SYSTEM,
184184
contents=[
185-
TextContent(
185+
Content.from_text(
186186
f"Request accepted for processing (correlation_id: {correlation_id}). "
187187
f"Agent is executing in the background. "
188188
f"Retrieve response via your configured streaming or callback mechanism."
@@ -362,7 +362,7 @@ def _handle_agent_response(
362362
error_message = ChatMessage(
363363
role=Role.SYSTEM,
364364
contents=[
365-
ErrorContent(
365+
Content.from_error(
366366
message=f"Error processing agent response: {e}",
367367
error_code="response_processing_error",
368368
)
@@ -377,7 +377,7 @@ def _handle_agent_response(
377377
error_message = ChatMessage(
378378
role=Role.SYSTEM,
379379
contents=[
380-
ErrorContent(
380+
Content.from_error(
381381
message=f"Timeout waiting for agent response after {self.max_poll_retries} attempts",
382382
error_code="response_timeout",
383383
)

python/packages/durabletask/tests/integration_tests/test_02_dt_multi_agent.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from typing import Any
1414

1515
import pytest
16-
from agent_framework import FunctionCallContent
1716
from dt_testutils import create_agent_client
1817

1918
# Agent names from the 02_multi_agent sample
@@ -65,7 +64,7 @@ def test_weather_agent_with_tool(self):
6564

6665
# Verify that the get_weather tool was actually invoked
6766
tool_calls = [
68-
content for msg in response.messages for content in msg.contents if isinstance(content, FunctionCallContent)
67+
content for msg in response.messages for content in msg.contents if content.type == "function_call"
6968
]
7069
assert len(tool_calls) > 0, "Expected at least one tool call"
7170
assert any(call.name == "get_weather" for call in tool_calls), "Expected get_weather tool to be called"
@@ -84,7 +83,7 @@ def test_math_agent_with_tool(self):
8483

8584
# Verify that the calculate_tip tool was actually invoked
8685
tool_calls = [
87-
content for msg in response.messages for content in msg.contents if isinstance(content, FunctionCallContent)
86+
content for msg in response.messages for content in msg.contents if content.type == "function_call"
8887
]
8988
assert len(tool_calls) > 0, "Expected at least one tool call"
9089
assert any(call.name == "calculate_tip" for call in tool_calls), "Expected calculate_tip tool to be called"

python/packages/durabletask/tests/test_durable_entities.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from unittest.mock import AsyncMock, Mock
1212

1313
import pytest
14-
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, ErrorContent, Role
14+
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Content, Role
1515
from pydantic import BaseModel
1616

1717
from agent_framework_durabletask import (
@@ -456,7 +456,7 @@ async def test_run_agent_handles_agent_exception(self) -> None:
456456
assert isinstance(result, AgentResponse)
457457
assert len(result.messages) == 1
458458
content = result.messages[0].contents[0]
459-
assert isinstance(content, ErrorContent)
459+
assert isinstance(content, Content)
460460
assert "Agent failed" in (content.message or "")
461461
assert content.error_code == "Exception"
462462

@@ -472,7 +472,7 @@ async def test_run_agent_handles_value_error(self) -> None:
472472
assert isinstance(result, AgentResponse)
473473
assert len(result.messages) == 1
474474
content = result.messages[0].contents[0]
475-
assert isinstance(content, ErrorContent)
475+
assert isinstance(content, Content)
476476
assert content.error_code == "ValueError"
477477
assert "Invalid input" in str(content.message)
478478

@@ -488,7 +488,7 @@ async def test_run_agent_handles_timeout_error(self) -> None:
488488
assert isinstance(result, AgentResponse)
489489
assert len(result.messages) == 1
490490
content = result.messages[0].contents[0]
491-
assert isinstance(content, ErrorContent)
491+
assert isinstance(content, Content)
492492
assert content.error_code == "TimeoutError"
493493

494494
async def test_run_agent_preserves_message_on_error(self) -> None:
@@ -506,7 +506,7 @@ async def test_run_agent_preserves_message_on_error(self) -> None:
506506
assert isinstance(result, AgentResponse)
507507
assert len(result.messages) == 1
508508
content = result.messages[0].contents[0]
509-
assert isinstance(content, ErrorContent)
509+
assert isinstance(content, Content)
510510

511511

512512
class TestConversationHistory:

python/samples/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,21 @@ The recommended way to use Ollama is via the native `OllamaChatClient` from the
253253
| [`getting_started/azure_functions/05_multi_agent_orchestration_concurrency/`](./getting_started/azure_functions/05_multi_agent_orchestration_concurrency/) | Run two agents concurrently within a durable orchestration and combine their domain-specific outputs. |
254254
| [`getting_started/azure_functions/06_multi_agent_orchestration_conditionals/`](./getting_started/azure_functions/06_multi_agent_orchestration_conditionals/) | Route orchestration logic based on structured agent responses for spam detection and reply drafting. |
255255
| [`getting_started/azure_functions/07_single_agent_orchestration_hitl/`](./getting_started/azure_functions/07_single_agent_orchestration_hitl/) | Implement a human-in-the-loop approval loop that iterates on agent output inside a durable orchestration. |
256+
| [`getting_started/azure_functions/08_mcp_server/`](./getting_started/azure_functions/08_mcp_server/) | Configure agents as both HTTP endpoints and MCP tools for flexible integration patterns. |
257+
258+
## Durable Task
259+
260+
These samples demonstrate durable agent hosting using the Durable Task Scheduler with a worker-client architecture pattern, enabling distributed agent execution with persistent conversation state.
261+
262+
| Sample | Description |
263+
|--------|-------------|
264+
| [`getting_started/durabletask/01_single_agent/`](./getting_started/durabletask/01_single_agent/) | Host a single conversational agent with worker-client architecture and agent state management. |
265+
| [`getting_started/durabletask/02_multi_agent/`](./getting_started/durabletask/02_multi_agent/) | Host multiple domain-specific agents and route requests based on question topic. |
266+
| [`getting_started/durabletask/03_single_agent_streaming/`](./getting_started/durabletask/03_single_agent_streaming/) | Implement reliable streaming using Redis Streams with cursor-based resumption for durable agents. |
267+
| [`getting_started/durabletask/04_single_agent_orchestration_chaining/`](./getting_started/durabletask/04_single_agent_orchestration_chaining/) | Chain multiple agent invocations using durable orchestration while preserving conversation context. |
268+
| [`getting_started/durabletask/05_multi_agent_orchestration_concurrency/`](./getting_started/durabletask/05_multi_agent_orchestration_concurrency/) | Run multiple agents concurrently within an orchestration and aggregate their responses. |
269+
| [`getting_started/durabletask/06_multi_agent_orchestration_conditionals/`](./getting_started/durabletask/06_multi_agent_orchestration_conditionals/) | Implement conditional branching with spam detection using structured outputs and activity functions. |
270+
| [`getting_started/durabletask/07_single_agent_orchestration_hitl/`](./getting_started/durabletask/07_single_agent_orchestration_hitl/) | Human-in-the-loop pattern with external event handling, timeouts, and iterative refinement. |
256271

257272
## Observability
258273

0 commit comments

Comments
 (0)