chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,142 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Task integration for Microsoft Agent Framework."""
import importlib.metadata
from ._async_bridge import run_agent_coroutine
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._client import DurableAIAgentClient
from ._constants import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
MIMETYPE_APPLICATION_JSON,
MIMETYPE_TEXT_PLAIN,
REQUEST_RESPONSE_FORMAT_JSON,
REQUEST_RESPONSE_FORMAT_TEXT,
THREAD_ID_FIELD,
THREAD_ID_HEADER,
WAIT_FOR_RESPONSE_FIELD,
WAIT_FOR_RESPONSE_HEADER,
ApiResponseFields,
ContentTypes,
DurableStateFields,
)
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateContent,
DurableAgentStateData,
DurableAgentStateDataContent,
DurableAgentStateEntry,
DurableAgentStateEntryJsonType,
DurableAgentStateErrorContent,
DurableAgentStateFunctionCallContent,
DurableAgentStateFunctionResultContent,
DurableAgentStateHostedFileContent,
DurableAgentStateHostedVectorStoreContent,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateResponse,
DurableAgentStateTextContent,
DurableAgentStateTextReasoningContent,
DurableAgentStateUnknownContent,
DurableAgentStateUriContent,
DurableAgentStateUsage,
DurableAgentStateUsageContent,
)
from ._entities import AgentEntity, AgentEntityStateProviderMixin
from ._executors import DurableAgentExecutor
from ._models import AgentSessionId, DurableAgentSession, RunRequest
from ._orchestration_context import DurableAIAgentOrchestrationContext
from ._response_utils import ensure_response_format, load_agent_response
from ._shim import DurableAIAgent
from ._worker import DurableAIAgentWorker
from ._workflows.activity import execute_workflow_activity
from ._workflows.client import DurableWorkflowClient
from ._workflows.context import WorkflowOrchestrationContext
from ._workflows.dt_context import DurableTaskWorkflowContext
from ._workflows.naming import (
DURABLE_NAME_PREFIX,
is_auto_generated_workflow_name,
validate_executor_id,
validate_workflow_name,
workflow_name_from_orchestrator,
workflow_orchestrator_name,
)
from ._workflows.orchestrator import run_workflow_orchestrator
from ._workflows.registration import WorkflowRegistrationPlan, collect_hosted_workflows, plan_workflow_registration
from ._workflows.runner_context import CapturingRunnerContext
from ._workflows.serialization import deserialize_workflow_output
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"DEFAULT_MAX_POLL_RETRIES",
"DEFAULT_POLL_INTERVAL_SECONDS",
"DURABLE_NAME_PREFIX",
"MIMETYPE_APPLICATION_JSON",
"MIMETYPE_TEXT_PLAIN",
"REQUEST_RESPONSE_FORMAT_JSON",
"REQUEST_RESPONSE_FORMAT_TEXT",
"THREAD_ID_FIELD",
"THREAD_ID_HEADER",
"WAIT_FOR_RESPONSE_FIELD",
"WAIT_FOR_RESPONSE_HEADER",
"AgentCallbackContext",
"AgentEntity",
"AgentEntityStateProviderMixin",
"AgentResponseCallbackProtocol",
"AgentSessionId",
"ApiResponseFields",
"CapturingRunnerContext",
"ContentTypes",
"DurableAIAgent",
"DurableAIAgentClient",
"DurableAIAgentOrchestrationContext",
"DurableAIAgentWorker",
"DurableAgentExecutor",
"DurableAgentSession",
"DurableAgentState",
"DurableAgentStateContent",
"DurableAgentStateData",
"DurableAgentStateDataContent",
"DurableAgentStateEntry",
"DurableAgentStateEntryJsonType",
"DurableAgentStateErrorContent",
"DurableAgentStateFunctionCallContent",
"DurableAgentStateFunctionResultContent",
"DurableAgentStateHostedFileContent",
"DurableAgentStateHostedVectorStoreContent",
"DurableAgentStateMessage",
"DurableAgentStateRequest",
"DurableAgentStateResponse",
"DurableAgentStateTextContent",
"DurableAgentStateTextReasoningContent",
"DurableAgentStateUnknownContent",
"DurableAgentStateUriContent",
"DurableAgentStateUsage",
"DurableAgentStateUsageContent",
"DurableStateFields",
"DurableTaskWorkflowContext",
"DurableWorkflowClient",
"RunRequest",
"WorkflowOrchestrationContext",
"WorkflowRegistrationPlan",
"__version__",
"collect_hosted_workflows",
"deserialize_workflow_output",
"ensure_response_format",
"execute_workflow_activity",
"is_auto_generated_workflow_name",
"load_agent_response",
"plan_workflow_registration",
"run_agent_coroutine",
"run_workflow_orchestrator",
"validate_executor_id",
"validate_workflow_name",
"workflow_name_from_orchestrator",
"workflow_orchestrator_name",
]
@@ -0,0 +1,89 @@
# Copyright (c) Microsoft. All rights reserved.
"""Persistent background event loop for running agent coroutines.
Durable entity (and agent) handlers are invoked synchronously by the host on
arbitrary worker threads. Agent clients and their async credentials create
asyncio primitives (locks, connection pools, futures) that are bound to the
event loop on which they are *first* used. Running a later invocation on a
*different* event loop causes those primitives to await futures attached to a
now-idle loop, which results in a silent, permanent hang.
This module provides a single, process-wide persistent event loop running on a
dedicated daemon thread. All agent coroutines are submitted to this loop via
``run_coroutine_threadsafe`` so shared async resources remain valid across
invocations regardless of which worker thread the host happens to use.
"""
from __future__ import annotations
import asyncio
import contextlib
import threading
from collections.abc import Coroutine
from typing import Any, TypeVar
_T = TypeVar("_T")
_loop: asyncio.AbstractEventLoop | None = None
_thread: threading.Thread | None = None
_lock = threading.Lock()
def _ensure_loop() -> asyncio.AbstractEventLoop:
"""Return the shared persistent event loop, starting it on first use.
The loop is only reusable when it is open *and* its backing thread is still
alive. A loop whose thread has died (e.g. during interpreter shutdown) is not
reusable: ``run_coroutine_threadsafe`` would schedule onto a loop that will
never run again and ``future.result()`` would block forever. Such a loop is
replaced with a fresh loop + thread.
"""
global _loop, _thread
loop, thread = _loop, _thread
if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive():
return loop
with _lock:
loop, thread = _loop, _thread
if loop is not None and not loop.is_closed() and thread is not None and thread.is_alive():
return loop
# An existing loop whose thread has died is orphaned; close it best-effort
# before replacing it so it does not leak.
if loop is not None and not loop.is_closed():
with contextlib.suppress(Exception):
loop.close()
new_loop = asyncio.new_event_loop()
def _run() -> None:
asyncio.set_event_loop(new_loop)
new_loop.run_forever()
new_thread = threading.Thread(target=_run, name="dafx-agent-loop", daemon=True)
new_thread.start()
_loop = new_loop
_thread = new_thread
return new_loop
def run_agent_coroutine(coro: Coroutine[Any, Any, _T]) -> _T:
"""Run a coroutine on the shared persistent event loop and return its result.
The calling (worker) thread blocks until the coroutine completes. Because
every agent coroutine runs on the same loop, async resources created by
shared agent clients/credentials (locks, connection pools) remain bound to a
live loop across all invocations, preventing cross-loop hangs.
Args:
coro: The coroutine to execute.
Returns:
The coroutine's result.
"""
loop = _ensure_loop()
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result()
@@ -0,0 +1,40 @@
# Copyright (c) Microsoft. All rights reserved.
"""Callback interfaces for Durable Agent executions.
This module enables callers of AgentFunctionApp to supply streaming and final-response callbacks that are
invoked during durable entity execution.
"""
from dataclasses import dataclass
from typing import Protocol
from agent_framework import AgentResponse, AgentResponseUpdate
@dataclass(frozen=True)
class AgentCallbackContext:
"""Context supplied to callback invocations."""
agent_name: str
correlation_id: str
thread_id: str | None = None
request_message: str | None = None
class AgentResponseCallbackProtocol(Protocol):
"""Protocol describing the callbacks invoked during agent execution."""
async def on_streaming_response_update(
self,
update: AgentResponseUpdate,
context: AgentCallbackContext,
) -> None:
"""Handle a streaming response update emitted by the agent."""
async def on_agent_response(
self,
response: AgentResponse,
context: AgentCallbackContext,
) -> None:
"""Handle the final agent response."""
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client wrapper for Durable Task Agent Framework.
This module provides the DurableAIAgentClient class for external clients to interact
with durable agents via gRPC.
"""
from __future__ import annotations
import logging
from agent_framework import AgentResponse
from durabletask.client import TaskHubGrpcClient
from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
from ._executors import ClientAgentExecutor
from ._shim import DurableAgentProvider, DurableAIAgent
logger = logging.getLogger("agent_framework.durabletask")
class DurableAIAgentClient(DurableAgentProvider[AgentResponse]):
"""Client wrapper for interacting with durable agents externally.
This class wraps a durabletask TaskHubGrpcClient and provides a convenient
interface for retrieving and executing durable agents from external contexts.
Example:
```python
from durabletask import TaskHubGrpcClient
from agent_framework.azure import DurableAIAgentClient
# Create the underlying client
client = TaskHubGrpcClient(host_address="localhost:4001")
# Wrap it with the agent client
agent_client = DurableAIAgentClient(client)
# Get an agent reference
agent = agent_client.get_agent("assistant")
# Run the agent (synchronous call that waits for completion)
response = agent.run("Hello, how are you?")
print(response.text)
```
"""
def __init__(
self,
client: TaskHubGrpcClient,
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
):
"""Initialize the client wrapper.
Args:
client: The durabletask client instance to wrap
max_poll_retries: Maximum polling attempts when waiting for responses
poll_interval_seconds: Delay in seconds between polling attempts
"""
self._client = client
# Validate and set polling parameters
self.max_poll_retries = max(1, max_poll_retries)
self.poll_interval_seconds = (
poll_interval_seconds if poll_interval_seconds > 0 else DEFAULT_POLL_INTERVAL_SECONDS
)
self._executor = ClientAgentExecutor(self._client, self.max_poll_retries, self.poll_interval_seconds)
logger.debug("[DurableAIAgentClient] Initialized with client type: %s", type(client).__name__)
def get_agent(self, agent_name: str) -> DurableAIAgent[AgentResponse]:
"""Retrieve a DurableAIAgent shim for the specified agent.
This method returns a proxy object that can be used to execute the agent.
The actual agent must be registered on a worker with the same name.
Args:
agent_name: Name of the agent to retrieve (without the dafx- prefix)
Returns:
DurableAIAgent instance that can be used to run the agent
Note:
This method does not validate that the agent exists. Validation
will occur when the agent is executed. If the entity doesn't exist,
the execution will fail with an appropriate error.
"""
logger.debug("[DurableAIAgentClient] Creating agent proxy for: %s", agent_name)
return DurableAIAgent(self._executor, agent_name)
@@ -0,0 +1,130 @@
# Copyright (c) Microsoft. All rights reserved.
"""Constants for Azure Functions Agent Framework integration.
This module contains:
- Runtime configuration constants (polling, MIME types, headers)
- JSON field name mappings for camelCase (JSON) ↔ snake_case (Python) serialization
For serialization constants, use the DurableStateFields, ContentTypes, and EntryTypes classes
to ensure consistent field naming between to_dict() and from_dict() methods.
"""
from typing import Final
# Supported request/response formats and MIME types
REQUEST_RESPONSE_FORMAT_JSON: str = "json"
REQUEST_RESPONSE_FORMAT_TEXT: str = "text"
MIMETYPE_APPLICATION_JSON: str = "application/json"
MIMETYPE_TEXT_PLAIN: str = "text/plain"
# Field and header names
THREAD_ID_FIELD: str = "thread_id"
THREAD_ID_HEADER: str = "x-ms-thread-id"
WAIT_FOR_RESPONSE_FIELD: str = "wait_for_response"
WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
# Polling configuration
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
# =============================================================================
# JSON Field Name Constants for Durable Agent State Serialization
# =============================================================================
# These constants ensure consistent camelCase field names in JSON serialization.
# Use these in both to_dict() and from_dict() methods to prevent mismatches.
# NOTE: Changing these constants is a breaking change and might require a schema version bump.
class DurableStateFields:
"""JSON field name constants for durable agent state serialization.
All field names are in camelCase to match the JSON schema.
Use these constants in both to_dict() and from_dict() methods.
"""
# Schema-level fields
SCHEMA_VERSION: Final[str] = "schemaVersion"
DATA: Final[str] = "data"
# Entry discriminator
TYPE_DISCRIMINATOR: Final[str] = "$type"
# Internal field names
JSON_TYPE: Final[str] = "json_type"
TYPE_INTERNAL: Final[str] = "type"
# Common entry fields
CORRELATION_ID: Final[str] = "correlationId"
CREATED_AT: Final[str] = "createdAt"
MESSAGES: Final[str] = "messages"
EXTENSION_DATA: Final[str] = "extensionData"
# Request-specific fields
RESPONSE_TYPE: Final[str] = "responseType"
RESPONSE_SCHEMA: Final[str] = "responseSchema"
ORCHESTRATION_ID: Final[str] = "orchestrationId"
# Response-specific fields
USAGE: Final[str] = "usage"
# Message fields
ROLE: Final[str] = "role"
CONTENTS: Final[str] = "contents"
AUTHOR_NAME: Final[str] = "authorName"
# Content fields
TEXT: Final[str] = "text"
URI: Final[str] = "uri"
MEDIA_TYPE: Final[str] = "mediaType"
MESSAGE: Final[str] = "message"
ERROR_CODE: Final[str] = "errorCode"
DETAILS: Final[str] = "details"
CALL_ID: Final[str] = "callId"
NAME: Final[str] = "name"
ARGUMENTS: Final[str] = "arguments"
RESULT: Final[str] = "result"
FILE_ID: Final[str] = "fileId"
VECTOR_STORE_ID: Final[str] = "vectorStoreId"
CONTENT: Final[str] = "content"
# Usage fields (noqa: S105 - these are JSON field names, not passwords)
INPUT_TOKEN_COUNT: Final[str] = "inputTokenCount" # noqa: S105
OUTPUT_TOKEN_COUNT: Final[str] = "outputTokenCount" # noqa: S105
TOTAL_TOKEN_COUNT: Final[str] = "totalTokenCount" # noqa: S105
# History field
CONVERSATION_HISTORY: Final[str] = "conversationHistory"
class ContentTypes:
"""Content type discriminator values for the $type field.
These values are used in the JSON $type field to identify content types.
"""
TEXT: Final[str] = "text"
DATA: Final[str] = "data"
ERROR: Final[str] = "error"
FUNCTION_CALL: Final[str] = "functionCall"
FUNCTION_RESULT: Final[str] = "functionResult"
HOSTED_FILE: Final[str] = "hostedFile"
HOSTED_VECTOR_STORE: Final[str] = "hostedVectorStore"
REASONING: Final[str] = "reasoning"
URI: Final[str] = "uri"
USAGE: Final[str] = "usage"
UNKNOWN: Final[str] = "unknown"
class ApiResponseFields:
"""Field names for HTTP API responses (not part of persisted schema).
These are used in try_get_agent_response() for backward compatibility
with the HTTP API response format.
"""
CONTENT: Final[str] = "content"
MESSAGE_COUNT: Final[str] = "message_count"
CORRELATION_ID: Final[str] = "correlationId"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Task entity implementations for Microsoft Agent Framework."""
from __future__ import annotations
import inspect
import logging
from datetime import datetime, timezone
from typing import Any, cast
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
Message,
ResponseStream,
SupportsAgentRun,
)
from durabletask.entities import DurableEntity
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateEntry,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateResponse,
)
from ._models import RunRequest
logger = logging.getLogger("agent_framework.durabletask")
class AgentEntityStateProviderMixin:
"""Mixin implementing durable agent state caching + (de)serialization + persistence.
Concrete classes must implement:
- _get_state_dict(): fetch raw persisted state dict (default should be {})
- _set_state_dict(): persist raw state dict
- _get_thread_id_from_entity(): fetch the thread ID from the underlying context
"""
_state_cache: DurableAgentState | None = None
def _get_state_dict(self) -> dict[str, Any]:
raise NotImplementedError
def _set_state_dict(self, state: dict[str, Any]) -> None:
raise NotImplementedError
def _get_thread_id_from_entity(self) -> str:
raise NotImplementedError
@property
def thread_id(self) -> str:
return self._get_thread_id_from_entity()
@property
def state(self) -> DurableAgentState:
if self._state_cache is None:
raw_state = self._get_state_dict()
self._state_cache = DurableAgentState.from_dict(raw_state) if raw_state else DurableAgentState()
return self._state_cache
@state.setter
def state(self, value: DurableAgentState) -> None:
self._state_cache = value
self.persist_state()
def persist_state(self) -> None:
"""Persist the current state to the underlying storage provider."""
if self._state_cache is None:
self._state_cache = DurableAgentState()
self._set_state_dict(self._state_cache.to_dict())
def reset(self) -> None:
"""Clear conversation history by resetting state to a fresh DurableAgentState."""
self._state_cache = DurableAgentState()
self.persist_state()
logger.debug("[AgentEntityStateProviderMixin.reset] State reset complete")
class AgentEntity:
"""Platform-agnostic agent execution logic.
This class encapsulates the core logic for executing an agent within a durable entity context.
"""
agent: SupportsAgentRun
callback: AgentResponseCallbackProtocol | None
def __init__(
self,
agent: SupportsAgentRun,
callback: AgentResponseCallbackProtocol | None = None,
*,
state_provider: AgentEntityStateProviderMixin,
) -> None:
self.agent = agent
self.callback = callback
self._state_provider = state_provider
logger.debug("[AgentEntity] Initialized with agent type: %s", type(agent).__name__)
@property
def state(self) -> DurableAgentState:
return self._state_provider.state
@state.setter
def state(self, value: DurableAgentState) -> None:
self._state_provider.state = value
def persist_state(self) -> None:
self._state_provider.persist_state()
def reset(self) -> None:
self._state_provider.reset()
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
"""Check if a conversation history entry is an error response."""
if isinstance(entry, DurableAgentStateResponse):
return entry.is_error
return False
async def run(
self,
request: RunRequest | dict[str, Any] | str,
) -> AgentResponse:
"""Execute the agent with a message."""
if isinstance(request, str):
run_request = RunRequest.from_json(request)
elif isinstance(request, dict):
run_request = RunRequest.from_dict(request)
else:
run_request = request
message = run_request.message
thread_id = self._state_provider.thread_id
correlation_id = run_request.correlation_id
if not thread_id:
raise ValueError("Entity State Provider must provide a thread_id")
options: dict[str, Any] = dict(run_request.options)
options.setdefault("response_format", run_request.response_format)
if not run_request.enable_tool_calls:
options.setdefault("tools", None)
logger.debug("[AgentEntity.run] Received ThreadId %s Message: %s", thread_id, run_request)
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversation_history.append(state_request)
try:
chat_messages: list[Message] = [
replayable_message
for entry in self.state.data.conversation_history
if not self._is_error_response(entry)
for m in entry.messages
if (replayable_message := self._to_replayable_message(m)) is not None
]
run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": options}
agent_run_response: AgentResponse = await self._invoke_agent(
run_kwargs=run_kwargs,
correlation_id=correlation_id,
thread_id=thread_id,
request_message=message,
)
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
self.state.data.conversation_history.append(state_response)
self.persist_state()
return agent_run_response
except Exception as exc:
logger.exception("[AgentEntity.run] Agent execution failed.")
error_message = Message(
role="assistant", contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
)
error_response = AgentResponse(
messages=[error_message],
created_at=datetime.now(tz=timezone.utc).isoformat(),
)
error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response)
error_state_response.is_error = True
self.state.data.conversation_history.append(error_state_response)
self.persist_state()
return error_response
@staticmethod
def _to_replayable_message(message: DurableAgentStateMessage) -> Message | None:
"""Convert persisted history into a message safe to replay into chat clients."""
chat_message = message.to_chat_message()
replayable_contents = [content for content in chat_message.contents if content.type != "reasoning"]
if not replayable_contents:
return None
return Message(
role=chat_message.role,
contents=replayable_contents,
author_name=chat_message.author_name,
additional_properties=chat_message.additional_properties,
)
async def _invoke_agent(
self,
run_kwargs: dict[str, Any],
correlation_id: str,
thread_id: str,
request_message: str,
) -> AgentResponse:
"""Execute the agent, preferring streaming when available."""
callback_context: AgentCallbackContext | None = None
if self.callback is not None:
callback_context = self._build_callback_context(
correlation_id=correlation_id,
thread_id=thread_id,
request_message=request_message,
)
run_callable = self.agent.run
# Try streaming first with run(stream=True)
try:
stream_candidate = run_callable(stream=True, **run_kwargs)
if inspect.isawaitable(stream_candidate):
stream_candidate = await stream_candidate
return await self._consume_stream(
stream=stream_candidate,
callback_context=callback_context,
)
except TypeError as type_error:
if "__aiter__" not in str(type_error) and "stream" not in str(type_error):
raise
logger.debug(
"run(stream=True) returned a non-async result; falling back to run(): %s",
type_error,
)
except Exception as stream_error:
logger.warning(
"run(stream=True) failed; falling back to run(): %s",
stream_error,
exc_info=True,
)
agent_run_response = run_callable(**run_kwargs)
if inspect.isawaitable(agent_run_response):
agent_run_response = await agent_run_response
if not isinstance(agent_run_response, AgentResponse):
raise TypeError(
f"Agent run() must return an AgentResponse instance; received {type(agent_run_response).__name__}"
)
await self._notify_final_response(agent_run_response, callback_context)
return agent_run_response
async def _consume_stream(
self,
stream: ResponseStream[AgentResponseUpdate, AgentResponse],
callback_context: AgentCallbackContext | None = None,
) -> AgentResponse:
"""Consume streaming responses and build the final AgentResponse."""
updates: list[AgentResponseUpdate] = []
async for update in stream:
updates.append(update)
await self._notify_stream_update(update, callback_context)
response = await stream.get_final_response()
await self._notify_final_response(response, callback_context)
return response
async def _notify_stream_update(
self,
update: AgentResponseUpdate,
context: AgentCallbackContext | None,
) -> None:
"""Invoke the streaming callback if one is registered."""
if self.callback is None or context is None:
return
try:
callback_result = self.callback.on_streaming_response_update(update, context)
if inspect.isawaitable(callback_result):
await callback_result
except Exception as exc:
logger.warning(
"[AgentEntity] Streaming callback raised an exception: %s",
exc,
exc_info=True,
)
async def _notify_final_response(
self,
response: AgentResponse,
context: AgentCallbackContext | None,
) -> None:
"""Invoke the final response callback if one is registered."""
if self.callback is None or context is None:
return
try:
callback_result = self.callback.on_agent_response(response, context)
if inspect.isawaitable(callback_result):
await callback_result
except Exception as exc:
logger.warning(
"[AgentEntity] Response callback raised an exception: %s",
exc,
exc_info=True,
)
def _build_callback_context(
self,
correlation_id: str,
thread_id: str,
request_message: str,
) -> AgentCallbackContext:
"""Create the callback context provided to consumers."""
agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__
return AgentCallbackContext(
agent_name=agent_name,
correlation_id=correlation_id,
thread_id=thread_id,
request_message=request_message,
)
class DurableTaskEntityStateProvider(DurableEntity, AgentEntityStateProviderMixin):
"""DurableTask Durable Entity state provider for AgentEntity.
This class utilizes the Durable Entity context from `durabletask` package
to get and set the state of the agent entity.
"""
def __init__(self) -> None:
super().__init__()
def _get_state_dict(self) -> dict[str, Any]:
raw = self.get_state(dict, default={})
return cast(dict[str, Any], raw)
def _set_state_dict(self, state: dict[str, Any]) -> None:
self.set_state(state)
def _get_thread_id_from_entity(self) -> str:
return self.entity_context.entity_id.key
@@ -0,0 +1,527 @@
# Copyright (c) Microsoft. All rights reserved.
"""Provider strategies for Durable Agent execution.
These classes are internal execution strategies used by the DurableAIAgent shim.
They are intentionally separate from the public client/orchestration APIs to keep
only `get_agent` exposed to consumers. Executors implement the execution contract
and are injected into the shim.
"""
from __future__ import annotations
import logging
import time
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar
from agent_framework import AgentResponse, AgentSession, Content, Message
from durabletask.client import TaskHubGrpcClient
from durabletask.entities import EntityInstanceId
from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task
from pydantic import BaseModel
from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
from ._durable_agent_state import DurableAgentState
from ._models import AgentSessionId, DurableAgentSession, RunRequest
from ._response_utils import ensure_response_format, load_agent_response
logger = logging.getLogger("agent_framework.durabletask")
# TypeVar for the task type returned by executors
TaskT = TypeVar("TaskT")
class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentResponse]):
"""A custom Task that wraps entity calls and provides typed AgentResponse results.
This task wraps the underlying entity call task and intercepts its completion
to convert the raw result into a typed AgentResponse object.
When yielded in an orchestration, this task returns an AgentResponse:
response: AgentResponse = yield durable_agent_task
"""
def __init__(
self,
entity_task: CompletableTask[Any],
response_format: type[BaseModel] | None,
correlation_id: str,
):
"""Initialize the DurableAgentTask.
Args:
entity_task: The underlying entity call task
response_format: Optional Pydantic model for response parsing
correlation_id: Correlation ID for logging
"""
self._response_format = response_format
self._correlation_id = correlation_id
super().__init__([entity_task])
def on_child_completed(self, task: Task[Any]) -> None:
"""Handle completion of the underlying entity task.
Parameters
----------
task : Task
The entity call task that just completed
"""
if self.is_complete:
return
if task.is_failed:
# Propagate the failure - pass the original exception directly
self.fail("call_entity Task failed", task.get_exception())
return
# Task succeeded - transform the raw result
raw_result = task.get_result()
logger.debug(
"[DurableAgentTask] Converting raw result for correlation_id %s",
self._correlation_id,
)
try:
response = load_agent_response(raw_result)
if self._response_format is not None:
ensure_response_format(
self._response_format,
self._correlation_id,
response,
)
# Set the typed AgentResponse as this task's result
self.complete(response)
except Exception as ex:
err_msg = "[DurableAgentTask] Failed to convert result for correlation_id: " + self._correlation_id
logger.exception(err_msg)
self.fail(err_msg, ex)
class DurableAgentExecutor(ABC, Generic[TaskT]):
"""Abstract base class for durable agent execution strategies.
Type Parameters:
TaskT: The task type returned by this executor
"""
@abstractmethod
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
session: AgentSession | None = None,
) -> TaskT:
"""Execute the durable agent.
Returns:
TaskT: The task type specific to this executor implementation
"""
raise NotImplementedError
def get_new_session(
self,
agent_name: str,
*,
session_id: str | None = None,
service_session_id: str | None = None,
) -> DurableAgentSession:
"""Create a new DurableAgentSession with random session ID."""
durable_session_id = self._create_session_id(agent_name)
return DurableAgentSession(
durable_session_id=durable_session_id,
session_id=session_id,
service_session_id=service_session_id,
)
def _create_session_id(
self,
agent_name: str,
session: AgentSession | None = None,
) -> AgentSessionId:
"""Create the AgentSessionId for the execution."""
if isinstance(session, DurableAgentSession) and session.durable_session_id is not None:
return session.durable_session_id
# Create new session ID - either no session provided or it's a regular AgentSession
key = self.generate_unique_id()
return AgentSessionId(name=agent_name, key=key)
def generate_unique_id(self) -> str:
"""Generate a new Unique ID."""
return uuid.uuid4().hex
def get_run_request(
self,
message: str,
*,
options: dict[str, Any] | None = None,
) -> RunRequest:
"""Create a RunRequest from message and options."""
correlation_id = self.generate_unique_id()
# Create a copy to avoid modifying the caller's dict
opts = dict(options) if options else {}
# Extract and REMOVE known keys from options copy
response_format = opts.pop("response_format", None)
enable_tool_calls = opts.pop("enable_tool_calls", True)
wait_for_response = opts.pop("wait_for_response", True)
return RunRequest(
message=message,
response_format=response_format,
enable_tool_calls=enable_tool_calls,
wait_for_response=wait_for_response,
correlation_id=correlation_id,
options=opts,
)
def _create_acceptance_response(self, correlation_id: str) -> AgentResponse:
"""Create an acceptance response for fire-and-forget mode.
Args:
correlation_id: Correlation ID for tracking the request
Returns:
AgentResponse: Acceptance response with correlation ID
"""
acceptance_message = Message(
role="system",
contents=[
Content.from_text(
f"Request accepted for processing (correlation_id: {correlation_id}). "
f"Agent is executing in the background. "
f"Retrieve response via your configured streaming or callback mechanism."
)
],
)
return AgentResponse(
messages=[acceptance_message],
created_at=datetime.now(timezone.utc).isoformat(),
)
class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]):
"""Execution strategy for external clients.
Note: Returns AgentResponse directly since the execution
is blocking until response is available via polling
as per the design of TaskHubGrpcClient.
"""
def __init__(
self,
client: TaskHubGrpcClient,
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
):
self._client = client
self.max_poll_retries = max_poll_retries
self.poll_interval_seconds = poll_interval_seconds
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
session: AgentSession | None = None,
) -> AgentResponse:
"""Execute the agent via the durabletask client.
Signals the agent entity with a message request, then polls the entity
state to retrieve the response once processing is complete.
Note: This is a blocking/synchronous operation (in line with how
TaskHubGrpcClient works) that polls until a response is available or
timeout occurs.
Args:
agent_name: Name of the agent to execute
run_request: The run request containing message and optional response format
session: Optional conversation session (creates new if not provided)
Returns:
AgentResponse: The agent's response after execution completes, or an immediate
acknowledgement if wait_for_response is False
"""
# Signal the entity with the request
entity_id = self._signal_agent_entity(agent_name, run_request, session)
# If fire-and-forget mode, return immediately without polling
if not run_request.wait_for_response:
logger.info(
"[ClientAgentExecutor] Fire-and-forget mode: request signaled (correlation: %s)",
run_request.correlation_id,
)
return self._create_acceptance_response(run_request.correlation_id)
# Poll for the response
agent_response = self._poll_for_agent_response(entity_id, run_request.correlation_id)
# Handle and return the result
return self._handle_agent_response(agent_response, run_request.response_format, run_request.correlation_id)
def _signal_agent_entity(
self,
agent_name: str,
run_request: RunRequest,
session: AgentSession | None,
) -> EntityInstanceId:
"""Signal the agent entity with a run request.
Args:
agent_name: Name of the agent to execute
run_request: The run request containing message and optional response format
session: Optional conversation session
Returns:
entity_id
"""
# Get or create session ID
session_id = self._create_session_id(agent_name, session)
# Create the entity ID
entity_id = EntityInstanceId(
entity=session_id.entity_name,
key=session_id.key,
)
logger.debug(
"[ClientAgentExecutor] Signaling entity '%s' (session: %s, correlation: %s)",
agent_name,
session_id,
run_request.correlation_id,
)
self._client.signal_entity(entity_id, "run", run_request.to_dict())
return entity_id
def _poll_for_agent_response(
self,
entity_id: EntityInstanceId,
correlation_id: str,
) -> AgentResponse | None:
"""Poll the entity for a response with retries.
Args:
entity_id: Entity instance identifier
correlation_id: Correlation ID to track the request
Returns:
The agent response if found, None if timeout occurs
"""
agent_response = None
for attempt in range(1, self.max_poll_retries + 1):
# Initial sleep is intentional - give the entity time to process before first poll
time.sleep(self.poll_interval_seconds)
agent_response = self._poll_entity_for_response(entity_id, correlation_id)
if agent_response is not None:
logger.info(
"[ClientAgentExecutor] Found response (attempt %d/%d, correlation: %s)",
attempt,
self.max_poll_retries,
correlation_id,
)
break
logger.debug(
"[ClientAgentExecutor] Response not ready (attempt %d/%d)",
attempt,
self.max_poll_retries,
)
return agent_response
def _handle_agent_response(
self,
agent_response: AgentResponse | None,
response_format: type[BaseModel] | None,
correlation_id: str,
) -> AgentResponse:
"""Handle the agent response or create an error response.
Args:
agent_response: The response from polling, or None if timeout
response_format: Optional response format for validation
correlation_id: Correlation ID for logging
Returns:
AgentResponse with either the agent's response or an error message
"""
if agent_response is not None:
try:
# Validate response format if specified
if response_format is not None:
ensure_response_format(
response_format,
correlation_id,
agent_response,
)
return agent_response
except Exception as e:
logger.exception(
"[ClientAgentExecutor] Error converting response for correlation: %s",
correlation_id,
)
error_message = Message(
role="system",
contents=[
Content.from_error(
message=f"Error processing agent response: {e}",
error_code="response_processing_error",
)
],
)
else:
logger.warning(
"[ClientAgentExecutor] Timeout after %d attempts (correlation: %s)",
self.max_poll_retries,
correlation_id,
)
error_message = Message(
role="system",
contents=[
Content.from_error(
message=f"Timeout waiting for agent response after {self.max_poll_retries} attempts",
error_code="response_timeout",
)
],
)
return AgentResponse(
messages=[error_message],
created_at=datetime.now(timezone.utc).isoformat(),
)
def _poll_entity_for_response(
self,
entity_id: EntityInstanceId,
correlation_id: str,
) -> AgentResponse | None:
"""Poll the entity state for a response matching the correlation ID.
Args:
entity_id: Entity instance identifier
correlation_id: Correlation ID to search for
Returns:
Response AgentResponse, None otherwise
"""
try:
entity_metadata = self._client.get_entity(entity_id, include_state=True)
if entity_metadata is None:
return None
state_json = entity_metadata.get_state()
if not state_json:
return None
state = DurableAgentState.from_json(state_json)
# Use the helper method to get response by correlation ID
return state.try_get_agent_response(correlation_id)
except Exception as e:
logger.warning(
"[ClientAgentExecutor] Error reading entity state: %s",
e,
)
return None
class OrchestrationAgentExecutor(DurableAgentExecutor[DurableAgentTask]):
"""Execution strategy for orchestrations (sync/yield)."""
def __init__(self, context: OrchestrationContext):
self._context = context
logger.debug("[OrchestrationAgentExecutor] Initialized")
def generate_unique_id(self) -> str:
"""Create a new UUID that is safe for replay within an orchestration or operation."""
return self._context.new_uuid()
def get_run_request(
self,
message: str,
*,
options: dict[str, Any] | None = None,
) -> RunRequest:
"""Get the current run request from the orchestration context.
Returns:
RunRequest: The current run request
"""
request = super().get_run_request(
message,
options=options,
)
request.orchestration_id = self._context.instance_id
return request
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
session: AgentSession | None = None,
) -> DurableAgentTask:
"""Execute the agent via orchestration context.
Calls the agent entity and returns a DurableAgentTask that can be yielded
in orchestrations to wait for the entity's response.
Args:
agent_name: Name of the agent to execute
run_request: The run request containing message and optional response format
session: Optional conversation session (creates new if not provided)
Returns:
DurableAgentTask: A task wrapping the entity call that yields AgentResponse
"""
# Resolve session
session_id = self._create_session_id(agent_name, session)
# Create the entity ID
entity_id = EntityInstanceId(
entity=session_id.entity_name,
key=session_id.key,
)
logger.debug(
"[OrchestrationAgentExecutor] correlation_id: %s entity_id: %s session_id: %s",
run_request.correlation_id,
entity_id,
session_id,
)
# Branch based on wait_for_response
if not run_request.wait_for_response:
# Fire-and-forget mode: signal entity and return pre-completed task
logger.info(
"[OrchestrationAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
run_request.correlation_id,
)
self._context.signal_entity(entity_id, "run", run_request.to_dict())
# Create a pre-completed task with acceptance response
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
entity_task: CompletableTask[AgentResponse] = CompletableTask()
entity_task.complete(acceptance_response)
else:
# Blocking mode: call entity and wait for response
entity_task = self._context.call_entity(entity_id, "run", run_request.to_dict())
# Wrap in DurableAgentTask for response transformation
return DurableAgentTask(
entity_task=entity_task,
response_format=run_request.response_format,
correlation_id=run_request.correlation_id,
)
@@ -0,0 +1,334 @@
# Copyright (c) Microsoft. All rights reserved.
"""Data models for Durable Agent Framework.
This module defines the request and response models used by the framework.
"""
from __future__ import annotations
import inspect
import json
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from importlib import import_module
from typing import TYPE_CHECKING, Any, cast
from agent_framework import AgentSession
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
if TYPE_CHECKING: # pragma: no cover - type checking imports only
from pydantic import BaseModel
_PydanticBaseModel: type[BaseModel] | None
try:
from pydantic import BaseModel as _RuntimeBaseModel
except ImportError: # pragma: no cover - optional dependency
_PydanticBaseModel = None
else:
_PydanticBaseModel = _RuntimeBaseModel
def serialize_response_format(response_format: type[BaseModel] | None) -> Any:
"""Serialize response format for transport across durable function boundaries."""
if response_format is None:
return None
if _PydanticBaseModel is None:
raise RuntimeError("pydantic is required to use structured response formats")
if not inspect.isclass(response_format) or not issubclass(response_format, _PydanticBaseModel):
raise TypeError("response_format must be a Pydantic BaseModel type")
return {
"__response_schema_type__": "pydantic_model",
"module": response_format.__module__,
"qualname": response_format.__qualname__,
}
def _deserialize_response_format(response_format: Any) -> type[BaseModel] | None:
"""Deserialize response format back into actionable type if possible."""
if response_format is None:
return None
if (
_PydanticBaseModel is not None
and inspect.isclass(response_format)
and issubclass(response_format, _PydanticBaseModel)
):
return response_format
if not isinstance(response_format, dict):
return None
response_dict = cast(dict[str, Any], response_format)
if response_dict.get("__response_schema_type__") != "pydantic_model":
return None
module_name = response_dict.get("module")
qualname = response_dict.get("qualname")
if not module_name or not qualname:
return None
try:
module = import_module(module_name)
except ImportError: # pragma: no cover - user provided module missing
return None
attr: Any = module
for part in qualname.split("."):
try:
attr = getattr(attr, part)
except AttributeError: # pragma: no cover - invalid qualname
return None
if _PydanticBaseModel is not None and inspect.isclass(attr) and issubclass(attr, _PydanticBaseModel):
return attr
return None
@dataclass
class RunRequest:
"""Represents a request to run an agent with a specific message and configuration.
Attributes:
message: The message to send to the agent
request_response_format: The desired response format (e.g., "text" or "json")
role: The role of the message sender (user, system, or assistant)
response_format: Optional Pydantic BaseModel type describing the structured response format
enable_tool_calls: Whether to enable tool calls for this request
wait_for_response: If True (default), caller will wait for agent response. If False,
returns immediately after signaling (fire-and-forget mode)
correlation_id: Correlation ID for tracking the response to this specific request
created_at: Optional timestamp when the request was created
orchestration_id: Optional ID of the orchestration that initiated this request
options: Optional options dictionary forwarded to the agent
"""
message: str
request_response_format: str
correlation_id: str
role: str = "user"
response_format: type[BaseModel] | None = None
enable_tool_calls: bool = True
wait_for_response: bool = True
created_at: datetime | None = None
orchestration_id: str | None = None
options: dict[str, Any] = field(default_factory=lambda: {})
def __init__(
self,
message: str,
correlation_id: str,
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
role: str | None = "user",
response_format: type[BaseModel] | None = None,
enable_tool_calls: bool = True,
wait_for_response: bool = True,
created_at: datetime | None = None,
orchestration_id: str | None = None,
options: dict[str, Any] | None = None,
) -> None:
self.message = message
self.correlation_id = correlation_id
self.role = self.coerce_role(role)
self.response_format = response_format
self.request_response_format = request_response_format
self.enable_tool_calls = enable_tool_calls
self.wait_for_response = wait_for_response
self.created_at = created_at if created_at is not None else datetime.now(tz=timezone.utc)
self.orchestration_id = orchestration_id
self.options = options if options is not None else {}
@staticmethod
def coerce_role(value: str | None) -> str:
"""Normalize various role representations into a role string."""
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return "user"
return normalized.lower()
return "user"
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = {
"message": self.message,
"enable_tool_calls": self.enable_tool_calls,
"wait_for_response": self.wait_for_response,
"role": self.role,
"request_response_format": self.request_response_format,
"correlationId": self.correlation_id,
"options": self.options,
}
if self.response_format:
result["response_format"] = serialize_response_format(self.response_format)
if self.created_at:
result["created_at"] = self.created_at.isoformat()
if self.orchestration_id:
result["orchestrationId"] = self.orchestration_id
return result
@classmethod
def from_json(cls, data: str) -> RunRequest:
"""Create RunRequest from JSON string."""
try:
dict_data = json.loads(data)
except json.JSONDecodeError as e:
raise ValueError("The durable agent state is not valid JSON.") from e
return cls.from_dict(dict_data)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
"""Create RunRequest from dictionary."""
created_at = data.get("created_at")
if isinstance(created_at, str):
try:
created_at = datetime.fromisoformat(created_at)
except ValueError:
created_at = None
correlation_id = data.get("correlationId")
if not correlation_id:
raise ValueError("correlationId is required in RunRequest data")
options = data.get("options")
return cls(
message=data.get("message", ""),
correlation_id=correlation_id,
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
role=cls.coerce_role(data.get("role")),
response_format=_deserialize_response_format(data.get("response_format")),
wait_for_response=data.get("wait_for_response", True),
enable_tool_calls=data.get("enable_tool_calls", True),
created_at=created_at,
orchestration_id=data.get("orchestrationId"),
options=cast(dict[str, Any], options) if isinstance(options, dict) else {},
)
@dataclass
class AgentSessionId:
"""Represents an agent session identifier (name + key)."""
name: str
key: str
ENTITY_NAME_PREFIX: str = "dafx-"
@staticmethod
def to_entity_name(name: str) -> str:
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
@staticmethod
def with_random_key(name: str) -> AgentSessionId:
return AgentSessionId(name=name, key=uuid.uuid4().hex)
@property
def entity_name(self) -> str:
return self.to_entity_name(self.name)
def __str__(self) -> str:
return f"@{self.name}@{self.key}"
def __repr__(self) -> str:
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
@staticmethod
def parse(session_id_string: str, agent_name: str | None = None) -> AgentSessionId:
"""Parses a string representation of an agent session ID.
Args:
session_id_string: A string in the form @name@key, or a plain key string
when agent_name is provided.
agent_name: Optional agent name to use instead of parsing from the string.
If provided, only the key portion is extracted from session_id_string
(for @name@key format) or the entire string is used as the key
(for plain strings).
Returns:
AgentSessionId instance
Raises:
ValueError: If the string format is invalid and agent_name is not provided
"""
# Check if string is in @name@key format
if session_id_string.startswith("@") and "@" in session_id_string[1:]:
parts = session_id_string[1:].split("@", 1)
name = agent_name if agent_name is not None else parts[0]
return AgentSessionId(name=name, key=parts[1])
# Plain string format - only valid when agent_name is provided
if agent_name is not None:
return AgentSessionId(name=agent_name, key=session_id_string)
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
class DurableAgentSession(AgentSession):
"""Durable agent session that tracks the owning :class:`AgentSessionId`."""
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
def __init__(
self,
*,
durable_session_id: AgentSessionId | None = None,
session_id: str | None = None,
service_session_id: str | None = None,
) -> None:
super().__init__(session_id=session_id, service_session_id=service_session_id)
self.durable_session_id: AgentSessionId | None = durable_session_id
def to_dict(self) -> dict[str, Any]:
state = super().to_dict()
if self.durable_session_id is not None:
state[self._SERIALIZED_SESSION_ID_KEY] = str(self.durable_session_id)
return state
@classmethod
def from_session_id(
cls,
durable_session_id: AgentSessionId,
*,
session_id: str | None = None,
service_session_id: str | None = None,
) -> DurableAgentSession:
"""Create a DurableAgentSession from an AgentSessionId."""
return cls(
durable_session_id=durable_session_id,
session_id=session_id,
service_session_id=service_session_id,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DurableAgentSession:
"""Create a DurableAgentSession from a state dict."""
data = dict(data) # defensive copy — avoid mutating caller's dict
session_id_value = data.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
session = super().from_dict(data)
service_session_id = session.service_session_id
if service_session_id is not None and not isinstance(service_session_id, str):
raise ValueError("durable sessions require service_session_id to be a string when present")
durable_session_id: AgentSessionId | None = None
# We need to create a DurableAgentSession from the base AgentSession
if session_id_value is not None:
if not isinstance(session_id_value, str):
raise ValueError("durable_session_id must be a string when present in serialized state")
durable_session_id = AgentSessionId.parse(session_id_value)
durable_session = cls(
durable_session_id=durable_session_id,
session_id=session.session_id,
service_session_id=service_session_id,
)
durable_session.state.update(session.state)
return durable_session
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
"""Orchestration context wrapper for Durable Task Agent Framework.
This module provides the DurableAIAgentOrchestrationContext class for use inside
orchestration functions to interact with durable agents.
"""
from __future__ import annotations
import logging
from durabletask.task import OrchestrationContext
from ._executors import DurableAgentTask, OrchestrationAgentExecutor
from ._shim import DurableAgentProvider, DurableAIAgent
logger = logging.getLogger("agent_framework.durabletask")
class DurableAIAgentOrchestrationContext(DurableAgentProvider[DurableAgentTask]):
"""Orchestration context wrapper for interacting with durable agents internally.
This class wraps a durabletask OrchestrationContext and provides a convenient
interface for retrieving and executing durable agents from within orchestration
functions.
Example:
```python
from durabletask import Orchestration
from agent_framework.azure import DurableAIAgentOrchestrationContext
def my_orchestration(context: OrchestrationContext):
# Wrap the context
agent_context = DurableAIAgentOrchestrationContext(context)
# Get an agent reference
agent = agent_context.get_agent("assistant")
# Run the agent (returns a Task to be yielded)
result = yield agent.run("Hello, how are you?")
return result.text
```
"""
def __init__(self, context: OrchestrationContext):
"""Initialize the orchestration context wrapper.
Args:
context: The durabletask orchestration context to wrap
"""
self._context = context
self._executor = OrchestrationAgentExecutor(self._context)
logger.debug("[DurableAIAgentOrchestrationContext] Initialized")
def get_agent(self, agent_name: str) -> DurableAIAgent[DurableAgentTask]:
"""Retrieve a DurableAIAgent shim for the specified agent.
This method returns a proxy object that can be used to execute the agent
within an orchestration. The agent's run() method will return a Task that
must be yielded.
Args:
agent_name: Name of the agent to retrieve (without the dafx- prefix)
Returns:
DurableAIAgent instance that can be used to run the agent
Note:
Validation is deferred to execution time. The entity must be registered
on a worker with the name f"dafx-{agent_name}".
"""
logger.debug("[DurableAIAgentOrchestrationContext] Creating agent proxy for: %s", agent_name)
return DurableAIAgent(self._executor, agent_name)
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared utilities for handling AgentResponse parsing and validation."""
import logging
from typing import Any
from agent_framework import AgentResponse
from pydantic import BaseModel
logger = logging.getLogger("agent_framework.durabletask")
def load_agent_response(agent_response: AgentResponse | dict[str, Any] | None) -> AgentResponse:
"""Convert raw payloads into AgentResponse instance.
Args:
agent_response: The response to convert, can be an AgentResponse, dict, or None
Returns:
AgentResponse: The converted response object
Raises:
ValueError: If agent_response is None
TypeError: If agent_response is an unsupported type
"""
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))
if isinstance(agent_response, AgentResponse):
return agent_response
if isinstance(agent_response, dict):
logger.debug("[load_agent_response] Converting dict payload using AgentResponse.from_dict")
return AgentResponse.from_dict(agent_response)
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
def ensure_response_format(
response_format: type[BaseModel] | None,
correlation_id: str,
response: AgentResponse,
) -> None:
"""Ensure the AgentResponse value is parsed into the expected response_format.
This function modifies the response in-place by parsing its value attribute
into the specified Pydantic model format.
Args:
response_format: Optional Pydantic model class to parse the response value into
correlation_id: Correlation ID for logging purposes
response: The AgentResponse object to validate and parse
Raises:
ValueError: If response_format is specified but response.value cannot be parsed
"""
if response_format is not None:
# Set the response format on the response so .value knows how to parse
response._response_format = response_format # pyright: ignore[reportPrivateUsage]
response._value_parsed = False # pyright: ignore[reportPrivateUsage] # Reset to allow re-parsing with new format
# Access response.value to trigger parsing (may raise ValidationError)
# Validate that parsing succeeded
if not isinstance(response.value, response_format):
raise ValueError(
f"Response value could not be parsed into required format {response_format.__name__} "
f"for correlation_id {correlation_id}"
)
logger.debug(
"[ensure_response_format] Loaded AgentResponse.value for correlation_id %s with type: %s",
correlation_id,
type(response.value).__name__,
)
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Agent Shim for Durable Task Framework.
This module provides the DurableAIAgent shim that implements SupportsAgentRun
and provides a consistent interface for both Client and Orchestration contexts.
The actual execution is delegated to the context-specific providers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Generic, Literal, TypeVar
from agent_framework import AgentSession, ServiceSessionId, SupportsAgentRun, normalize_messages
from agent_framework._types import AgentRunInputs
from ._executors import DurableAgentExecutor
from ._models import DurableAgentSession
# TypeVar for the task type returned by executors
# Covariant because TaskT only appears in return positions (output)
TaskT = TypeVar("TaskT", covariant=True)
class DurableAgentProvider(ABC, Generic[TaskT]):
"""Abstract provider for constructing durable agent proxies.
Implemented by context-specific wrappers (client/orchestration) to return a
`DurableAIAgent` shim backed by their respective `DurableAgentExecutor`
implementation, ensuring a consistent `get_agent` entry point regardless of
execution context.
"""
@abstractmethod
def get_agent(self, agent_name: str) -> DurableAIAgent[TaskT]:
"""Retrieve a DurableAIAgent shim for the specified agent.
Args:
agent_name: Name of the agent to retrieve
Returns:
DurableAIAgent instance that can be used to run the agent
Raises:
NotImplementedError: Must be implemented by subclasses
"""
raise NotImplementedError("Subclasses must implement get_agent()")
class DurableAIAgent(SupportsAgentRun, Generic[TaskT]):
"""A durable agent proxy that delegates execution to the provider.
This class implements SupportsAgentRun but with one critical difference:
- SupportsAgentRun.run() returns a Coroutine (async, must await)
- DurableAIAgent.run() returns TaskT (sync Task object - must yield
or the AgentResponse directly in the case of TaskHubGrpcClient)
This represents fundamentally different execution models but maintains the same
interface contract for all other properties and methods.
The underlying provider determines how execution occurs (entity calls, HTTP requests, etc.)
and what type of Task object is returned.
Type Parameters:
TaskT: The task type returned by this agent (e.g., AgentResponse, DurableAgentTask, AgentTask)
"""
id: str
name: str
display_name: str
description: str | None
def __init__(self, executor: DurableAgentExecutor[TaskT], name: str, *, agent_id: str | None = None):
"""Initialize the shim with a provider and agent name.
Args:
executor: The execution provider (Client or OrchestrationContext)
name: The name of the agent to execute
agent_id: Optional unique identifier for the agent (defaults to name)
"""
self._executor = executor
self.name = name # pyright: ignore[reportIncompatibleVariableOverride]
self.id = agent_id if agent_id is not None else name
self.display_name = name
self.description = f"Durable agent proxy for {name}"
def run( # type: ignore[override]
self,
messages: AgentRunInputs | None = None,
*,
stream: Literal[False] = False,
session: AgentSession | None = None,
options: dict[str, Any] | None = None,
) -> TaskT:
"""Execute the agent via the injected provider.
Args:
messages: The message(s) to send to the agent
stream: Whether to use streaming for the response (must be False)
DurableAgents do not support streaming mode.
session: Optional agent session for conversation context
options: Optional options dictionary. Supported keys include
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
Additional keys are forwarded to the agent execution.
Note:
This method overrides SupportsAgentRun.run() with a different return type:
- SupportsAgentRun.run() returns Coroutine[Any, Any, AgentResponse] (async)
- DurableAIAgent.run() returns TaskT (Task object for yielding)
This is intentional to support orchestration contexts that use yield patterns
instead of async/await patterns.
Returns:
TaskT: The task type specific to the executor
Raises:
ValueError: If wait_for_response=False is used in an unsupported context
"""
if stream is not False:
raise ValueError("DurableAIAgent does not support streaming mode (stream must be False)")
message_str = self._normalize_messages(messages)
run_request = self._executor.get_run_request(
message=message_str,
options=options,
)
return self._executor.run_durable_agent(
agent_name=self.name,
run_request=run_request,
session=session,
)
def create_session(self, *, session_id: str | None = None) -> DurableAgentSession:
"""Create a new agent session via the provider."""
return self._executor.get_new_session(self.name)
def get_session(self, service_session_id: str | ServiceSessionId, *, session_id: str | None = None) -> AgentSession:
"""Retrieve an existing session via the provider."""
if not isinstance(service_session_id, str):
raise ValueError("DurableAIAgent requires service_session_id to be a string")
return self._executor.get_new_session(self.name, service_session_id=service_session_id, session_id=session_id)
def _normalize_messages(self, messages: AgentRunInputs | None) -> str:
"""Convert supported message inputs to a single string.
Args:
messages: The messages to normalize
Returns:
A single string representation of the messages
Raises:
ValueError: If normalized messages contain non-text content only.
"""
normalized_messages = normalize_messages(messages)
if not normalized_messages:
return ""
message_texts: list[str] = []
for message in normalized_messages:
if not message.text:
raise ValueError("DurableAIAgent only supports text message inputs.")
message_texts.append(message.text)
return "\n".join(message_texts)
@@ -0,0 +1,421 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker wrapper for Durable Task Agent Framework.
This module provides the DurableAIAgentWorker class that wraps a durabletask worker
and enables registration of agents as durable entities, and optionally workflows
as durable orchestrations with automatically generated activity functions.
"""
from __future__ import annotations
import logging
from typing import Any
from agent_framework import SupportsAgentRun, Workflow
from durabletask.task import ActivityContext, OrchestrationContext
from durabletask.worker import TaskHubGrpcWorker
from ._async_bridge import run_agent_coroutine
from ._callbacks import AgentResponseCallbackProtocol
from ._entities import AgentEntity, DurableTaskEntityStateProvider
from ._workflows.activity import execute_workflow_activity
from ._workflows.dt_context import DurableTaskWorkflowContext
from ._workflows.naming import (
validate_executor_id,
validate_workflow_name,
workflow_executor_activity_name,
workflow_orchestrator_name,
workflow_scoped_executor_id,
)
from ._workflows.orchestrator import run_workflow_orchestrator
from ._workflows.registration import collect_hosted_workflows, plan_workflow_registration
logger = logging.getLogger("agent_framework.durabletask")
class DurableAIAgentWorker:
"""Wrapper for a durabletask worker that hosts agents and workflows.
This class wraps an existing TaskHubGrpcWorker instance and is the single
host-side registration surface for a worker process. It supports two
complementary kinds of work:
- **Agents** via :meth:`add_agent`, which registers each agent as a durable entity.
- **Workflows** via :meth:`configure_workflow`, which registers a MAF
``Workflow`` (its agent executors as entities, its non-agent executors as
activities, and the workflow orchestrator).
A single worker process commonly hosts both, so registration is intentionally
aggregated on one object rather than split per kind. (On the *client* side the
surfaces are split into :class:`DurableAIAgentClient` and ``DurableWorkflowClient``,
because a caller invokes one or the other.)
Example:
```python
from durabletask.worker import TaskHubGrpcWorker
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework_durabletask import DurableAIAgentWorker
# Create the underlying worker
worker = TaskHubGrpcWorker(host_address="localhost:4001")
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Register agents (or call configure_workflow(workflow) to host a workflow)
client = OpenAIChatCompletionClient()
my_agent = Agent(client=client, name="assistant")
agent_worker.add_agent(my_agent)
# Start the worker
worker.start()
```
"""
def __init__(
self,
worker: TaskHubGrpcWorker,
callback: AgentResponseCallbackProtocol | None = None,
):
"""Initialize the worker wrapper.
Args:
worker: The durabletask worker instance to wrap
callback: Optional callback for agent response notifications
"""
self._worker = worker
self._callback = callback
self._registered_agents: dict[str, SupportsAgentRun] = {}
self._workflows: dict[str, Workflow] = {}
# Every workflow whose orchestration has been registered (top-level plus nested
# sub-workflows), keyed by case-folded name -> the registered instance, so a
# sub-workflow shared across the tree is registered once while two different
# workflows whose names collide (including case-only differences) are rejected.
self._registered_orchestrations: dict[str, Workflow] = {}
logger.debug("[DurableAIAgentWorker] Initialized with worker type: %s", type(worker).__name__)
def add_agent(
self,
agent: SupportsAgentRun,
callback: AgentResponseCallbackProtocol | None = None,
*,
entity_id: str | None = None,
) -> None:
"""Register an agent with the worker.
This method creates a durable entity class for the agent and registers
it with the underlying durabletask worker. The entity will be accessible
by the name "dafx-{entity_id or agent_name}".
Args:
agent: The agent to register (must have a name)
callback: Optional callback for this specific agent (overrides worker-level callback)
entity_id: Optional identity to register the entity under instead of
``agent.name``. Workflow hosting passes the executor's ``id`` so the
entity matches the identity the orchestrator dispatches to.
Raises:
ValueError: If the agent doesn't have a name or is already registered
"""
registration_name = entity_id or agent.name
if not registration_name:
raise ValueError("Agent must have a name to be registered")
if registration_name in self._registered_agents:
raise ValueError(f"Agent '{registration_name}' is already registered")
logger.info(
"[DurableAIAgentWorker] Registering agent: %s as entity: dafx-%s", registration_name, registration_name
)
# Store the agent reference
self._registered_agents[registration_name] = agent
# Use agent-specific callback if provided, otherwise use worker-level callback
effective_callback = callback or self._callback
# Create a configured entity class using the factory
entity_class = self.__create_agent_entity(agent, effective_callback, entity_id=registration_name)
# Register the entity class with the worker
# The worker.add_entity method takes a class
entity_registered: str = self._worker.add_entity(entity_class)
logger.debug(
"[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s",
entity_registered,
registration_name,
)
def start(self) -> None:
"""Start the worker to begin processing tasks.
Note:
This method delegates to the underlying worker's start method.
The worker will block until stopped.
"""
logger.info("[DurableAIAgentWorker] Starting worker with %d registered agents", len(self._registered_agents))
self._worker.start()
def stop(self) -> None:
"""Stop the worker gracefully.
Note:
This method delegates to the underlying worker's stop method.
"""
logger.info("[DurableAIAgentWorker] Stopping worker")
self._worker.stop()
@property
def registered_agent_names(self) -> list[str]:
"""Get the names of all registered agents.
Returns:
List of agent names (without the dafx- prefix)
"""
return list(self._registered_agents.keys())
@property
def registered_workflow_names(self) -> list[str]:
"""Get the names of all workflows configured on this worker.
Returns:
List of workflow names (the identities used to derive each workflow's
``dafx-{name}`` orchestration).
"""
return list(self._workflows.keys())
# -----------------------------------------------------------------
# Workflow support
# -----------------------------------------------------------------
def configure_workflow(
self,
workflow: Workflow,
callback: AgentResponseCallbackProtocol | None = None,
) -> None:
"""Register a :class:`Workflow` for automatic orchestration.
This extracts agents from the workflow and registers them as durable
entities, registers non-agent executors as activities, and creates an
orchestrator function that drives the workflow graph.
Multiple workflows can be hosted on one worker: call this method once per
workflow. Each workflow is keyed by its :attr:`Workflow.name`, and its
durable primitives are scoped by that name (orchestration
``dafx-{name}``; activities/entities ``dafx-{name}-{executorId}``) so two
co-hosted workflows that reuse an executor id do not collide.
Sub-workflows nest: if the workflow contains
:class:`~agent_framework.WorkflowExecutor` nodes, each inner workflow's
orchestration/agents/activities are registered too (deduped by name) so the
parent can drive them as durable child orchestrations.
Args:
workflow: The MAF :class:`Workflow` to register. Must have an explicit,
stable :attr:`Workflow.name` (an auto-generated
``WorkflowBuilder-<uuid>`` name is rejected because it is not stable
across restarts and would break durable resume). Every nested
sub-workflow must likewise be named.
callback: Optional callback for agent response notifications.
Raises:
ValueError: If the workflow (or a nested sub-workflow) name is missing,
invalid, or auto-generated, or if the top-level workflow name is
already registered on this worker.
"""
workflow_name = workflow.name
validate_workflow_name(workflow_name)
if any(name.casefold() == workflow_name.casefold() for name in self._workflows):
raise ValueError(
f"Workflow '{workflow_name}' is already registered on this worker "
"(workflow names are compared case-insensitively)."
)
# Validate the whole composition (top-level plus every nested sub-workflow)
# up front, so an invalid/auto-generated nested name (or an executor id that
# would break durable naming / nested-HITL addressing) fails before any
# registration side effects leave the worker partially configured.
hosted_workflows = list(collect_hosted_workflows(workflow))
for hosted in hosted_workflows:
validate_workflow_name(hosted.name)
for executor_id in hosted.executors:
validate_executor_id(executor_id)
# Check every cross-call collision *before* mutating any state, so a clash
# between a nested sub-workflow and an already-registered orchestration cannot
# leave the worker partially configured (e.g. the top-level name added to
# ``_workflows`` while a later child fails). Registration below is then a pure
# commit step.
for hosted in hosted_workflows:
existing = self._registered_orchestrations.get(hosted.name.casefold())
if existing is not None and existing is not hosted:
raise ValueError(
f"A different workflow named '{hosted.name}' collides with already-registered "
f"'{existing.name}' on this worker. A workflow name maps to a single durable "
f"orchestration ('dafx-{hosted.name}'), compared case-insensitively; rename one "
"of them."
)
self._workflows[workflow_name] = workflow
# Commit: register the top-level workflow and every nested sub-workflow (deduped
# by name), so the parent can drive sub-workflows as durable child orchestrations.
for hosted in hosted_workflows:
if hosted.name.casefold() in self._registered_orchestrations:
continue
self._register_single_workflow(hosted, callback)
def _register_single_workflow(
self,
workflow: Workflow,
callback: AgentResponseCallbackProtocol | None,
) -> None:
"""Register one workflow's durable primitives (no recursion into sub-workflows).
The "what to register" decision (agent -> entity, non-agent -> activity,
sub-workflow -> child orchestration) is shared with the Azure Functions host
via ``plan_workflow_registration``.
"""
validate_workflow_name(workflow.name)
self._registered_orchestrations[workflow.name.casefold()] = workflow
plan = plan_workflow_registration(workflow)
# Register agent executors as durable entities, scoped by workflow name so
# two workflows that reuse an executor id register distinct entities. The
# entity is keyed by the scoped identity (the same identity the orchestrator
# dispatches to); the entity *key* at run time is the orchestration instance
# id, which keeps conversation state isolated per run.
for agent_executor in plan.agent_executors:
scoped_id = workflow_scoped_executor_id(workflow.name, agent_executor.id)
if scoped_id not in self._registered_agents:
self.add_agent(agent_executor.agent, callback=callback, entity_id=scoped_id)
# Register non-agent executors as durable activities, scoped by workflow name.
# WorkflowExecutor nodes are intentionally not registered as activities: their
# inner workflows are registered separately (above, via collect_hosted_workflows)
# and driven as child orchestrations.
for executor in plan.activity_executors:
self._register_executor_activity(workflow, executor)
# Register this workflow's orchestrator under its per-workflow name.
self._register_workflow_orchestrator(workflow)
logger.info(
"[DurableAIAgentWorker] Workflow '%s' configured with %d executors "
"(%d agents, %d activities, %d sub-workflows)",
workflow.name,
len(workflow.executors),
len(plan.agent_executors),
len(plan.activity_executors),
len(plan.subworkflow_executors),
)
def _register_executor_activity(self, workflow: Workflow, executor: Any) -> None:
"""Register a non-agent executor as a durabletask activity (workflow-scoped)."""
captured_executor = executor
captured_workflow = workflow
activity_name = workflow_executor_activity_name(workflow.name, executor.id)
def executor_activity(ctx: ActivityContext, input_data: str) -> str:
return execute_workflow_activity(captured_executor, input_data, captured_workflow)
# Give the function the expected name for registration
executor_activity.__name__ = activity_name
executor_activity.__qualname__ = activity_name
self._worker.add_activity(executor_activity)
logger.debug("[DurableAIAgentWorker] Registered activity: %s", activity_name)
def _register_workflow_orchestrator(self, workflow: Workflow) -> None:
"""Register a workflow's orchestrator function under its per-workflow name."""
captured_workflow = workflow
orchestrator_name = workflow_orchestrator_name(workflow.name)
def workflow_orchestrator(context: OrchestrationContext, input_data: Any) -> Any:
# Pass the deserialized client input straight to the shared engine, which
# reconstructs the start executor's declared type (see _coerce_initial_input).
initial_message = input_data
shared_state: dict[str, Any] = {}
dt_ctx = DurableTaskWorkflowContext(context)
outputs = yield from run_workflow_orchestrator(dt_ctx, captured_workflow, initial_message, shared_state)
return outputs # noqa: B901
workflow_orchestrator.__name__ = orchestrator_name
workflow_orchestrator.__qualname__ = orchestrator_name
self._worker.add_orchestrator(workflow_orchestrator)
logger.debug("[DurableAIAgentWorker] Registered workflow orchestrator: %s", orchestrator_name)
def __create_agent_entity(
self,
agent: SupportsAgentRun,
callback: AgentResponseCallbackProtocol | None = None,
*,
entity_id: str | None = None,
) -> type[DurableTaskEntityStateProvider]:
"""Factory function to create a DurableEntity class configured with an agent.
This factory creates a new class that combines the entity state provider
with the agent execution logic. Each agent gets its own entity class.
Args:
agent: The agent instance to wrap
callback: Optional callback for agent responses
entity_id: Optional identity to register the entity under instead of
``agent.name`` (used by workflow hosting to key entities by
executor id).
Returns:
A new DurableEntity subclass configured for this agent
"""
agent_name = entity_id or agent.name or type(agent).__name__
entity_name = f"dafx-{agent_name}"
class ConfiguredAgentEntity(DurableTaskEntityStateProvider):
"""Durable entity configured with a specific agent instance."""
def __init__(self) -> None:
super().__init__()
# Create the AgentEntity with this state provider
self._agent_entity = AgentEntity(
agent=agent,
callback=callback,
state_provider=self,
)
logger.debug(
"[ConfiguredAgentEntity] Initialized entity for agent: %s (entity name: %s)",
agent_name,
entity_name,
)
def run(self, request: Any) -> Any:
"""Handle run requests from clients or orchestrations.
Args:
request: RunRequest as dict or string
Returns:
AgentResponse as dict
"""
logger.debug("[ConfiguredAgentEntity.run] Executing agent: %s", agent_name)
# Run on the shared persistent loop so async resources created by
# shared agent clients/credentials stay bound to a live loop across
# successive entity invocations (avoids cross-loop hangs).
response = run_agent_coroutine(self._agent_entity.run(request))
return response.to_dict()
def reset(self) -> None:
"""Reset the agent's conversation history."""
logger.debug("[ConfiguredAgentEntity.reset] Resetting agent: %s", agent_name)
self._agent_entity.reset()
# Set the entity name to match the prefixed agent name
# This is used by durabletask to register the entity
ConfiguredAgentEntity.__name__ = entity_name
ConfiguredAgentEntity.__qualname__ = entity_name
return ConfiguredAgentEntity
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable hosting of Microsoft Agent Framework workflows.
This subpackage turns a MAF :class:`~agent_framework.Workflow` into durable
primitives -- a single orchestrator, agent entities, and non-agent executor
activities -- that run on either a standalone Durable Task worker or Azure
Functions. The host-agnostic engine lives here; each host programs against the
:class:`~.context.WorkflowOrchestrationContext` protocol.
"""
@@ -0,0 +1,186 @@
# Copyright (c) Microsoft. All rights reserved.
"""Host-agnostic execution of non-agent workflow executors as durable activities.
When a MAF :class:`Workflow` runs as a durable orchestration, each non-agent
executor is dispatched as a durable *activity*. The activity body is identical
regardless of host (Azure Functions or a standalone durabletask worker): it
deserializes the activity input, runs the executor (or a human-in-the-loop
response handler), diffs the shared state, and serializes the executor's
outputs, sent messages, shared-state changes, and any pending HITL requests back
to the orchestrator.
This module provides that shared body as :func:`execute_workflow_activity` so
both host adapters call one implementation instead of duplicating it.
"""
from __future__ import annotations
import asyncio
import json
from copy import deepcopy
from typing import Any, cast
from agent_framework import Executor, Workflow, WorkflowEvent
from agent_framework._workflows._runner_context import YieldOutputEventType
from agent_framework._workflows._state import State
from .orchestrator import (
SOURCE_HITL_RESPONSE,
SOURCE_ORCHESTRATOR,
execute_hitl_response_handler,
)
from .runner_context import CapturingRunnerContext
from .serialization import deserialize_value, serialize_value, serialize_workflow_event
def execute_workflow_activity(executor: Executor, input_json: str, workflow: Workflow | None = None) -> str:
"""Execute a single non-agent workflow executor and return its serialized result.
This is the host-agnostic activity body shared by the Azure Functions and
standalone durabletask workflow hosts.
Args:
executor: The non-agent executor instance to run.
input_json: JSON-encoded activity input with keys ``message``,
``shared_state_snapshot``, and ``source_executor_ids``.
workflow: The owning workflow, used to classify the executor's
``yield_output`` payloads as final ``output`` vs ``intermediate``.
When omitted, all yielded outputs are treated as final outputs.
Returns:
A JSON string with keys ``sent_messages``, ``outputs``, ``events``,
``shared_state_updates``, ``shared_state_deletes``, and
``pending_request_info_events``.
Raises:
ValueError: If the input does not decode to a JSON object, or a HITL
message payload is not a JSON object.
"""
data_obj = json.loads(input_json)
if not isinstance(data_obj, dict):
raise ValueError("Activity input must decode to a JSON object")
data = cast(dict[str, Any], data_obj)
message_data = data.get("message")
# The orchestrator may pass null for these when shared state / sources are
# omitted, so coerce None to the appropriate empty default.
shared_state_snapshot: dict[str, Any] = data.get("shared_state_snapshot") or {}
source_executor_ids = cast(list[str], data.get("source_executor_ids") or [SOURCE_ORCHESTRATOR])
# Reconstruct the message - deserialize_value restores the original typed
# objects from the encoded data (with type markers).
message = deserialize_value(message_data)
# A HITL response is identified by a source id starting with the HITL prefix.
is_hitl_response = any(s.startswith(SOURCE_HITL_RESPONSE) for s in source_executor_ids)
def classify_yielded_output(executor_id: str) -> YieldOutputEventType | None:
# Mirror the core runner's classification so intermediate executors'
# yields are not surfaced as final workflow outputs.
if workflow is None:
return "output"
if workflow.is_terminal_executor(executor_id):
return "output"
if workflow.is_intermediate_executor(executor_id):
return "intermediate"
return None
async def _run() -> dict[str, Any]:
runner_context = CapturingRunnerContext()
runner_context.set_yield_output_classifier(classify_yielded_output)
shared_state = State()
# Deserialize shared state values to reconstruct dataclasses / Pydantic models.
deserialized_state: dict[str, Any] = {str(k): deserialize_value(v) for k, v in shared_state_snapshot.items()}
# Snapshot the deserialized (in-memory) state for diffing. State.export_state()
# returns the in-memory committed objects, so the snapshot must hold objects
# too (deepcopy) - comparing against a serialized snapshot would mark every
# key as changed.
original_snapshot = deepcopy(deserialized_state)
shared_state.import_state(deserialized_state)
if is_hitl_response:
if not isinstance(message_data, dict):
raise ValueError("HITL message payload must be a JSON object")
await execute_hitl_response_handler(
executor=executor,
hitl_message=cast(dict[str, Any], message_data),
shared_state=shared_state,
runner_context=runner_context,
)
else:
await executor.execute(
message=message,
source_executor_ids=source_executor_ids,
state=shared_state,
runner_context=runner_context,
)
# Commit pending state changes and compute the diff vs the original snapshot.
shared_state.commit()
current_state = shared_state.export_state()
original_keys: set[str] = set(original_snapshot.keys())
current_keys: set[str] = set(current_state.keys())
# Deleted = was in original, not in current.
deletes: set[str] = original_keys - current_keys
# Updates = keys that are new or whose value changed.
updates: dict[str, Any] = {}
for key in current_keys:
if key not in original_keys or current_state[key] != original_snapshot.get(key):
updates[key] = current_state[key]
sent_messages = await runner_context.drain_messages()
events = await runner_context.drain_events()
# Serialize the executor's workflow events so the orchestrator can republish
# them to the streaming custom status. Output payloads are also extracted
# separately for message routing and the final workflow result.
outputs: list[Any] = []
serialized_events: list[dict[str, Any]] = []
for event in events:
if not isinstance(event, WorkflowEvent):
continue
serialized_events.append(serialize_workflow_event(event))
if event.type == "output":
outputs.append(serialize_value(event.data))
# Serialize pending HITL request info events for the orchestrator.
pending_request_info_events = await runner_context.get_pending_request_info_events()
serialized_pending_requests: list[dict[str, Any]] = []
for _request_id, event in pending_request_info_events.items():
serialized_pending_requests.append({
"request_id": event.request_id,
"source_executor_id": event.source_executor_id,
"data": serialize_value(event.data),
"request_type": f"{type(event.data).__module__}:{type(event.data).__name__}",
"response_type": f"{event.response_type.__module__}:{event.response_type.__name__}"
if event.response_type
else None,
})
# Serialize sent messages for JSON compatibility.
serialized_sent_messages: list[dict[str, Any]] = []
for _source_id, msg_list in sent_messages.items():
for msg in msg_list:
serialized_sent_messages.append({
"message": serialize_value(msg.data),
"target_id": msg.target_id,
"source_id": msg.source_id,
})
serialized_updates = {k: serialize_value(v) for k, v in updates.items()}
return {
"sent_messages": serialized_sent_messages,
"outputs": outputs,
"events": serialized_events,
"shared_state_updates": serialized_updates,
"shared_state_deletes": list(deletes),
"pending_request_info_events": serialized_pending_requests,
}
result = asyncio.run(_run())
return json.dumps(result)
@@ -0,0 +1,527 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow client wrapper for Durable Task Agent Framework.
This module provides :class:`DurableWorkflowClient` for external clients to start,
await, and drive (including human-in-the-loop) workflows registered on a worker via
``DurableAIAgentWorker.configure_workflow``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import AsyncIterator
from typing import Any, cast
from agent_framework import WorkflowEvent
from durabletask.client import TaskHubGrpcClient
from .naming import (
qualify_subworkflow_request_id,
split_subworkflow_request_id,
workflow_orchestrator_name,
)
from .serialization import (
deserialize_workflow_event,
deserialize_workflow_output,
strip_pickle_markers,
strip_subworkflow_markers,
)
logger = logging.getLogger("agent_framework.durabletask")
class DurableWorkflowClient:
"""Client wrapper for starting and driving durable workflows externally.
This class wraps a durabletask ``TaskHubGrpcClient`` and provides a convenient
interface for the workflow registered by ``DurableAIAgentWorker.configure_workflow``:
starting it, awaiting its output, and responding to human-in-the-loop (HITL) pauses.
For interacting with individual durable *agents*, use
:class:`~agent_framework_durabletask.DurableAIAgentClient` instead. Both wrap the
same underlying ``TaskHubGrpcClient``, so an application that needs both can
construct both over one client.
Example:
```python
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from agent_framework.azure import DurableWorkflowClient
# Create the underlying client
client = DurableTaskSchedulerClient(host_address="localhost:8080", taskhub="default")
# Wrap it with the workflow client, defaulting to the workflow named "orders"
workflow_client = DurableWorkflowClient(client, workflow_name="orders")
# Start a workflow and wait for its output
instance_id = workflow_client.start_workflow(input="some input")
output = workflow_client.await_workflow_output(instance_id)
print(output)
# A client without a default targets workflows explicitly per call:
multi = DurableWorkflowClient(client)
instance_id = multi.start_workflow(input="...", workflow_name="billing")
```
"""
def __init__(self, client: TaskHubGrpcClient, *, workflow_name: str | None = None):
"""Initialize the workflow client wrapper.
Args:
client: The durabletask client instance to wrap.
workflow_name: Optional default workflow name to target. When set, the
per-call ``workflow_name`` may be omitted. When a worker hosts a
single workflow, set this once here; when it hosts several, either
set a default and override per call, or pass ``workflow_name`` on
each call.
"""
self._client = client
self._default_workflow_name = workflow_name
logger.debug("[DurableWorkflowClient] Initialized with client type: %s", type(client).__name__)
def _resolve_workflow_name(self, workflow_name: str | None) -> str:
"""Resolve the effective workflow name from a per-call value or the default.
Raises:
ValueError: If neither a per-call ``workflow_name`` nor a constructor
default was provided.
"""
name = workflow_name or self._default_workflow_name
if not name:
raise ValueError(
"No workflow name provided. Pass workflow_name=... (or set a default on "
"DurableWorkflowClient(workflow_name=...)) so the client can target the "
"right orchestration."
)
return name
def start_workflow(
self, input: Any = None, *, workflow_name: str | None = None, instance_id: str | None = None
) -> str:
"""Start the workflow orchestration registered by ``configure_workflow``.
This schedules the orchestration ``dafx-{workflow_name}`` that
``DurableAIAgentWorker.configure_workflow`` auto-registers, so callers do
not need to know its internal name.
Args:
input: The initial message/payload for the workflow.
workflow_name: The workflow to start. Optional if a default was set on
the client; required otherwise.
instance_id: Optional explicit orchestration instance ID. If omitted, one
is generated.
Returns:
The orchestration instance ID, for use with ``await_workflow_output``.
"""
orchestration_name = workflow_orchestrator_name(self._resolve_workflow_name(workflow_name))
new_instance_id = self._client.schedule_new_orchestration(
orchestration_name,
# Neutralize a forged sub-workflow envelope before scheduling: only an
# internal child dispatch (post trust boundary) may carry those reserved
# keys, so stripping them here keeps untrusted input off the orchestrator's
# trusted-deserialization path even if start_workflow is exposed remotely.
input=strip_subworkflow_markers(input),
instance_id=instance_id,
)
logger.debug("[DurableWorkflowClient] Started workflow instance: %s", new_instance_id)
return new_instance_id
def _is_owned_orchestration(self, state: Any, workflow_name: str | None) -> bool:
"""Return whether ``state`` belongs to the targeted workflow.
Ownership validation is opt-in: when neither a per-call ``workflow_name``
nor a constructor default is set there is nothing to validate against, so
this returns ``True``. When a name is resolvable, the instance's
orchestration name must equal ``dafx-{workflow_name}`` (compared
case-insensitively, mirroring the Azure Functions host's route-scoping
check). This guards against addressing an instance that belongs to a
different workflow on the same task hub.
"""
name = workflow_name or self._default_workflow_name
if not name:
return True
expected = workflow_orchestrator_name(name)
actual = getattr(state, "name", None)
return isinstance(actual, str) and actual.casefold() == expected.casefold()
def await_workflow_output(
self, instance_id: str, *, workflow_name: str | None = None, timeout_seconds: int = 300
) -> Any:
"""Wait for a workflow orchestration to complete and return its output.
Args:
instance_id: The instance ID returned by ``start_workflow``.
workflow_name: Optional workflow name; when set (or a client default is
set) the instance's orchestration is validated to belong to that
workflow.
timeout_seconds: Maximum time, in seconds, to wait for completion.
Returns:
The deserialized workflow output (typically a list of yielded outputs),
or ``None`` if the workflow produced no output.
Raises:
TimeoutError: If the workflow does not complete within ``timeout_seconds``.
RuntimeError: If the workflow completes with a non-successful status.
ValueError: If the instance does not belong to the targeted workflow.
"""
metadata = self._client.wait_for_orchestration_completion(instance_id, timeout=timeout_seconds)
if metadata is None:
raise TimeoutError(f"Workflow '{instance_id}' did not complete within {timeout_seconds}s")
if not self._is_owned_orchestration(metadata, workflow_name):
raise ValueError(f"Instance '{instance_id}' does not belong to the targeted workflow.")
status = metadata.runtime_status.name
if status != "COMPLETED":
raise RuntimeError(f"Workflow '{instance_id}' ended with status {status}: {metadata.serialized_output}")
if metadata.serialized_output is None:
return None
# The shared activity encodes each yielded output with serialize_value()
# before it reaches the orchestrator, so typed objects come back as
# checkpoint-marker dicts. Reconstruct the originals before returning.
return deserialize_workflow_output(json.loads(metadata.serialized_output))
async def run_workflow(
self,
input: Any = None,
*,
workflow_name: str | None = None,
instance_id: str | None = None,
wait: bool = True,
timeout_seconds: int = 300,
) -> Any:
"""Start the workflow and, by default, await its output.
The async counterpart to ``start_workflow`` + ``await_workflow_output``. The
underlying durabletask client is synchronous, so the blocking calls run in a
worker thread to avoid blocking the event loop.
Args:
input: The initial message/payload for the workflow.
workflow_name: The workflow to start. Optional if a default was set on
the client; required otherwise.
instance_id: Optional explicit orchestration instance ID. If omitted,
one is generated.
wait: When ``True`` (default), wait for completion and return the
deserialized output. When ``False``, return the instance ID as
soon as the workflow is scheduled (use with ``stream_workflow`` or
the HITL methods).
timeout_seconds: Maximum time, in seconds, to wait for completion when
``wait`` is ``True``.
Returns:
The deserialized workflow output when ``wait`` is ``True``; otherwise
the orchestration instance ID.
Raises:
TimeoutError: If ``wait`` is ``True`` and the workflow does not complete
within ``timeout_seconds``.
RuntimeError: If ``wait`` is ``True`` and the workflow ends with a
non-successful status.
"""
new_instance_id = await asyncio.to_thread(
self.start_workflow, input, workflow_name=workflow_name, instance_id=instance_id
)
if not wait:
return new_instance_id
return await asyncio.to_thread(
self.await_workflow_output, new_instance_id, workflow_name=workflow_name, timeout_seconds=timeout_seconds
)
def get_runtime_status(self, instance_id: str, *, workflow_name: str | None = None) -> str | None:
"""Return the workflow's current runtime status name, or ``None`` if unknown.
Lets callers distinguish a workflow that is still running or paused for
human input from one that has reached a terminal state (for example
``COMPLETED``, ``FAILED``, or ``TERMINATED``) — useful when polling, so a
workflow that ends without pausing is not mistaken for one that never paused.
Args:
instance_id: The instance ID returned by ``start_workflow``.
workflow_name: Optional workflow name; when set (or a client default is
set) an instance that does not belong to that workflow returns
``None`` (treated as "not found").
Returns:
The runtime status name (e.g. ``"RUNNING"``, ``"COMPLETED"``), or
``None`` if no state is available for the instance or it belongs to a
different workflow.
"""
state = self._client.get_orchestration_state(instance_id)
if state is None:
return None
if not self._is_owned_orchestration(state, workflow_name):
return None
return state.runtime_status.name
async def stream_workflow(
self,
instance_id: str,
*,
workflow_name: str | None = None,
poll_interval_seconds: float = 1.0,
timeout_seconds: int | None = None,
) -> AsyncIterator[WorkflowEvent]:
"""Stream the workflow's events as typed :class:`WorkflowEvent` objects.
Yields the workflow's events (``executor_invoked`` / ``executor_completed`` /
``output`` / ``request_info`` / ...) in order, finishing when the workflow
reaches a terminal state. Each event's ``data`` payload is already
reconstructed into its original typed object, so callers do not deserialize
anything themselves.
This is brokerless: it polls the orchestration custom status, into which the
orchestrator publishes accumulated events after each superstep. Granularity is
per executor and per yielded output, not token-level. Non-agent executors emit
events with data payloads; agent executors emit coarse ``executor_invoked`` /
``executor_completed`` lifecycle events. The custom status accumulates events
for the run, so this suits workflows with a bounded number of executors rather
than very long-running fan-outs.
Args:
instance_id: The instance ID returned by ``start_workflow``.
workflow_name: Optional workflow name; when set (or a client default is
set) the instance is validated to belong to that workflow before
streaming.
poll_interval_seconds: Delay between status polls.
timeout_seconds: Optional overall timeout; ``None`` streams until the
workflow reaches a terminal state.
Yields:
:class:`WorkflowEvent` objects as the workflow progresses.
Raises:
TimeoutError: If ``timeout_seconds`` elapses before completion.
ValueError: If the instance does not belong to the targeted workflow.
"""
cursor = 0
terminal_statuses = {"COMPLETED", "FAILED", "TERMINATED"}
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
ownership_checked = False
while True:
state = await asyncio.to_thread(self._client.get_orchestration_state, instance_id)
# Validate ownership once, on the first poll that returns state.
if state is not None and not ownership_checked:
if not self._is_owned_orchestration(state, workflow_name):
raise ValueError(f"Instance '{instance_id}' does not belong to the targeted workflow.")
ownership_checked = True
if state is not None:
status = self._parse_custom_status(state.serialized_custom_status)
if status is not None:
events = status.get("events")
if isinstance(events, list):
typed_events = cast("list[dict[str, Any]]", events)
while cursor < len(typed_events):
yield deserialize_workflow_event(typed_events[cursor])
cursor += 1
runtime_status = state.runtime_status.name if state is not None else None
if runtime_status in terminal_statuses:
return
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError(f"Workflow '{instance_id}' did not complete within {timeout_seconds}s")
await asyncio.sleep(poll_interval_seconds)
def get_pending_hitl_requests(self, instance_id: str, *, workflow_name: str | None = None) -> list[dict[str, Any]]:
"""Return the workflow's pending human-in-the-loop (HITL) requests, if any.
While a workflow is paused awaiting human input, the orchestrator records the
open requests in its custom status. This method reads and normalizes that
status so callers do not need to know its internal schema.
Args:
instance_id: The workflow instance ID returned by ``start_workflow``.
workflow_name: Optional workflow name; when set (or a client default is
set) an instance that does not belong to that workflow returns an
empty list (treated as "not found").
Returns:
A list of pending requests. Each entry contains ``request_id``,
``source_executor_id``, ``data``, ``request_type``, and ``response_type``.
Empty if the workflow is not currently waiting for human input.
Note:
Requests originating in a nested sub-workflow are included with a
**qualified** ``request_id`` (``{executorId}~{ordinal}~{requestId}``, nested
for deeper levels). Pass that qualified id straight back to
:meth:`send_hitl_response`; it is routed to the owning child orchestration
automatically, so the caller only ever addresses the top-level instance.
"""
state = self._client.get_orchestration_state(instance_id)
if state is None or not state.serialized_custom_status:
return []
if not self._is_owned_orchestration(state, workflow_name):
return []
return self._collect_pending_hitl_requests(state.serialized_custom_status)
@staticmethod
def _parse_custom_status(serialized_custom_status: str | None) -> dict[str, Any] | None:
"""Parse a serialized custom status into a dict, or ``None`` if unusable.
Returns ``None`` for an empty/absent status or any value that is not a JSON
object (the only shape the orchestrator ever writes), so callers can treat
"no usable status" uniformly.
"""
if not serialized_custom_status:
return None
try:
parsed = json.loads(serialized_custom_status)
except (json.JSONDecodeError, TypeError):
return None
return cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else None
def _collect_pending_hitl_requests(self, serialized_custom_status: str) -> list[dict[str, Any]]:
"""Collect an orchestration's pending requests plus any nested sub-workflow ones.
Nested requests (discovered via the ``subworkflows`` map the parent records in
its custom status as ``{executorId: [childInstanceId, ...]}``) are qualified by
``(executorId, ordinal)`` so deeper requests accumulate a full
``{executorId}~{ordinal}~...~{requestId}`` path and a node with several children
keeps each one addressable. Child instances are reached directly by id (already
trusted, having come from the parent's status), so no per-child ownership check
is applied.
"""
status_dict = self._parse_custom_status(serialized_custom_status)
if status_dict is None:
return []
requests: list[dict[str, Any]] = []
pending = status_dict.get("pending_requests")
if isinstance(pending, dict):
for request_id, req_data in cast(dict[str, Any], pending).items():
if not isinstance(req_data, dict):
continue
req = cast(dict[str, Any], req_data)
requests.append({
"request_id": req.get("request_id", request_id),
"source_executor_id": req.get("source_executor_id"),
"data": req.get("data"),
"request_type": req.get("request_type"),
"response_type": req.get("response_type"),
})
subworkflows = status_dict.get("subworkflows")
if isinstance(subworkflows, dict):
for executor_id, child_ids in cast(dict[str, Any], subworkflows).items():
children: list[Any] = cast("list[Any]", child_ids) if isinstance(child_ids, list) else []
for ordinal, child_instance_id in enumerate(children):
if not isinstance(child_instance_id, str):
continue
child_state = self._client.get_orchestration_state(child_instance_id)
if child_state is None or not child_state.serialized_custom_status:
continue
for child_req in self._collect_pending_hitl_requests(child_state.serialized_custom_status):
qualified = dict(child_req)
qualified["request_id"] = qualify_subworkflow_request_id(
executor_id, ordinal, child_req["request_id"]
)
requests.append(qualified)
return requests
def send_hitl_response(
self, instance_id: str, request_id: str, response: Any, *, workflow_name: str | None = None
) -> None:
"""Send a response to a pending HITL request, resuming the workflow.
The orchestrator correlates the response by using ``request_id`` as the
external-event name, so callers do not need to know that convention.
Args:
instance_id: The workflow instance ID.
request_id: The pending request's ID (from ``get_pending_hitl_requests``).
May be a **qualified** id (``{executorId}~{ordinal}~{requestId}``) for a
request that originated in a nested sub-workflow; it is routed to the
owning child orchestration automatically.
response: The response payload (e.g. a dict matching the expected
response type the executor's ``@response_handler`` expects).
workflow_name: Optional workflow name; when set (or a client default is
set) the instance is validated to belong to that workflow before the
event is raised, so a response is never injected into a different
workflow's orchestration.
Raises:
ValueError: If the instance does not belong to the targeted workflow, or a
qualified id references a sub-workflow that is not currently active.
Note:
The payload is sanitized with ``strip_pickle_markers`` before delivery to
neutralize pickle-marker injection, since the worker deserializes it.
"""
# Validate ownership before raising the event when a target is resolvable.
if workflow_name or self._default_workflow_name:
state = self._client.get_orchestration_state(instance_id)
if state is None or not self._is_owned_orchestration(state, workflow_name):
raise ValueError(f"Instance '{instance_id}' does not belong to the targeted workflow.")
# A qualified id addresses a nested sub-workflow: resolve it to the owning child
# orchestration instance and the bare request id the child is actually waiting on.
target_instance_id, bare_request_id = self._resolve_hitl_target(instance_id, request_id)
safe_response = strip_pickle_markers(response)
self._client.raise_orchestration_event(target_instance_id, event_name=bare_request_id, data=safe_response)
logger.debug(
"[DurableWorkflowClient] Sent HITL response for request %s on instance %s",
bare_request_id,
target_instance_id,
)
def _resolve_hitl_target(self, instance_id: str, request_id: str) -> tuple[str, str]:
"""Resolve a possibly-qualified request id to ``(owning_instance_id, bare_request_id)``.
An unqualified id (no well-formed hop) targets ``instance_id`` directly. A
qualified id ``{executorId}~{ordinal}~{rest}`` addresses a nested sub-workflow:
the executor's child instance id is read from this instance's ``subworkflows``
custom-status map (a list selected by ``ordinal``) and the remainder is resolved
recursively, so arbitrarily deep nesting lands on the leaf child orchestration
and its bare request id.
"""
hop = split_subworkflow_request_id(request_id)
if hop is None:
return instance_id, request_id
executor_id, ordinal, remainder = hop
child_instance_id = self._lookup_subworkflow_instance(instance_id, executor_id, ordinal)
if child_instance_id is None:
raise ValueError(
f"No active sub-workflow '{executor_id}' (ordinal {ordinal}) found for instance "
f"'{instance_id}' while routing HITL response for request '{request_id}'."
)
return self._resolve_hitl_target(child_instance_id, remainder)
def _lookup_subworkflow_instance(self, instance_id: str, executor_id: str, ordinal: int) -> str | None:
"""Return the child orchestration instance id for ``(executor_id, ordinal)``, if active.
Reads the ``subworkflows`` map (``{executorId: [childInstanceId, ...]}``) the
parent records in its custom status while dispatching sub-workflow nodes, and
selects the child at ``ordinal`` (its dispatch order this superstep).
"""
state = self._client.get_orchestration_state(instance_id)
custom_status = self._parse_custom_status(state.serialized_custom_status if state else None)
if custom_status is None:
return None
subworkflows = custom_status.get("subworkflows")
if not isinstance(subworkflows, dict):
return None
children_raw = cast(dict[str, Any], subworkflows).get(executor_id)
if not isinstance(children_raw, list):
return None
children = cast("list[Any]", children_raw)
if ordinal < 0 or ordinal >= len(children):
return None
child = children[ordinal]
return child if isinstance(child, str) else None
@@ -0,0 +1,195 @@
# Copyright (c) Microsoft. All rights reserved.
"""Protocol definition for workflow orchestration contexts.
This module defines the ``WorkflowOrchestrationContext`` protocol that abstracts
the differences between Azure Functions' ``DurableOrchestrationContext`` and the
standalone ``durabletask.task.OrchestrationContext``. The shared workflow
orchestrator (:func:`run_workflow_orchestrator`) programs against this protocol
so that the same orchestration logic works on any host.
Each host provides a thin adapter that maps its native context to this protocol:
- ``DurableTaskWorkflowContext`` (this package) — wraps ``OrchestrationContext``
- ``AzureFunctionsWorkflowContext`` (azurefunctions package) — wraps
``DurableOrchestrationContext``
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class WorkflowOrchestrationContext(Protocol):
"""Host-agnostic interface for workflow orchestration primitives.
All methods that return yieldable tasks return ``Any`` because the concrete
task types differ between hosting SDKs (``TaskBase`` for Azure Functions,
``Task[T]`` for durabletask). The generator-based orchestrator simply
yields these opaque objects back to the hosting framework.
"""
@property
def instance_id(self) -> str:
"""The unique ID of the current orchestration instance."""
...
@property
def is_replaying(self) -> bool:
"""Whether the orchestrator is replaying previously-recorded history.
Side effects intended to be observed live exactly once (for example,
publishing streaming status to the custom status) must be skipped while
this is ``True`` so they are not re-emitted on replay.
"""
...
@property
def supports_event_streaming(self) -> bool:
"""Whether this host streams the workflow event timeline via custom status.
The orchestrator accumulates the full :class:`WorkflowEvent` history and can
publish it to the orchestration custom status so a streaming client can
replay it (see ``DurableWorkflowClient.stream_workflow``). A host returns
``True`` only when both are true: it has a streaming consumer *and* its
custom status can carry an accumulating, payload-bearing event log.
The Azure Functions host returns ``False``: its Durable Functions custom
status is capped at 16 KB (UTF-16) by the WebJobs extension, and its HTTP
status endpoint exposes only ``state`` / ``pending_requests`` / ``output``,
never the event stream. Publishing the accumulating event log there would
overflow the cap and fail the orchestrator without serving any consumer.
When ``False``, the orchestrator skips event accumulation and omits
``events`` from the custom status; ``state`` and any ``pending_requests``
(needed for human-in-the-loop) are still published.
"""
...
@property
def current_utc_datetime(self) -> datetime:
"""The current replay-safe UTC datetime."""
...
def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any:
"""Create a yieldable task that runs an agent executor.
Args:
executor_id: Agent name / executor ID.
message: The text message to send to the agent.
orchestration_instance_id: Instance ID used as the entity session key.
Returns:
A yieldable task whose result is an ``AgentResponse``.
"""
...
def prepare_activity_task(self, activity_name: str, input_json: str) -> Any:
"""Create a yieldable task that runs an activity executor.
Args:
activity_name: The registered activity function name.
input_json: JSON-serialized activity input.
Returns:
A yieldable task whose result is a JSON string.
"""
...
def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any:
"""Create a yieldable task that runs a nested workflow as a child orchestration.
Used to drive a :class:`~agent_framework.WorkflowExecutor` node: the inner
workflow runs as its own durable orchestration (named ``dafx-{innerName}``),
independently checkpointed and observable, and its result flows back into
the parent's edge routing like any other executor's output.
Args:
name: The registered orchestration name to invoke (``dafx-{innerName}``).
input: The JSON-serializable input for the child orchestration.
instance_id: Optional deterministic child instance ID. The orchestrator
derives one from the parent instance so nested runs are discoverable
and replay-safe.
Returns:
A yieldable task whose result is the child orchestration's output.
"""
...
def task_all(self, tasks: list[Any]) -> Any:
"""Create a yieldable composite task that completes when *all* tasks complete.
Args:
tasks: List of yieldable tasks.
Returns:
A yieldable task whose result is a list of individual results.
"""
...
def task_any(self, tasks: list[Any]) -> Any:
"""Create a yieldable composite task that completes when *any* task completes.
Args:
tasks: List of yieldable tasks.
Returns:
A yieldable task whose result is the winning task.
"""
...
def wait_for_external_event(self, name: str) -> Any:
"""Create a yieldable task that waits for a named external event.
Args:
name: Event name to wait for.
Returns:
A yieldable task whose result is the event payload.
"""
...
def create_timer(self, fire_at: datetime) -> Any:
"""Create a yieldable timer task.
Args:
fire_at: UTC datetime when the timer should fire.
Returns:
A yieldable timer task.
"""
...
def set_custom_status(self, status: Any) -> None:
"""Set the orchestration's custom status (visible to external clients).
Args:
status: JSON-serializable status object.
"""
...
def new_uuid(self) -> str:
"""Generate a replay-safe UUID."""
...
def cancel_task(self, task: Any) -> None:
"""Best-effort cancellation of a pending task.
Args:
task: The task to cancel. If the underlying SDK does not support
cancellation this is a no-op.
"""
...
def get_task_result(self, task: Any) -> Any:
"""Extract the result from a completed task.
Args:
task: A completed task object.
Returns:
The result value.
"""
...
@@ -0,0 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.
"""DurableTask SDK adapter for WorkflowOrchestrationContext.
Wraps ``durabletask.task.OrchestrationContext`` to satisfy the
:class:`WorkflowOrchestrationContext` protocol.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, cast
from durabletask.task import (
OrchestrationContext,
Task,
when_all,
when_any,
)
from .._executors import OrchestrationAgentExecutor
from .._models import AgentSessionId, DurableAgentSession
from .._shim import DurableAIAgent
from .context import WorkflowOrchestrationContext
logger = logging.getLogger(__name__)
class DurableTaskWorkflowContext:
"""Adapter that maps ``OrchestrationContext`` to :class:`WorkflowOrchestrationContext`."""
def __init__(self, context: OrchestrationContext) -> None:
self._context = context
self._executor = OrchestrationAgentExecutor(context)
# -- Properties -----------------------------------------------------------
@property
def instance_id(self) -> str:
return self._context.instance_id
@property
def is_replaying(self) -> bool:
return self._context.is_replaying
@property
def supports_event_streaming(self) -> bool:
# The standalone DurableTask host exposes the event timeline to clients via
# DurableWorkflowClient.stream_workflow, and its DTS backend imposes no 16 KB
# custom-status cap, so the full accumulated event stream is published.
return True
@property
def current_utc_datetime(self) -> datetime:
return self._context.current_utc_datetime
# -- Agent / Activity dispatch --------------------------------------------
def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any:
session_id = AgentSessionId(name=executor_id, key=orchestration_instance_id)
session = DurableAgentSession(durable_session_id=session_id)
agent = DurableAIAgent(self._executor, executor_id)
return agent.run(message, session=session)
def prepare_activity_task(self, activity_name: str, input_json: str) -> Any:
return cast(Any, self._context.call_activity(activity_name, input=input_json))
def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any:
return cast(Any, self._context.call_sub_orchestrator(name, input=input, instance_id=instance_id))
# -- Composite tasks ------------------------------------------------------
def task_all(self, tasks: list[Any]) -> Any:
return when_all(tasks)
def task_any(self, tasks: list[Any]) -> Any:
return when_any(tasks)
# -- External events / timers ---------------------------------------------
def wait_for_external_event(self, name: str) -> Any:
return cast(Any, self._context).wait_for_external_event(name)
def create_timer(self, fire_at: datetime) -> Any:
return cast(Any, self._context).create_timer(fire_at)
# -- Status / utility -----------------------------------------------------
def set_custom_status(self, status: Any) -> None:
self._context.set_custom_status(status)
def new_uuid(self) -> str:
return self._context.new_uuid()
def cancel_task(self, task: Any) -> None:
# durabletask Task doesn't expose cancel(); this is a best-effort no-op.
cancel_fn = getattr(task, "cancel", None)
if callable(cancel_fn):
cancel_fn()
def get_task_result(self, task: Any) -> Any:
if isinstance(task, Task):
return cast(Any, task.get_result())
return getattr(task, "result", None)
# Ensure the adapter satisfies the protocol. Validated statically by the type
# checker (and at every ``run_workflow_orchestrator`` call site) with no runtime cost.
_protocol_check: type[WorkflowOrchestrationContext] = DurableTaskWorkflowContext
@@ -0,0 +1,299 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable naming helpers for hosting MAF Workflows.
A hosted workflow maps to durable primitives (an orchestration, plus an activity
or entity per executor) whose names must be **stable** across worker restarts:
durable replay only resumes an in-flight orchestration if the orchestration,
activity, and entity names still resolve to the same functions. This module
centralizes how those names are derived from a workflow name so every host (the
Azure Functions host and the standalone durabletask worker) and the client agree
on one scheme.
Naming scheme (the orchestration name is aligned byte-for-byte with .NET's
``WorkflowNamingHelper``)::
orchestration: dafx-{workflowName}
non-agent activity: dafx-{workflowName}-{executorId}
agent entity: dafx-{workflowName}-{executorId}
The orchestration name is the identifier the Durable Task tooling/UI surfaces, so
it matches .NET exactly. The inner activity/entity names are scoped by workflow in
Python (unlike .NET's bare ``dafx-{executorId}``) so two co-hosted workflows that
reuse an executor id cannot collide.
"""
from __future__ import annotations
import re
__all__ = [
"DURABLE_NAME_PREFIX",
"MAX_EXECUTOR_ID_LENGTH",
"SUBWORKFLOW_REQUEST_SEPARATOR",
"is_auto_generated_workflow_name",
"qualify_subworkflow_request_id",
"split_subworkflow_request_id",
"validate_executor_id",
"validate_workflow_name",
"workflow_executor_activity_name",
"workflow_name_from_orchestrator",
"workflow_orchestrator_name",
"workflow_scoped_executor_id",
]
# Shared prefix for every durable name this hosting layer registers. Matches
# .NET's ``WorkflowNamingHelper.OrchestrationFunctionPrefix`` and the existing
# ``AgentSessionId.ENTITY_NAME_PREFIX``.
DURABLE_NAME_PREFIX = "dafx-"
# Separator used to qualify a nested sub-workflow's pending HITL request when it is
# bubbled up to the top-level instance (one top-level addressing surface). A qualified id
# is a path of ``{executorId}~{ordinal}`` hops ending in the leaf's bare request id,
# e.g. ``review~0~approve~1~<requestId>``. Both hosts and the client must agree on it
# so a qualified id round-trips: the read side prepends hops; the respond side peels
# them to route the response to the owning child orchestration.
#
# ``~`` (RFC 3986 "unreserved", so URL-path-safe) is deliberately **not** ``::``:
# core emits ``auto::{index}`` request ids for functional ``@workflow`` HITL, so a
# ``::`` separator would mis-parse those leaf ids. ``~`` does not appear in core
# request ids (uuid4 or ``auto::N``); executor ids are validated to exclude it (see
# :func:`validate_executor_id`), so only the structural hops carry the separator.
SUBWORKFLOW_REQUEST_SEPARATOR = "~"
# Upper bound on an executor id's length when a workflow is hosted durably. The id is
# interpolated into durable activity/entity names (``dafx-{workflow}-{executor}``) and,
# for sub-workflow nodes, into recursively-nested child orchestration instance ids
# (``{parent}::{executor}::{n}``). Capping it keeps those derived strings within typical
# durable backend name/id limits; combined with the workflow-name cap, the worst-case
# instance id stays bounded even for deeply-nested sub-workflows.
MAX_EXECUTOR_ID_LENGTH = 128
# A workflow name is interpolated into durable orchestration/activity/entity names
# *and* into HTTP route segments (``workflow/{workflowName}/run``), so it must be
# conservative enough to be safe in every position: ASCII letters, digits, '_' or
# '-', starting with a letter, at most 63 characters. The length cap leaves room
# for the ``dafx-`` prefix and an ``-{executorId}`` suffix within typical durable
# name limits.
_WORKFLOW_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,62}$")
# Names auto-generated by ``WorkflowBuilder`` when the caller does not pass one,
# e.g. ``"WorkflowBuilder-3f2b1c0a-1234-5678-9abc-def012345678"``. They embed a
# fresh ``uuid4`` per process build, so they are not stable identities and must be
# rejected for durable hosting (see :func:`validate_workflow_name`).
_AUTO_GENERATED_NAME_RE = re.compile(
r"^WorkflowBuilder-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def workflow_orchestrator_name(workflow_name: str) -> str:
"""Return the durable orchestration name for a workflow.
Args:
workflow_name: The workflow's name. Must satisfy
:func:`validate_workflow_name`.
Returns:
``"dafx-{workflow_name}"``.
Raises:
ValueError: If ``workflow_name`` is not a valid, stable workflow name.
"""
validate_workflow_name(workflow_name)
return f"{DURABLE_NAME_PREFIX}{workflow_name}"
def workflow_name_from_orchestrator(orchestrator_name: str) -> str | None:
"""Recover the workflow name from a durable orchestration name.
The inverse of :func:`workflow_orchestrator_name`. Intended to be applied to
orchestration names (for example a durable instance's ``status.name``); it
strips the shared :data:`DURABLE_NAME_PREFIX`.
Args:
orchestrator_name: A durable orchestration name.
Returns:
The workflow name, or ``None`` if ``orchestrator_name`` does not carry the
expected prefix (so a caller can treat it as "not one of ours").
"""
if not orchestrator_name.startswith(DURABLE_NAME_PREFIX):
return None
name = orchestrator_name[len(DURABLE_NAME_PREFIX) :]
return name or None
def workflow_scoped_executor_id(workflow_name: str, executor_id: str) -> str:
"""Return the workflow-scoped identity for an executor.
Inner executors (non-agent activities and agent entities) are scoped by
workflow so two co-hosted workflows that reuse an ``executor_id`` register and
dispatch to distinct durable primitives instead of colliding on one global
name. This is the **unprefixed** identity (e.g. used as
:class:`~agent_framework_durabletask.AgentSessionId` ``name``, which the entity
layer then prefixes); see :func:`workflow_executor_activity_name` for the full
activity function name.
Args:
workflow_name: The owning workflow's name.
executor_id: The executor's id within that workflow.
Returns:
``"{workflow_name}-{executor_id}"``.
"""
return f"{workflow_name}-{executor_id}"
def workflow_executor_activity_name(workflow_name: str, executor_id: str) -> str:
"""Return the durable activity function name for a non-agent executor.
Args:
workflow_name: The owning workflow's name.
executor_id: The executor's id within that workflow.
Returns:
``"dafx-{workflow_name}-{executor_id}"``.
"""
return f"{DURABLE_NAME_PREFIX}{workflow_scoped_executor_id(workflow_name, executor_id)}"
def validate_workflow_name(workflow_name: str) -> None:
"""Validate that a workflow name is usable as a stable durable identity.
The name is **validated and rejected** rather than silently sanitized. A
workflow name is an identity baked into durable orchestration/activity/entity
names and HTTP routes, so transforming it could either (a) collapse two
distinct names into one and reintroduce the cross-workflow collision this
scheme exists to prevent, or (b) change the resolved name across versions and
break resume of in-flight instances. A loud error is safer than a silent
rename.
Args:
workflow_name: The candidate name.
Raises:
ValueError: If the name is empty, an auto-generated ``WorkflowBuilder``
name, or contains characters outside
``[A-Za-z][A-Za-z0-9_-]{0,62}``.
"""
if not workflow_name:
raise ValueError("Workflow name must be a non-empty string.")
if is_auto_generated_workflow_name(workflow_name):
raise ValueError(
f"Workflow name '{workflow_name}' is an auto-generated WorkflowBuilder name, which is "
"not stable across restarts. Pass an explicit, stable name to WorkflowBuilder(name=...) "
"before hosting the workflow durably."
)
if not _WORKFLOW_NAME_RE.match(workflow_name):
raise ValueError(
f"Workflow name '{workflow_name}' is invalid. Use 1-63 characters consisting of ASCII "
"letters, digits, '_' or '-', and starting with a letter."
)
def is_auto_generated_workflow_name(workflow_name: str) -> bool:
"""Return whether a name looks like ``WorkflowBuilder``'s auto-generated default.
``WorkflowBuilder`` names an otherwise-unnamed workflow
``f"WorkflowBuilder-{uuid4()}"``, which changes on every process build and is
therefore not a stable durable identity.
Args:
workflow_name: The candidate name.
Returns:
``True`` if the name matches the auto-generated pattern.
"""
return bool(_AUTO_GENERATED_NAME_RE.match(workflow_name))
def validate_executor_id(executor_id: str) -> None:
"""Validate that an executor id is safe to host durably.
An executor id is interpolated into durable activity/entity names and, for
sub-workflow nodes, into nested child-orchestration instance ids and the
qualified ids used to address nested human-in-the-loop requests. Two properties
must hold:
* It must not contain :data:`SUBWORKFLOW_REQUEST_SEPARATOR`. That sequence
separates the structural hops of a qualified nested-HITL request id, so an id
containing it would make a qualified id ambiguous and mis-route a response.
* It must be at most :data:`MAX_EXECUTOR_ID_LENGTH` characters, so the durable
names and (recursively nested) instance ids derived from it stay within typical
durable backend limits.
Args:
executor_id: The executor's id within a hosted workflow.
Raises:
ValueError: If the id is empty, contains the reserved separator, or is too
long.
"""
if not executor_id:
raise ValueError("Executor id must be a non-empty string.")
if SUBWORKFLOW_REQUEST_SEPARATOR in executor_id:
raise ValueError(
f"Executor id '{executor_id}' contains the reserved sub-workflow request separator "
f"'{SUBWORKFLOW_REQUEST_SEPARATOR}', which is used to address nested human-in-the-loop "
"requests. Rename the executor so its id does not contain that sequence."
)
if len(executor_id) > MAX_EXECUTOR_ID_LENGTH:
raise ValueError(
f"Executor id '{executor_id[:32]}...' is too long ({len(executor_id)} > "
f"{MAX_EXECUTOR_ID_LENGTH}). Durable activity/entity names and nested instance ids are "
"derived from it; use a shorter id."
)
def qualify_subworkflow_request_id(executor_id: str, ordinal: int, inner_request_id: str) -> str:
"""Prepend one sub-workflow hop to a (possibly already-qualified) request id.
Produces ``{executor_id}~{ordinal}~{inner_request_id}``. ``ordinal`` selects the
specific child orchestration among several a single ``WorkflowExecutor`` node may
dispatch in one superstep, so two children of the same executor stay distinctly
addressable. ``inner_request_id`` is the child's bare leaf request id or its own
already-qualified path for deeper nesting.
Args:
executor_id: The sub-workflow node's executor id (separator-free; see
:func:`validate_executor_id`).
ordinal: The child's index in the parent's ``subworkflows`` status list.
inner_request_id: The request id (bare or qualified) within the child.
Returns:
The qualified request id one level higher.
"""
sep = SUBWORKFLOW_REQUEST_SEPARATOR
return f"{executor_id}{sep}{ordinal}{sep}{inner_request_id}"
def split_subworkflow_request_id(request_id: str) -> tuple[str, int, str] | None:
"""Peel the outermost sub-workflow hop off a qualified request id.
The inverse of :func:`qualify_subworkflow_request_id` for a single level.
Returns ``(executor_id, ordinal, remainder)`` where ``remainder`` is the still
(possibly) qualified id one level deeper, or ``None`` when ``request_id`` carries
no well-formed hop -- i.e. it is a bare leaf request id that targets the current
instance directly. A leaf id may itself contain the separator (e.g. core's
``auto::N`` does not, but a custom id could); because only structural hops use the
``{executor}~{int-ordinal}~`` shape, a value whose second segment is not an integer
is treated as a bare leaf rather than a hop.
Args:
request_id: A bare or qualified request id.
Returns:
``(executor_id, ordinal, remainder)`` for a qualified id, else ``None``.
"""
sep = SUBWORKFLOW_REQUEST_SEPARATOR
if sep not in request_id:
return None
parts = request_id.split(sep, 2)
if len(parts) < 3:
return None
executor_id, ordinal_str, remainder = parts
try:
ordinal = int(ordinal_str)
except ValueError:
return None
return executor_id, ordinal, remainder
@@ -0,0 +1,137 @@
# Copyright (c) Microsoft. All rights reserved.
"""Host-agnostic plan for registering a MAF Workflow as a durable orchestration.
A MAF :class:`Workflow` is hosted by turning each graph node into a durable
primitive:
- each :class:`AgentExecutor` becomes a durable **entity**,
- each :class:`WorkflowExecutor` (a nested sub-workflow) becomes a durable
**child orchestration**, and
- each other :class:`Executor` becomes a durable **activity**,
driven by a single workflow **orchestrator**.
The *decision* of which executor maps to which primitive is identical on every
host (Azure Functions or a standalone durabletask worker); only the *mechanism*
for registering them differs (Functions trigger decorators vs.
``worker.add_*``). :func:`plan_workflow_registration` captures the shared
decision so each host applies one consistent plan with its own registration
mechanism — analogous to .NET's shared ``DurableWorkflowOptions`` feeding
host-specific trigger generation.
Sub-workflows nest: a hosted workflow may contain :class:`WorkflowExecutor`
nodes whose inner workflows must themselves be registered (their orchestrator,
agents, and activities) so the parent can drive them via
``call_sub_orchestrator``. :func:`collect_hosted_workflows` walks that tree so a
host registers every reachable workflow exactly once.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from agent_framework import AgentExecutor, Executor, Workflow, WorkflowExecutor
@dataclass
class WorkflowRegistrationPlan:
"""The durable primitives a workflow registers, independent of host.
Attributes:
agent_executors: Agent executors to register as durable entities. The
full :class:`AgentExecutor` is carried (not just its agent) so each
host can register the entity under the executor's ``id`` — the same
identity the orchestrator dispatches to — which keeps
``AgentExecutor(agent, id=...)`` working when the id differs from
``agent.name``.
activity_executors: Non-agent, non-subworkflow executors to register as
durable activities.
subworkflow_executors: :class:`WorkflowExecutor` nodes whose inner
workflows are driven as durable child orchestrations. The node itself
is *not* registered as an activity; its inner workflow is registered
separately (see :func:`collect_hosted_workflows`).
"""
agent_executors: list[AgentExecutor]
activity_executors: list[Executor]
subworkflow_executors: list[WorkflowExecutor]
def plan_workflow_registration(workflow: Workflow) -> WorkflowRegistrationPlan:
"""Classify a workflow's executors into the durable primitives to register.
Args:
workflow: The MAF :class:`Workflow` to host.
Returns:
A :class:`WorkflowRegistrationPlan` describing the agent executors
(entities), sub-workflow executors (child orchestrations), and the
remaining non-agent executors (activities).
"""
agent_executors: list[AgentExecutor] = []
activity_executors: list[Executor] = []
subworkflow_executors: list[WorkflowExecutor] = []
for executor in workflow.executors.values():
if isinstance(executor, AgentExecutor):
agent_executors.append(executor)
elif isinstance(executor, WorkflowExecutor):
subworkflow_executors.append(executor)
else:
activity_executors.append(executor)
return WorkflowRegistrationPlan(
agent_executors=agent_executors,
activity_executors=activity_executors,
subworkflow_executors=subworkflow_executors,
)
def collect_hosted_workflows(workflow: Workflow) -> Iterator[Workflow]:
"""Yield ``workflow`` and every nested sub-workflow, deduped by name.
A host registers the orchestration primitives for each yielded workflow so a
parent orchestration can invoke its sub-workflows as child orchestrations.
Workflows are deduped by :attr:`Workflow.name`, **compared case-insensitively**:
the *same* sub-workflow instance reused across the tree (or shared by two
top-level workflows) is yielded once, which is the expected fan-out pattern. Two
**different** workflow instances whose names collide (including case-only
differences) are rejected, since both would resolve to one durable orchestration
(``dafx-{name}``) -- whose name the route ownership check compares
case-insensitively -- and would silently shadow each other. The top-level
``workflow`` is yielded first.
Args:
workflow: The top-level workflow to walk.
Yields:
Each distinct workflow in the nesting tree, parent before child.
Raises:
ValueError: If two different workflow instances in the tree have colliding
(case-insensitive) names.
"""
seen: dict[str, Workflow] = {}
def _walk(current: Workflow) -> Iterator[Workflow]:
key = current.name.casefold()
existing = seen.get(key)
if existing is not None:
if existing is not current:
raise ValueError(
f"A different workflow named '{current.name}' collides with '{existing.name}'. A "
f"workflow name maps to a single durable orchestration ('dafx-{current.name}'), "
"compared case-insensitively, so names must be unique within a hosted composition. "
"Rename one, or reuse the same Workflow instance if they are meant to be the same "
"sub-workflow."
)
return
seen[key] = current
yield current
plan = plan_workflow_registration(current)
for sub in plan.subworkflow_executors:
yield from _walk(sub.workflow)
yield from _walk(workflow)
@@ -0,0 +1,147 @@
# Copyright (c) Microsoft. All rights reserved.
"""Runner context for activity execution within durable orchestrations.
This module provides the :class:`CapturingRunnerContext` class that captures
messages and events produced during executor execution within activities.
It is host-agnostic and works on any durable task host.
"""
from __future__ import annotations
import asyncio
from copy import copy
from typing import Any
from agent_framework import (
CheckpointStorage,
RunnerContext,
WorkflowCheckpoint,
WorkflowEvent,
WorkflowMessage,
)
from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType
from agent_framework._workflows._state import State
class CapturingRunnerContext(RunnerContext):
"""A RunnerContext that captures messages and events for durable activities.
This context captures all messages and events produced during execution
without requiring durable entity storage, allowing the results to be
returned to the orchestrator.
Checkpointing is not supported — the orchestrator manages state.
"""
def __init__(self) -> None:
self._messages: dict[str, list[WorkflowMessage]] = {}
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
self._workflow_id: str | None = None
self._streaming: bool = False
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
# -- Messaging ------------------------------------------------------------
async def send_message(self, message: WorkflowMessage) -> None:
self._messages.setdefault(message.source_id, [])
self._messages[message.source_id].append(message)
async def drain_messages(self) -> dict[str, list[WorkflowMessage]]:
messages = copy(self._messages)
self._messages.clear()
return messages
async def has_messages(self) -> bool:
return bool(self._messages)
# -- Events ---------------------------------------------------------------
async def add_event(self, event: WorkflowEvent) -> None:
await self._event_queue.put(event)
async def drain_events(self) -> list[WorkflowEvent]:
events: list[WorkflowEvent] = []
while True:
try:
events.append(self._event_queue.get_nowait())
except asyncio.QueueEmpty:
break
return events
async def has_events(self) -> bool:
return not self._event_queue.empty()
async def next_event(self) -> WorkflowEvent:
return await self._event_queue.get()
# -- Checkpointing (not supported) ----------------------------------------
def has_checkpointing(self) -> bool:
return False
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
pass
def clear_runtime_checkpoint_storage(self) -> None:
pass
async def create_checkpoint(
self,
workflow_name: str,
graph_signature_hash: str,
state: State,
previous_checkpoint_id: str | None,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
raise NotImplementedError("Checkpointing is not supported in activity context")
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
raise NotImplementedError("Checkpointing is not supported in activity context")
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
raise NotImplementedError("Checkpointing is not supported in activity context")
# -- Workflow configuration -----------------------------------------------
def set_workflow_id(self, workflow_id: str) -> None:
self._workflow_id = workflow_id
def reset_for_new_run(self) -> None:
self._messages.clear()
self._event_queue = asyncio.Queue()
self._pending_request_info_events.clear()
self._streaming = False
def set_streaming(self, streaming: bool) -> None:
self._streaming = streaming
def is_streaming(self) -> bool:
return self._streaming
# -- Yield-output classification -------------------------------------------
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
"""Set the classifier used by ``WorkflowContext.yield_output()``."""
self._yield_output_classifier = classifier
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
return self._yield_output_classifier(executor_id)
# -- Request Info Events --------------------------------------------------
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
self._pending_request_info_events[event.request_id] = event
await self.add_event(event)
async def send_request_info_response(self, request_id: str, response: Any) -> None:
raise NotImplementedError(
"send_request_info_response is not supported in activity context. "
"Human-in-the-loop scenarios should be handled at the orchestrator level."
)
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
return dict(self._pending_request_info_events)
@@ -0,0 +1,357 @@
# Copyright (c) Microsoft. All rights reserved.
"""Internal serialization helpers for workflow execution.
These helpers are framework-internal plumbing for moving typed objects across
durable orchestration/activity boundaries. They are **not** part of the public
API and must not be called by application code.
They wrap the core checkpoint codec (``encode_checkpoint_value`` /
``decode_checkpoint_value`` from ``agent_framework._workflows``), which uses
pickle + base64 to round-trip arbitrary Python objects (dataclasses, Pydantic
models, ``Message``, ...) while leaving JSON-native types (str, int, float,
bool, None) as-is.
Because that codec can unpickle objects, every value that crosses an external
trust boundary -- HTTP request bodies and HITL responses raised as external
events -- is sanitized by the framework with :func:`strip_pickle_markers`
*before* it can reach these helpers. Application code never has to perform that
sanitization itself: the orchestrator, the activity body, and the HTTP entry
points already do it at the boundary. See
:mod:`agent_framework._workflows._checkpoint_encoding` for the full security model.
Contents:
- ``serialize_value`` / ``deserialize_value``: internal codec aliases for encode/decode.
- ``reconstruct_to_type``: rebuilds HITL response data (which arrives without type
markers) to a known type.
- ``resolve_type``: resolves 'module:class' type keys to Python types.
- ``strip_pickle_markers``: the framework's trust-boundary defense that neutralizes
attacker-injected pickle/type markers.
"""
from __future__ import annotations
import importlib
import logging
from contextlib import suppress
from dataclasses import is_dataclass
from typing import Any, cast
from agent_framework import WorkflowEvent
from agent_framework._workflows._checkpoint_encoding import (
_PICKLE_MARKER, # pyright: ignore[reportPrivateUsage]
_TYPE_MARKER, # pyright: ignore[reportPrivateUsage]
decode_checkpoint_value,
encode_checkpoint_value,
)
from agent_framework._workflows._events import WorkflowEventType
from pydantic import BaseModel
logger = logging.getLogger(__name__)
def resolve_type(type_key: str) -> type | None:
"""Resolve a 'module:class' type key to its Python type.
Args:
type_key: Fully qualified type reference in 'module_name:class_name' format.
Returns:
The resolved type, or None if resolution fails.
"""
try:
module_name, class_name = type_key.split(":", 1)
module = importlib.import_module(module_name)
resolved = getattr(module, class_name, None)
# Only return actual classes. A non-type attribute (function, module member,
# etc.) would raise TypeError in issubclass() inside reconstruct_to_type().
return resolved if isinstance(resolved, type) else None
except Exception:
logger.debug("Could not resolve type %s", type_key)
return None
# ============================================================================
# Pickle marker sanitization (security)
# ============================================================================
def strip_pickle_markers(data: Any) -> Any:
"""Recursively strip pickle/type markers from untrusted data.
The core checkpoint encoding uses ``__pickled__`` and ``__type__`` markers to
roundtrip arbitrary Python objects via *pickle*. If an attacker crafts an
HTTP payload that contains these markers, the data would flow into
``pickle.loads()`` and enable **arbitrary code execution**.
This function walks the incoming data structure and replaces any ``dict``
that contains either marker key with ``None``, neutralizing the attack
vector while leaving all other data untouched.
The framework applies this at every external trust boundary -- HTTP request
bodies and HITL responses raised as external events -- before the value can
reach the internal codec (:func:`deserialize_value` /
``decode_checkpoint_value``). Application code does not need to call it.
"""
if isinstance(data, dict):
if _PICKLE_MARKER in data or _TYPE_MARKER in data:
logger.debug("Stripped pickle/type markers from untrusted input.")
return None
typed_dict = cast(dict[str, Any], data)
return {k: strip_pickle_markers(v) for k, v in typed_dict.items()}
if isinstance(data, list):
typed_list = cast(list[Any], data)
return [strip_pickle_markers(item) for item in typed_list]
return data
# ============================================================================
# Sub-workflow envelope markers (trust boundary)
# ============================================================================
# A WorkflowExecutor node runs its inner workflow as a durable child orchestration.
# The parent wraps the node's input in this envelope so the child orchestrator can
# tell a trusted sub-orchestration payload (serialized by the parent, post-boundary,
# via call_sub_orchestrator) apart from untrusted top-level client input.
SUBWORKFLOW_INPUT_KEY = "__subworkflow_input__"
# When a workflow runs as a sub-workflow, its orchestrator returns this envelope
# instead of a bare outputs list, so the parent can recover both the inner outputs
# *and* the inner event timeline (a child orchestration is a separate durable
# instance; its return value is the only deterministic, replay-safe channel back to
# the parent). A top-level run still returns a bare list, so the client output path
# is unchanged. See ``orchestrator._process_subworkflow_result``.
SUBWORKFLOW_RESULT_KEY = "__subworkflow_result__"
def strip_subworkflow_markers(data: Any) -> Any:
"""Remove the reserved sub-workflow envelope key from untrusted top-level input.
The orchestrator treats a top-level input dict carrying :data:`SUBWORKFLOW_INPUT_KEY`
as a *trusted* child-orchestration payload and reconstructs it with
:func:`deserialize_value` (pickle) **without** the usual
:func:`strip_pickle_markers` sanitization, because a genuine envelope is only ever
built internally (post trust boundary) by ``call_sub_orchestrator``. If untrusted
client input could carry that key, an attacker could smuggle a pickle payload
straight into ``pickle.loads`` (RCE).
Hosts therefore call this on client-supplied workflow input *before* scheduling the
orchestration, so the only way the orchestrator ever sees the envelope is from a
real internal child dispatch. Only the top-level key is removed (that is the only
position the orchestrator interprets it), leaving the rest of the caller's payload
untouched.
"""
if not isinstance(data, dict):
return data
typed = cast(dict[str, Any], data)
if SUBWORKFLOW_INPUT_KEY not in typed:
return typed
logger.debug("Stripped reserved sub-workflow envelope key from untrusted input.")
cleaned = typed.copy()
cleaned.pop(SUBWORKFLOW_INPUT_KEY, None)
return cleaned
# ============================================================================
# Serialize / Deserialize
# ============================================================================
def serialize_value(value: Any) -> Any:
"""Encode a value for JSON-compatible cross-activity communication (internal).
Framework-internal codec. Delegates to core checkpoint encoding which uses
pickle + base64 for non-JSON-native types (dataclasses, Pydantic models,
Message, etc.). Not part of the public API.
Args:
value: Any Python value (primitive, dataclass, Pydantic model, Message, etc.)
Returns:
A JSON-serializable representation with embedded type metadata for reconstruction.
"""
return encode_checkpoint_value(value)
def deserialize_value(value: Any) -> Any:
"""Decode a value previously encoded with :func:`serialize_value` (internal).
Framework-internal codec. Delegates to core checkpoint decoding which
unpickles base64-encoded values and verifies type integrity. Not part of the
public API: callers only ever hand it values that the framework produced
itself or that have already passed the :func:`strip_pickle_markers` trust
boundary, so untrusted markers can never reach ``pickle.loads()`` here.
Args:
value: The serialized data (dict with pickle markers, list, or primitive)
Returns:
Reconstructed typed object if type metadata found, otherwise original value.
"""
return decode_checkpoint_value(value)
def deserialize_workflow_output(output: Any) -> Any:
"""Reconstruct the workflow outputs produced by the shared activity.
Each value an executor yields is encoded with :func:`serialize_value` before
it reaches the orchestrator, so typed objects (dataclasses, Pydantic models,
``AgentResponse``, ...) are stored as checkpoint-marker dicts. This reverses
that encoding so callers receive the original objects.
This is the single decode path shared by every host (the in-process
:class:`DurableWorkflowClient` and the Azure Functions status endpoint) so
they never diverge in how a completed workflow's output is reconstructed.
``output`` must originate from the workflow's own orchestration result
(trusted durable storage), never from untrusted external input. Markers in
untrusted input must be neutralized with :func:`strip_pickle_markers` first.
Args:
output: The workflow's orchestration result, already JSON-decoded (a list
of yielded outputs or a single value).
Returns:
The output with every checkpoint-encoded value reconstructed; primitives
and plain JSON structures pass through unchanged.
"""
return deserialize_value(output)
# ============================================================================
# Workflow Event Serialization (streaming)
# ============================================================================
def _type_key(value_type: type[Any] | None) -> str | None:
"""Format a type as a ``'module:qualname'`` key for :func:`resolve_type`."""
if value_type is None:
return None
return f"{value_type.__module__}:{value_type.__name__}"
def serialize_workflow_event(event: WorkflowEvent[Any]) -> dict[str, Any]:
"""Serialize a :class:`WorkflowEvent` to a JSON-compatible dict.
Carries a workflow event from the durable activity, through the orchestration
custom status, to a streaming client. The data payload is encoded with
:func:`serialize_value` so typed objects survive the round trip;
:func:`deserialize_workflow_event` reverses it into a ``WorkflowEvent`` so
callers never handle checkpoint-marker dicts directly.
Args:
event: The workflow event to serialize.
Returns:
A JSON-serializable dict with the event ``type`` and the fields needed to
reconstruct it.
"""
serialized: dict[str, Any] = {"type": event.type}
if event.executor_id is not None:
serialized["executor_id"] = event.executor_id
if event.data is not None:
serialized["data"] = serialize_value(event.data)
if event.type == "request_info":
# request_type is omitted: deserialize_workflow_event rebuilds the event via
# WorkflowEvent.request_info, which derives it from the data payload.
serialized["request_id"] = event.request_id
serialized["source_executor_id"] = event.source_executor_id
serialized["response_type"] = _type_key(event.response_type)
return serialized
def deserialize_workflow_event(serialized: dict[str, Any]) -> WorkflowEvent[Any]:
"""Reconstruct a :class:`WorkflowEvent` from :func:`serialize_workflow_event` output.
``serialized`` must originate from the workflow's own orchestration custom
status (trusted durable storage); its encoded payload is decoded with
:func:`deserialize_value`. Never pass untrusted external input here.
Args:
serialized: A dict previously produced by :func:`serialize_workflow_event`,
optionally augmented with an ``iteration`` key by the orchestrator.
Returns:
The reconstructed workflow event with its data payload restored.
"""
event_type = cast(WorkflowEventType, serialized["type"])
payload = deserialize_value(serialized["data"]) if "data" in serialized else None
if event_type == "request_info":
response_key = serialized.get("response_type")
response_type = resolve_type(response_key) if response_key else None
event: WorkflowEvent[Any] = WorkflowEvent.request_info(
request_id=cast(str, serialized["request_id"]),
source_executor_id=cast(str, serialized["source_executor_id"]),
request_data=payload,
response_type=response_type or object,
)
else:
event = WorkflowEvent(event_type, data=payload, executor_id=serialized.get("executor_id"))
iteration = serialized.get("iteration")
if iteration is not None:
event.iteration = iteration
return event
# ============================================================================
# HITL Type Reconstruction
# ============================================================================
def reconstruct_to_type(value: Any, target_type: type) -> Any:
"""Reconstruct a value to a known target type.
Used for HITL responses where external data (without checkpoint type markers)
needs to be reconstructed to a specific type determined by the response_type hint.
Tries strategies in order:
1. Return as-is if already the correct type
2. deserialize_value (for data with any type markers)
3. Pydantic model_validate (for Pydantic models)
4. Dataclass constructor (for dataclasses)
Args:
value: The value to reconstruct (typically a dict from JSON)
target_type: The expected type to reconstruct to
Returns:
Reconstructed value if possible, otherwise the original value
"""
if value is None:
return None
with suppress(TypeError):
if isinstance(value, target_type):
return value
if not isinstance(value, dict):
return value
# Try decoding if data has pickle markers (from checkpoint encoding).
# NOTE: This function is general-purpose. Callers that handle untrusted
# data (e.g. HITL responses) MUST call strip_pickle_markers() before
# passing data here. See _deserialize_hitl_response in orchestrator.py.
decoded = deserialize_value(value)
if not isinstance(decoded, dict):
return decoded
# Try Pydantic model validation (for unmarked dicts, e.g., external HITL data)
if issubclass(target_type, BaseModel):
try:
return target_type.model_validate(value)
except Exception:
logger.debug("Could not validate Pydantic model %s", target_type)
return value # type: ignore[return-value]
# Try dataclass construction (for unmarked dicts, e.g., external HITL data)
if is_dataclass(target_type) and isinstance(target_type, type): # type: ignore
try:
return target_type(**value)
except Exception:
logger.debug("Could not construct dataclass %s", target_type)
return value # type: ignore[return-value]