Python: Complete durableagent package (#3058)

* Add worker and clients

* Clean code and refactor common code

* Implement sample

* Add sample

* Update readmes

* Fix tests

* Fix tests

* Update requirements

* Fix typo

* Address comments

* use response.text
This commit is contained in:
Laveesh Rohra
2026-01-07 13:53:21 -08:00
committed by GitHub
Unverified
parent a5b36dc379
commit e3eff65a6b
46 changed files with 4477 additions and 1644 deletions
@@ -2,10 +2,9 @@
import importlib.metadata
from agent_framework_durabletask import AgentCallbackContext, AgentResponseCallbackProtocol
from agent_framework_durabletask import AgentCallbackContext, AgentResponseCallbackProtocol, DurableAIAgent
from ._app import AgentFunctionApp
from ._orchestration import DurableAIAgent
try:
__version__ = importlib.metadata.version(__name__)
@@ -8,6 +8,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
import json
import re
import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -28,14 +29,16 @@ from agent_framework_durabletask import (
WAIT_FOR_RESPONSE_FIELD,
WAIT_FOR_RESPONSE_HEADER,
AgentResponseCallbackProtocol,
AgentSessionId,
ApiResponseFields,
DurableAgentState,
DurableAIAgent,
RunRequest,
)
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._models import AgentSessionId
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
logger = get_logger("agent_framework.azurefunctions")
@@ -296,7 +299,7 @@ class AgentFunctionApp(DFAppBase):
self,
context: AgentOrchestrationContextType,
agent_name: str,
) -> DurableAIAgent:
) -> DurableAIAgent[AgentTask]:
"""Return a DurableAIAgent proxy for a registered agent.
Args:
@@ -307,14 +310,15 @@ class AgentFunctionApp(DFAppBase):
ValueError: If the requested agent has not been registered.
Returns:
DurableAIAgent wrapper bound to the orchestration context.
DurableAIAgent[AgentTask] wrapper bound to the orchestration context.
"""
normalized_name = str(agent_name)
if normalized_name not in self._agent_metadata:
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
return DurableAIAgent(context, normalized_name)
executor = AzureFunctionsAgentExecutor(context)
return DurableAIAgent(executor, normalized_name)
def _setup_agent_functions(
self,
@@ -377,8 +381,6 @@ class AgentFunctionApp(DFAppBase):
"enable_tool_calls": true|false (optional, default: true)
}
"""
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
thread_id: str | None = None
@@ -387,9 +389,9 @@ class AgentFunctionApp(DFAppBase):
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
logger.debug(f"[HTTP Trigger] Message: {message}")
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
logger.debug(
f"[HTTP Trigger] Message: {message}, Thread ID: {thread_id}, wait_for_response: {wait_for_response}"
)
if not message:
logger.warning("[HTTP Trigger] Request rejected: Missing message")
@@ -403,15 +405,18 @@ class AgentFunctionApp(DFAppBase):
session_id = self._create_session_id(agent_name, thread_id)
correlation_id = self._generate_unique_id()
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}")
logger.debug("[HTTP Trigger] Calling entity to run agent...")
logger.debug(
f"[HTTP Trigger] Calling entity to run agent using session ID: {session_id} "
f"and correlation ID: {correlation_id}"
)
entity_instance_id = session_id.to_entity_id()
entity_instance_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
run_request = self._build_request_data(
req_body,
message,
thread_id,
correlation_id,
request_response_format,
)
@@ -624,14 +629,16 @@ class AgentFunctionApp(DFAppBase):
session_id = AgentSessionId.with_random_key(agent_name)
# Build entity instance ID
entity_instance_id = session_id.to_entity_id()
entity_instance_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
# Create run request
correlation_id = self._generate_unique_id()
run_request = self._build_request_data(
req_body={"message": query, "role": "user"},
message=query,
thread_id=str(session_id),
correlation_id=correlation_id,
request_response_format=REQUEST_RESPONSE_FORMAT_TEXT,
)
@@ -783,7 +790,7 @@ class AgentFunctionApp(DFAppBase):
agent_response = state.try_get_agent_response(correlation_id)
if agent_response:
result = self._build_success_result(
response_data=agent_response,
response_message=agent_response.text,
message=message,
thread_id=thread_id,
correlation_id=correlation_id,
@@ -829,23 +836,22 @@ class AgentFunctionApp(DFAppBase):
)
def _build_success_result(
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
self, response_message: str, message: str, thread_id: str, correlation_id: str, state: DurableAgentState
) -> dict[str, Any]:
"""Build the success result returned to the HTTP caller."""
return self._build_response_payload(
response=response_data.get("content"),
response=response_message,
message=message,
thread_id=thread_id,
status="success",
correlation_id=correlation_id,
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
extra_fields={ApiResponseFields.MESSAGE_COUNT: state.message_count},
)
def _build_request_data(
self,
req_body: dict[str, Any],
message: str,
thread_id: str,
correlation_id: str,
request_response_format: str,
) -> dict[str, Any]:
@@ -912,15 +918,13 @@ class AgentFunctionApp(DFAppBase):
def _generate_unique_id(self) -> str:
"""Generate a new unique identifier."""
import uuid
return uuid.uuid4().hex
def _create_session_id(self, func_name: str, thread_id: str | None) -> AgentSessionId:
def _create_session_id(self, agent_name: str, thread_id: str | None) -> AgentSessionId:
"""Create a session identifier using the provided thread id or a random value."""
if thread_id:
return AgentSessionId(name=func_name, key=thread_id)
return AgentSessionId.with_random_key(name=func_name)
return AgentSessionId(name=agent_name, key=thread_id)
return AgentSessionId.with_random_key(name=agent_name)
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
"""Retrieve the thread identifier from request body or query parameters."""
@@ -1,201 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Functions-specific data models for Durable Agent Framework.
This module contains Azure Functions-specific models:
- AgentSessionId: Entity ID management for Azure Durable Entities
- DurableAgentThread: Thread implementation that tracks AgentSessionId
Common models like RunRequest have been moved to agent-framework-durabletask.
"""
from __future__ import annotations
import uuid
from collections.abc import MutableMapping
from dataclasses import dataclass
from typing import Any
import azure.durable_functions as df
from agent_framework import AgentThread
@dataclass
class AgentSessionId:
"""Represents an agent session ID, which is used to identify a long-running agent session.
Attributes:
name: The name of the agent that owns the session (case-insensitive)
key: The unique key of the agent session (case-sensitive)
"""
name: str
key: str
ENTITY_NAME_PREFIX: str = "dafx-"
@staticmethod
def to_entity_name(name: str) -> str:
"""Converts an agent name to an entity name by adding the DAFx prefix.
Args:
name: The agent name
Returns:
The entity name with the dafx- prefix
"""
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
@staticmethod
def with_random_key(name: str) -> AgentSessionId:
"""Creates a new AgentSessionId with the specified name and a randomly generated key.
Args:
name: The name of the agent that owns the session
Returns:
A new AgentSessionId with the specified name and a random GUID key
"""
return AgentSessionId(name=name, key=uuid.uuid4().hex)
def to_entity_id(self) -> df.EntityId:
"""Converts this AgentSessionId to a Durable Functions EntityId.
Returns:
EntityId for use with Durable Functions APIs
"""
return df.EntityId(self.to_entity_name(self.name), self.key)
@staticmethod
def from_entity_id(entity_id: df.EntityId) -> AgentSessionId:
"""Creates an AgentSessionId from a Durable Functions EntityId.
Args:
entity_id: The EntityId to convert
Returns:
AgentSessionId instance
Raises:
ValueError: If the entity ID does not have the expected prefix
"""
if not entity_id.name.startswith(AgentSessionId.ENTITY_NAME_PREFIX):
raise ValueError(
f"'{entity_id}' is not a valid agent session ID. "
f"Expected entity name to start with '{AgentSessionId.ENTITY_NAME_PREFIX}'"
)
agent_name = entity_id.name[len(AgentSessionId.ENTITY_NAME_PREFIX) :]
return AgentSessionId(name=agent_name, key=entity_id.key)
def __str__(self) -> str:
"""Returns a string representation in the form @name@key."""
return f"@{self.name}@{self.key}"
def __repr__(self) -> str:
"""Returns a detailed string representation."""
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
@staticmethod
def parse(session_id_string: str) -> AgentSessionId:
"""Parses a string representation of an agent session ID.
Args:
session_id_string: A string in the form @name@key
Returns:
AgentSessionId instance
Raises:
ValueError: If the string format is invalid
"""
if not session_id_string.startswith("@"):
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
parts = session_id_string[1:].split("@", 1)
if len(parts) != 2:
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
return AgentSessionId(name=parts[0], key=parts[1])
class DurableAgentThread(AgentThread):
"""Durable agent thread that tracks the owning :class:`AgentSessionId`."""
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
def __init__(
self,
*,
session_id: AgentSessionId | None = None,
service_thread_id: str | None = None,
message_store: Any = None,
context_provider: Any = None,
) -> None:
super().__init__(
service_thread_id=service_thread_id,
message_store=message_store,
context_provider=context_provider,
)
self._session_id: AgentSessionId | None = session_id
@property
def session_id(self) -> AgentSessionId | None:
"""Returns the durable agent session identifier for this thread."""
return self._session_id
def attach_session(self, session_id: AgentSessionId) -> None:
"""Associates the thread with the provided :class:`AgentSessionId`."""
self._session_id = session_id
@classmethod
def from_session_id(
cls,
session_id: AgentSessionId,
*,
service_thread_id: str | None = None,
message_store: Any = None,
context_provider: Any = None,
) -> DurableAgentThread:
"""Creates a durable thread pre-associated with the supplied session ID."""
return cls(
session_id=session_id,
service_thread_id=service_thread_id,
message_store=message_store,
context_provider=context_provider,
)
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
"""Serializes thread state including the durable session identifier."""
state = await super().serialize(**kwargs)
if self._session_id is not None:
state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id)
return state
@classmethod
async def deserialize(
cls,
serialized_thread_state: MutableMapping[str, Any],
*,
message_store: Any = None,
**kwargs: Any,
) -> DurableAgentThread:
"""Restores a durable thread, rehydrating the stored session identifier."""
state_payload = dict(serialized_thread_state)
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
thread = await super().deserialize(
state_payload,
message_store=message_store,
**kwargs,
)
if not isinstance(thread, DurableAgentThread):
raise TypeError("Deserialized thread is not a DurableAgentThread instance")
if session_id_value is None:
return thread
if not isinstance(session_id_value, str):
raise ValueError("durable_session_id must be a string when present in serialized state")
thread.attach_session(AgentSessionId.parse(session_id_value))
return thread
@@ -5,25 +5,21 @@
This module provides support for using agents inside Durable Function orchestrations.
"""
import uuid
from collections.abc import AsyncIterator, Callable
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatMessage,
get_logger,
import azure.durable_functions as df
from agent_framework import AgentThread, get_logger
from agent_framework_durabletask import (
DurableAgentExecutor,
RunRequest,
ensure_response_format,
load_agent_response,
)
from agent_framework_durabletask import RunRequest
from azure.durable_functions.models import TaskBase
from azure.durable_functions.models.Task import CompoundTask, TaskState
from pydantic import BaseModel
from ._models import AgentSessionId, DurableAgentThread
logger = get_logger("agent_framework.azurefunctions.orchestration")
CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None
@@ -96,10 +92,10 @@ class AgentTask(_TypedCompoundTask):
)
try:
response = self._load_agent_response(raw_result)
response = load_agent_response(raw_result)
if self._response_format is not None:
self._ensure_response_format(
ensure_response_format(
self._response_format,
self._correlation_id,
response,
@@ -119,249 +115,60 @@ class AgentTask(_TypedCompoundTask):
self._first_error = child.result
self.set_value(is_error=True, value=self._first_error)
def _load_agent_response(self, agent_response: AgentRunResponse | dict[str, Any] | None) -> AgentRunResponse:
"""Convert raw payloads into AgentRunResponse instance."""
if agent_response is None:
raise ValueError("agent_response cannot be None")
logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response))
class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]):
"""Executor that executes durable agents inside Azure Functions orchestrations."""
if isinstance(agent_response, AgentRunResponse):
return agent_response
if isinstance(agent_response, dict):
logger.debug("[load_agent_response] Converting dict payload using AgentRunResponse.from_dict")
return AgentRunResponse.from_dict(agent_response)
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
def _ensure_response_format(
self,
response_format: type[BaseModel] | None,
correlation_id: str,
response: AgentRunResponse,
) -> None:
"""Ensure the AgentRunResponse value is parsed into the expected response_format."""
if response_format is not None and not isinstance(response.value, response_format):
response.try_parse_value(response_format)
logger.debug(
"[DurableAIAgent] Loaded AgentRunResponse.value for correlation_id %s with type: %s",
correlation_id,
type(response.value).__name__,
)
class DurableAIAgent(AgentProtocol):
"""A durable agent implementation that uses entity methods to interact with agent entities.
This class implements AgentProtocol and provides methods to work with Azure Durable Functions
orchestrations, which use generators and yield instead of async/await.
Key methods:
- get_new_thread(): Create a new conversation thread
- run(): Execute the agent and return a Task for yielding in orchestrations
Note: The run() method is NOT async. It returns a Task directly that must be
yielded in orchestrations to wait for the entity call to complete.
Example usage in orchestration:
writer = app.get_agent(context, "WriterAgent")
thread = writer.get_new_thread() # NOT yielded - returns immediately
response = yield writer.run( # Yielded - waits for entity call
message="Write a haiku about coding",
thread=thread
)
"""
def __init__(self, context: AgentOrchestrationContextType, agent_name: str):
"""Initialize the DurableAIAgent.
Args:
context: The orchestration context
agent_name: Name of the agent (used to construct entity ID)
"""
def __init__(self, context: AgentOrchestrationContextType):
self.context = context
self.agent_name = agent_name
self._id = str(uuid.uuid4())
self._name = agent_name
self._display_name = agent_name
self._description = f"Durable agent proxy for {agent_name}"
logger.debug("[DurableAIAgent] Initialized for agent: %s", agent_name)
@property
def id(self) -> str:
"""Get the unique identifier for this agent."""
return self._id
def generate_unique_id(self) -> str:
return str(self.context.new_uuid())
@property
def name(self) -> str | None:
"""Get the name of the agent."""
return self._name
@property
def display_name(self) -> str:
"""Get the display name of the agent."""
return self._display_name
@property
def description(self) -> str | None:
"""Get the description of the agent."""
return self._description
# We return an AgentTask here which is a TaskBase subclass.
# This is an intentional deviation from AgentProtocol which defines run() as async.
# The AgentTask can be yielded in Durable Functions orchestrations and will provide
# a typed AgentRunResponse result.
def run( # type: ignore[override]
def get_run_request(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
response_format: type[BaseModel] | None = None,
**kwargs: Any,
) -> AgentTask:
"""Execute the agent with messages and return an AgentTask for orchestrations.
This method implements AgentProtocol and returns an AgentTask (subclass of TaskBase)
that can be yielded in Durable Functions orchestrations. The task's result will be
a typed AgentRunResponse.
Args:
messages: The message(s) to send to the agent
thread: Optional agent thread for conversation context
response_format: Optional Pydantic model for response parsing
**kwargs: Additional arguments (enable_tool_calls)
message: str,
response_format: type[BaseModel] | None,
enable_tool_calls: bool,
) -> RunRequest:
"""Get the current run request from the orchestration context.
Returns:
An AgentTask that resolves to an AgentRunResponse when yielded
Example:
@app.orchestration_trigger(context_name="context")
def my_orchestration(context):
agent = app.get_agent(context, "MyAgent")
thread = agent.get_new_thread()
response = yield agent.run("Hello", thread=thread)
# response is typed as AgentRunResponse
RunRequest: The current run request
"""
message_str = self._normalize_messages(messages)
request = super().get_run_request(
message,
response_format,
enable_tool_calls,
)
request.orchestration_id = self.context.instance_id
return request
# Extract optional parameters from kwargs
enable_tool_calls = kwargs.get("enable_tool_calls", True)
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
) -> AgentTask:
# Get the session ID for the entity
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
session_id = thread.session_id
else:
# Create a unique session ID for each call when no thread is provided
# This ensures each call gets its own conversation context
session_key = str(self.context.new_uuid())
session_id = AgentSessionId(name=self.agent_name, key=session_key)
logger.debug("[DurableAIAgent] No thread provided, created unique session_id: %s", session_id)
# Resolve session
session_id = self._create_session_id(agent_name, thread)
# Create entity ID from session ID
entity_id = session_id.to_entity_id()
entity_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
# Generate a deterministic correlation ID for this call
# This is required by the entity and must be unique per call
correlation_id = str(self.context.new_uuid())
logger.debug(
"[DurableAIAgent] Using correlation_id: %s for entity_id: %s for session_id: %s",
correlation_id,
"[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s",
run_request.correlation_id,
entity_id,
session_id,
)
# Prepare the request using RunRequest model
# Include the orchestration's instance_id so it can be stored in the agent's entity state
run_request = RunRequest(
message=message_str,
enable_tool_calls=enable_tool_calls,
correlation_id=correlation_id,
response_format=response_format,
orchestration_id=self.context.instance_id,
created_at=self.context.current_utc_datetime,
)
logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100])
# Call the entity to get the underlying task
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
# Wrap it in an AgentTask that will convert the result to AgentRunResponse
agent_task = AgentTask(
return AgentTask(
entity_task=entity_task,
response_format=response_format,
correlation_id=correlation_id,
response_format=run_request.response_format,
correlation_id=run_request.correlation_id,
)
logger.debug(
"[DurableAIAgent] Created AgentTask for correlation_id %s",
correlation_id,
)
return agent_task
def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterator[AgentRunResponseUpdate]:
"""Run the agent with streaming (not supported for durable agents).
Raises:
NotImplementedError: Streaming is not supported for durable agents.
"""
raise NotImplementedError("Streaming is not supported for durable agents in orchestrations.")
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Create a new agent thread for this orchestration instance.
Each call creates a unique thread with its own conversation context.
The session ID is deterministic (uses context.new_uuid()) to ensure
orchestration replay works correctly.
Returns:
A new AgentThread instance with a unique session ID
"""
# Generate a deterministic unique key for this thread
# Using context.new_uuid() ensures the same GUID is generated during replay
session_key = str(self.context.new_uuid())
# Create AgentSessionId with agent name and session key
session_id = AgentSessionId(name=self.agent_name, key=session_key)
thread = DurableAgentThread.from_session_id(session_id, **kwargs)
logger.debug("[DurableAIAgent] Created new thread with session_id: %s", session_id)
return thread
def _messages_to_string(self, messages: list[ChatMessage]) -> str:
"""Convert a list of ChatMessage objects to a single string.
Args:
messages: List of ChatMessage objects
Returns:
Concatenated string of message contents
"""
return "\n".join([msg.text or "" for msg in messages])
def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str:
"""Convert supported message inputs to a single string."""
if messages is None:
return ""
if isinstance(messages, str):
return messages
if isinstance(messages, ChatMessage):
return messages.text or ""
if isinstance(messages, list):
if not messages:
return ""
first_item = messages[0]
if isinstance(first_item, str):
return "\n".join(cast(list[str], messages))
return self._messages_to_string(cast(list[ChatMessage], messages))
return str(messages)