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
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:
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
Reference in New Issue
Block a user