Python: Durable Support for Workflows (#3630)

* Add workflow support for Azure Functions

* fix compatability with latest framework changes and add integration tests

* refactor code

* remove white space

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* align help text with actual port used

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* replace instance id with a place holder

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove unused import

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove redundant typing import and fix SIM115

* fix latest breaking changes

* fix mypy issues

* clean up imports

* define source marker strings as constants

* fix json module name

* refactor _extract_message_content_from_dict

* refactor serialization

* add helper method for error response construction and remove _extract_message_content_from_dict since it is not needed

* use strict tpe checking for edges

* change how duplicate agent registrations are handled

* cancel approval_task on HITL timeout

* update docstring

* fix: align azurefunctions package with core API changes after rebase

- State.import_state/export_state are now sync (removed await)
- Add State.commit() before export_state() in activity execution
- Rename executor parameter shared_state -> state
- Rename ctx.set_shared_state/get_shared_state -> set_state/get_state (sync)
- WorkflowBuilder now takes start_executor as constructor kwarg
- Update WorkflowOutputEvent -> WorkflowEvent with type='output'
- Update RequestInfoEvent -> WorkflowEvent[Any]
- Update SharedState -> State in test imports
- Update duplicate agent name tests to match new warning behavior
- Update sample README API references

* fix sample check errors

* fix mypy issues

* fix trailing white spaces

* fix test imports

* feat: add durable workflow samples and adapt to main branch changes

- Add workflow samples 09-12 to 04-hosting/azure_functions/
- Adapt to ChatMessage -> Message rename from main
- Adapt to pickle-based checkpoint encoding from main
- Simplify _serialization.py to delegate to core encode/decode
- Fix Message -> WorkflowMessage disambiguation in _context.py
- Remove non-existent _checkpoint_summary import

* fix: update create_checkpoint signature to match superclass

* fix: correct relative link in HITL sample README

* fix: resolve import breakage after rebase (State, DurableAgentThread, get_logger)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Ahmed Muhsin
2026-02-17 16:11:33 -06:00
committed by GitHub
Unverified
parent 9a369c69c0
commit bb3d3c2efc
46 changed files with 5501 additions and 15 deletions
@@ -8,6 +8,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
from __future__ import annotations
import asyncio
import json
import logging
import re
@@ -19,7 +20,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
import azure.durable_functions as df
import azure.functions as func
from agent_framework import SupportsAgentRun
from agent_framework import AgentExecutor, SupportsAgentRun, Workflow, WorkflowEvent
from agent_framework_durabletask import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
@@ -39,9 +40,17 @@ from agent_framework_durabletask import (
RunRequest,
)
from ._context import CapturingRunnerContext
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
from ._serialization import deserialize_value, serialize_value
from ._workflow import (
SOURCE_HITL_RESPONSE,
SOURCE_ORCHESTRATOR,
execute_hitl_response_handler,
run_workflow_orchestrator,
)
logger = logging.getLogger("agent_framework.azurefunctions")
@@ -154,16 +163,19 @@ class AgentFunctionApp(DFAppBase):
enable_mcp_tool_trigger: Whether MCP tool triggers are created for agents
max_poll_retries: Maximum polling attempts when waiting for responses
poll_interval_seconds: Delay (seconds) between polling attempts
workflow: Optional Workflow instance for workflow orchestration
"""
_agent_metadata: dict[str, AgentMetadata]
enable_health_check: bool
enable_http_endpoints: bool
enable_mcp_tool_trigger: bool
workflow: Workflow | None
def __init__(
self,
agents: list[SupportsAgentRun] | None = None,
workflow: Workflow | None = None,
http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION,
enable_health_check: bool = True,
enable_http_endpoints: bool = True,
@@ -175,6 +187,7 @@ class AgentFunctionApp(DFAppBase):
"""Initialize the AgentFunctionApp.
:param agents: List of agent instances to register.
:param workflow: Optional Workflow instance to extract agents from and set up orchestration.
:param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``).
:param enable_health_check: Enable the built-in health check endpoint (default: ``True``).
:param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``).
@@ -199,6 +212,7 @@ class AgentFunctionApp(DFAppBase):
self.enable_http_endpoints = enable_http_endpoints
self.enable_mcp_tool_trigger = enable_mcp_tool_trigger
self.default_callback = default_callback
self.workflow = workflow
try:
retries = int(max_poll_retries)
@@ -212,6 +226,20 @@ class AgentFunctionApp(DFAppBase):
interval = DEFAULT_POLL_INTERVAL_SECONDS
self.poll_interval_seconds = interval if interval > 0 else DEFAULT_POLL_INTERVAL_SECONDS
# If workflow is provided, extract agents and set up orchestration
if workflow:
if agents is None:
agents = []
logger.debug("[AgentFunctionApp] Extracting agents from workflow")
for executor in workflow.executors.values():
if isinstance(executor, AgentExecutor):
agents.append(executor.agent)
else:
# Setup individual activity for each non-agent executor
self._setup_executor_activity(executor.id)
self._setup_workflow_orchestration()
if agents:
# Register all provided agents
logger.debug(f"[AgentFunctionApp] Registering {len(agents)} agent(s)")
@@ -224,6 +252,281 @@ class AgentFunctionApp(DFAppBase):
logger.debug("[AgentFunctionApp] Initialization complete")
def _setup_executor_activity(self, executor_id: str) -> None:
"""Register an activity for executing a specific non-agent executor.
Args:
executor_id: The ID of the executor to create an activity for.
"""
activity_name = f"dafx-{executor_id}"
logger.debug(f"[AgentFunctionApp] Registering activity '{activity_name}' for executor '{executor_id}'")
# Capture executor_id in closure
captured_executor_id = executor_id
@self.function_name(activity_name)
@self.activity_trigger(input_name="inputData")
def executor_activity(inputData: str) -> str:
"""Activity to execute a specific non-agent executor.
Note: We use str type annotations instead of dict to work around
Azure Functions worker type validation issues with dict[str, Any].
"""
from agent_framework._workflows import State
data = json.loads(inputData)
message_data = data["message"]
shared_state_snapshot = data.get("shared_state_snapshot", {})
source_executor_ids = data.get("source_executor_ids", [SOURCE_ORCHESTRATOR])
if not self.workflow:
raise RuntimeError("Workflow not initialized in AgentFunctionApp")
executor = self.workflow.executors.get(captured_executor_id)
if not executor:
raise ValueError(f"Unknown executor: {captured_executor_id}")
# Reconstruct message - deserialize_value restores the original typed objects
# from the encoded data (with type markers)
message = deserialize_value(message_data)
# Check if this is a HITL response message by examining source_executor_ids
is_hitl_response = any(s.startswith(SOURCE_HITL_RESPONSE) for s in source_executor_ids)
async def run() -> dict[str, Any]:
# Create runner context and shared state
runner_context = CapturingRunnerContext()
shared_state = State()
# Deserialize shared state values to reconstruct dataclasses/Pydantic models
deserialized_state = {k: deserialize_value(v) for k, v in (shared_state_snapshot or {}).items()}
original_snapshot = dict(deserialized_state)
shared_state.import_state(deserialized_state)
if is_hitl_response:
# Handle HITL response by calling the executor's @response_handler
await execute_hitl_response_handler(
executor=executor,
hitl_message=message_data,
shared_state=shared_state,
runner_context=runner_context,
)
else:
# Execute using the public execute() method
await executor.execute(
message=message,
source_executor_ids=source_executor_ids,
state=shared_state,
runner_context=runner_context,
)
# Commit pending state changes and export
shared_state.commit()
current_state = shared_state.export_state()
original_keys = set(original_snapshot.keys())
current_keys = set(current_state.keys())
# Deleted = was in original, not in current
deletes = original_keys - current_keys
# Updates = keys in current that are new or have different values
updates = {
k: v for k, v in current_state.items() if k not in original_snapshot or original_snapshot[k] != v
}
# Drain messages and events from runner context
sent_messages = await runner_context.drain_messages()
events = await runner_context.drain_events()
# Extract outputs from WorkflowEvent instances with type='output'
outputs: list[Any] = []
for event in events:
if isinstance(event, WorkflowEvent) and event.type == "output":
outputs.append(serialize_value(event.data))
# Get pending request info events for HITL
pending_request_info_events = await runner_context.get_pending_request_info_events()
# Serialize pending request info events for orchestrator
serialized_pending_requests = []
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 messages for JSON compatibility
serialized_sent_messages = []
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,
"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)
# Ensure the function is registered (prevents garbage collection)
_ = executor_activity
def _setup_workflow_orchestration(self) -> None:
"""Register the workflow orchestration and related HTTP endpoints."""
@self.orchestration_trigger(context_name="context")
def workflow_orchestrator(context: df.DurableOrchestrationContext) -> Any: # type: ignore[type-arg]
"""Generic orchestrator for running the configured workflow."""
if self.workflow is None:
raise RuntimeError("Workflow not initialized in AgentFunctionApp")
input_data = context.get_input()
# Ensure input is a string for the agent
initial_message = json.dumps(input_data) if isinstance(input_data, (dict, list)) else str(input_data)
# Create local shared state dict for cross-executor state sharing
shared_state: dict[str, Any] = {}
outputs = yield from run_workflow_orchestrator(context, self.workflow, initial_message, shared_state)
# Durable Functions runtime extracts return value from StopIteration
return outputs # noqa: B901
@self.route(route="workflow/run", methods=["POST"])
@self.durable_client_input(client_name="client")
async def start_workflow_orchestration(
req: func.HttpRequest, client: df.DurableOrchestrationClient
) -> func.HttpResponse:
"""HTTP endpoint to start the workflow."""
try:
req_body = req.get_json()
except ValueError:
return self._build_error_response("Invalid JSON body")
instance_id = await client.start_new("workflow_orchestrator", client_input=req_body)
base_url = self._build_base_url(req.url)
status_url = f"{base_url}/api/workflow/status/{instance_id}"
return func.HttpResponse(
json.dumps({
"instanceId": instance_id,
"statusQueryGetUri": status_url,
"respondUri": f"{base_url}/api/workflow/respond/{instance_id}/{{requestId}}",
"message": "Workflow started",
}),
status_code=202,
mimetype="application/json",
)
@self.route(route="workflow/status/{instanceId}", methods=["GET"])
@self.durable_client_input(client_name="client")
async def get_workflow_status(
req: func.HttpRequest, client: df.DurableOrchestrationClient
) -> func.HttpResponse:
"""HTTP endpoint to get workflow status."""
instance_id = req.route_params.get("instanceId")
status = await client.get_status(instance_id)
if not status:
return self._build_error_response("Instance not found", status_code=404)
response = {
"instanceId": status.instance_id,
"runtimeStatus": status.runtime_status.name if status.runtime_status else None,
"customStatus": status.custom_status,
"output": status.output,
"error": status.output if status.runtime_status == df.OrchestrationRuntimeStatus.Failed else None,
"createdTime": status.created_time.isoformat() if status.created_time else None,
"lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None,
}
# Add pending HITL requests info if available
custom_status = status.custom_status or {}
if isinstance(custom_status, dict) and custom_status.get("pending_requests"):
base_url = self._build_base_url(req.url)
pending_requests = []
for req_id, req_data in custom_status["pending_requests"].items():
pending_requests.append({
"requestId": req_id,
"sourceExecutor": req_data.get("source_executor_id"),
"requestData": req_data.get("data"),
"requestType": req_data.get("request_type"),
"responseType": req_data.get("response_type"),
"respondUrl": f"{base_url}/api/workflow/respond/{instance_id}/{req_id}",
})
response["pendingHumanInputRequests"] = pending_requests
return func.HttpResponse(
json.dumps(response, default=str),
status_code=200,
mimetype="application/json",
)
@self.route(route="workflow/respond/{instanceId}/{requestId}", methods=["POST"])
@self.durable_client_input(client_name="client")
async def send_hitl_response(req: func.HttpRequest, client: df.DurableOrchestrationClient) -> func.HttpResponse:
"""HTTP endpoint to send a response to a pending HITL request.
The requestId in the URL corresponds to the request_id from the RequestInfoEvent.
The request body should contain the response data matching the expected response_type.
"""
instance_id = req.route_params.get("instanceId")
request_id = req.route_params.get("requestId")
if not instance_id or not request_id:
return self._build_error_response("Instance ID and Request ID are required.")
try:
response_data = req.get_json()
except ValueError:
return self._build_error_response("Request body must be valid JSON.")
# Send the response as an external event
# The request_id is used as the event name for correlation
await client.raise_event(
instance_id=instance_id,
event_name=request_id,
event_data=response_data,
)
return func.HttpResponse(
json.dumps({
"message": "Response delivered successfully",
"instanceId": instance_id,
"requestId": request_id,
}),
status_code=200,
mimetype="application/json",
)
def _build_status_url(self, request_url: str, instance_id: str) -> str:
"""Build the status URL for a workflow instance."""
base_url = self._build_base_url(request_url)
return f"{base_url}/api/workflow/status/{instance_id}"
def _build_base_url(self, request_url: str) -> str:
"""Extract the base URL from a request URL."""
base_url, _, _ = request_url.partition("/api/")
if not base_url:
base_url = request_url.rstrip("/")
return base_url
@property
def agents(self) -> dict[str, SupportsAgentRun]:
"""Returns dict of agent names to agent instances.
@@ -252,8 +555,7 @@ class AgentFunctionApp(DFAppBase):
The app level enable_mcp_tool_trigger setting will override this setting.
Raises:
ValueError: If the agent doesn't have a 'name' attribute or if an agent
with the same name is already registered
ValueError: If the agent doesn't have a 'name' attribute.
"""
# Get agent name from the agent's name attribute
name = getattr(agent, "name", None)
@@ -261,7 +563,8 @@ class AgentFunctionApp(DFAppBase):
raise ValueError("Agent does not have a 'name' attribute. All agents must have a 'name' attribute.")
if name in self._agent_metadata:
raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.")
logger.warning("[AgentFunctionApp] Agent '%s' is already registered, skipping duplicate.", name)
return
effective_enable_http_endpoint = (
self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint)
@@ -911,6 +1214,15 @@ class AgentFunctionApp(DFAppBase):
body_json = payload if isinstance(payload, str) else json.dumps(payload)
return func.HttpResponse(body_json, status_code=status_code, mimetype=MIMETYPE_APPLICATION_JSON)
@staticmethod
def _build_error_response(message: str, status_code: int = 400) -> func.HttpResponse:
"""Return a JSON error response with the given message and status code."""
return func.HttpResponse(
json.dumps({"error": message}),
status_code=status_code,
mimetype=MIMETYPE_APPLICATION_JSON,
)
def _convert_payload_to_text(self, payload: dict[str, Any]) -> str:
"""Convert a structured payload into a human-readable text response."""
for key in ("response", "error", "message"):
@@ -0,0 +1,171 @@
# Copyright (c) Microsoft. All rights reserved.
"""Runner context for Azure Functions activity execution.
This module provides the CapturingRunnerContext class that captures messages
and events produced during executor execution within Azure Functions activities.
"""
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 import State
class CapturingRunnerContext(RunnerContext):
"""A RunnerContext implementation that captures messages and events for Azure Functions activities.
This context is designed for executing standard Executors within Azure Functions activities.
It captures all messages and events produced during execution without requiring durable
entity storage, allowing the results to be returned to the orchestrator.
Unlike InProcRunnerContext, this implementation does NOT support checkpointing
(always returns False for has_checkpointing). The orchestrator manages state
coordination; this context just captures execution output.
"""
def __init__(self) -> None:
"""Initialize the capturing runner context."""
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
# region Messaging
async def send_message(self, message: WorkflowMessage) -> None:
"""Capture a message sent by an executor."""
self._messages.setdefault(message.source_id, [])
self._messages[message.source_id].append(message)
async def drain_messages(self) -> dict[str, list[WorkflowMessage]]:
"""Drain and return all captured messages."""
messages = copy(self._messages)
self._messages.clear()
return messages
async def has_messages(self) -> bool:
"""Check if there are any captured messages."""
return bool(self._messages)
# endregion Messaging
# region Events
async def add_event(self, event: WorkflowEvent) -> None:
"""Capture an event produced during execution."""
await self._event_queue.put(event)
async def drain_events(self) -> list[WorkflowEvent]:
"""Drain all currently queued events without blocking."""
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:
"""Check if there are any queued events."""
return not self._event_queue.empty()
async def next_event(self) -> WorkflowEvent:
"""Wait for and return the next event."""
return await self._event_queue.get()
# endregion Events
# region Checkpointing (not supported in activity context)
def has_checkpointing(self) -> bool:
"""Checkpointing is not supported in activity context."""
return False
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
"""No-op: checkpointing not supported in activity context."""
pass
def clear_runtime_checkpoint_storage(self) -> None:
"""No-op: checkpointing not supported in activity context."""
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:
"""Checkpointing not supported in activity context."""
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Checkpointing not supported in activity context."""
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
"""Checkpointing not supported in activity context."""
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
# endregion Checkpointing
# region Workflow Configuration
def set_workflow_id(self, workflow_id: str) -> None:
"""Set the workflow ID."""
self._workflow_id = workflow_id
def reset_for_new_run(self) -> None:
"""Reset the context for a new run."""
self._messages.clear()
self._event_queue = asyncio.Queue()
self._pending_request_info_events.clear()
self._streaming = False
def set_streaming(self, streaming: bool) -> None:
"""Set streaming mode (not used in activity context)."""
self._streaming = streaming
def is_streaming(self) -> bool:
"""Check if streaming mode is enabled (always False in activity context)."""
return self._streaming
# endregion Workflow Configuration
# region Request Info Events
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
"""Add a request_info WorkflowEvent and track it for correlation."""
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:
"""Send a response correlated to a pending request.
Note: This is not supported in activity context since human-in-the-loop
scenarios require orchestrator-level coordination.
"""
raise NotImplementedError(
"send_request_info_response is not supported in Azure Functions 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]]:
"""Get the mapping of request IDs to their corresponding request_info events."""
return dict(self._pending_request_info_events)
# endregion Request Info Events
@@ -0,0 +1,139 @@
# Copyright (c) Microsoft. All rights reserved.
"""Serialization utilities for workflow execution.
This module provides thin wrappers around the core checkpoint encoding system
(encode_checkpoint_value / decode_checkpoint_value) from agent_framework._workflows.
The core checkpoint encoding uses pickle + base64 for type-safe roundtripping of
arbitrary Python objects (dataclasses, Pydantic models, Message, etc.) while
keeping JSON-native types (str, int, float, bool, None) as-is.
This module adds:
- serialize_value / deserialize_value: convenience aliases for encode/decode
- reconstruct_to_type: for HITL responses where external data (without type markers)
needs to be reconstructed to a known type
- _resolve_type: resolves 'module:class' type keys to Python types
"""
from __future__ import annotations
import importlib
import logging
from dataclasses import is_dataclass
from typing import Any
from agent_framework._workflows import decode_checkpoint_value, encode_checkpoint_value
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)
return getattr(module, class_name, None)
except Exception:
logger.debug("Could not resolve type %s", type_key)
return None
# ============================================================================
# Serialize / Deserialize
# ============================================================================
def serialize_value(value: Any) -> Any:
"""Serialize a value for JSON-compatible cross-activity communication.
Delegates to core checkpoint encoding which uses pickle + base64 for
non-JSON-native types (dataclasses, Pydantic models, Message, etc.).
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:
"""Deserialize a value previously serialized with serialize_value().
Delegates to core checkpoint decoding which unpickles base64-encoded values
and verifies type integrity.
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)
# ============================================================================
# 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
try:
if isinstance(value, target_type):
return value
except TypeError:
pass
if not isinstance(value, dict):
return value
# Try decoding if data has pickle markers (from checkpoint encoding)
decoded = deserialize_value(value)
if not isinstance(decoded, dict):
return decoded
# Try Pydantic model validation (for unmarked dicts, e.g., external HITL data)
if hasattr(target_type, "model_validate"):
try:
return target_type.model_validate(value)
except Exception:
logger.debug("Could not validate Pydantic model %s", target_type)
# Try dataclass construction (for unmarked dicts, e.g., external HITL data)
if is_dataclass(target_type) and isinstance(target_type, type):
try:
return target_type(**value)
except Exception:
logger.debug("Could not construct dataclass %s", target_type)
return value
@@ -0,0 +1,978 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow Execution for Durable Functions.
This module provides the workflow orchestration engine that executes MAF Workflows
using Azure Durable Functions. It reuses MAF's edge group routing logic while
adapting execution to the DF generator-based model (yield instead of await).
Key components:
- run_workflow_orchestrator: Main orchestration function for workflow execution
- route_message_through_edge_groups: Routing helper using MAF edge group APIs
- build_agent_executor_response: Helper to construct AgentExecutorResponse
HITL (Human-in-the-Loop) Support:
- Detects pending RequestInfoEvents from executor activities
- Uses wait_for_external_event to pause for human input
- Routes responses back to executor's @response_handler methods
"""
from __future__ import annotations
import json
import logging
from collections import defaultdict
from collections.abc import Generator
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from typing import Any
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Message,
Workflow,
)
from agent_framework._workflows._edge import (
Edge,
EdgeGroup,
FanInEdgeGroup,
FanOutEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
)
from agent_framework_durabletask import AgentSessionId, DurableAgentSession, DurableAIAgent
from azure.durable_functions import DurableOrchestrationContext
from ._context import CapturingRunnerContext
from ._orchestration import AzureFunctionsAgentExecutor
from ._serialization import _resolve_type, deserialize_value, reconstruct_to_type, serialize_value
logger = logging.getLogger(__name__)
# ============================================================================
# Source Marker Constants
# ============================================================================
# These markers identify the origin of messages in the workflow orchestration.
# They are used to track message provenance and handle special cases like HITL.
# Marker indicating the message originated from the workflow start (initial user input)
SOURCE_WORKFLOW_START = "__workflow_start__"
# Marker indicating the message originated from the orchestrator itself
# (used as default when executor is called directly by orchestrator, not via another executor)
SOURCE_ORCHESTRATOR = "__orchestrator__"
# Marker indicating the message is a human-in-the-loop response.
# Used as a source ID prefix. To detect HITL responses, check if any source_executor_id
# starts with this prefix.
SOURCE_HITL_RESPONSE = "__hitl_response__"
# ============================================================================
# Task Types and Data Structures
# ============================================================================
class TaskType(Enum):
"""Type of executor task."""
AGENT = "agent"
ACTIVITY = "activity"
@dataclass
class TaskMetadata:
"""Metadata for a pending task."""
executor_id: str
message: Any
source_executor_id: str
task_type: TaskType
remaining_messages: list[tuple[str, Any, str]] | None = None # For agents with multiple messages
@dataclass
class ExecutorResult:
"""Result from executing an agent or activity."""
executor_id: str
output_message: AgentExecutorResponse | None
activity_result: dict[str, Any] | None
task_type: TaskType
@dataclass
class PendingHITLRequest:
"""Tracks a pending Human-in-the-Loop request in the orchestrator.
Attributes:
request_id: Unique identifier for correlation with external events
source_executor_id: The executor that called ctx.request_info()
request_data: The serialized request payload
request_type: Fully qualified type name of the request data
response_type: Fully qualified type name of expected response
"""
request_id: str
source_executor_id: str
request_data: Any
request_type: str | None
response_type: str | None
# Default timeout for HITL requests (72 hours)
DEFAULT_HITL_TIMEOUT_HOURS = 72.0
# ============================================================================
# Routing Functions
# ============================================================================
def _evaluate_edge_condition_sync(edge: Edge, message: Any) -> bool:
"""Evaluate an edge's condition synchronously.
This is needed because Durable Functions orchestrators use generators,
not async/await, so we cannot call async methods like edge.should_route().
Args:
edge: The Edge with an optional _condition callable
message: The message to evaluate against the condition
Returns:
True if the edge should be traversed, False otherwise
"""
# Access the internal condition directly since should_route is async
condition = edge._condition
if condition is None:
return True
result = condition(message)
# If the condition is async, we cannot await it in a generator context
# Log a warning and assume True (or False for safety)
if hasattr(result, "__await__"):
import warnings
warnings.warn(
f"Edge condition for {edge.source_id}->{edge.target_id} is async, "
"which is not supported in Durable Functions orchestrators. "
"The edge will be traversed unconditionally.",
RuntimeWarning,
stacklevel=2,
)
return True
return bool(result)
def route_message_through_edge_groups(
edge_groups: list[EdgeGroup],
source_id: str,
message: Any,
) -> list[str]:
"""Route a message through edge groups to find target executor IDs.
Delegates to MAF's edge group routing logic instead of manual inspection.
Args:
edge_groups: List of EdgeGroup instances from the workflow
source_id: The ID of the source executor
message: The message to route
Returns:
List of target executor IDs that should receive the message
"""
targets: list[str] = []
for group in edge_groups:
if source_id not in group.source_executor_ids:
continue
# SwitchCaseEdgeGroup and FanOutEdgeGroup use selection_func
if isinstance(group, (SwitchCaseEdgeGroup, FanOutEdgeGroup)):
if group.selection_func is not None:
selected = group.selection_func(message, group.target_executor_ids)
targets.extend(selected)
else:
# No selection func means broadcast to all targets
targets.extend(group.target_executor_ids)
elif isinstance(group, SingleEdgeGroup):
# SingleEdgeGroup has exactly one edge
edge = group.edges[0]
if _evaluate_edge_condition_sync(edge, message):
targets.append(edge.target_id)
elif isinstance(group, FanInEdgeGroup):
# FanIn is handled separately in the orchestrator loop
# since it requires aggregation
pass
else:
# Generic EdgeGroup: check each edge's condition
for edge in group.edges:
if edge.source_id == source_id and _evaluate_edge_condition_sync(edge, message):
targets.append(edge.target_id)
return targets
def build_agent_executor_response(
executor_id: str,
response_text: str | None,
structured_response: dict[str, Any] | None,
previous_message: Any,
) -> AgentExecutorResponse:
"""Build an AgentExecutorResponse from entity response data.
Shared helper to construct the response object consistently.
Args:
executor_id: The ID of the executor that produced the response
response_text: Plain text response from the agent (if any)
structured_response: Structured JSON response (if any)
previous_message: The input message that triggered this response
Returns:
AgentExecutorResponse with reconstructed conversation
"""
final_text = response_text
if structured_response:
final_text = json.dumps(structured_response)
assistant_message = Message(role="assistant", text=final_text)
agent_response = AgentResponse(
messages=[assistant_message],
)
# Build conversation history
full_conversation: list[Message] = []
if isinstance(previous_message, AgentExecutorResponse) and previous_message.full_conversation:
full_conversation.extend(previous_message.full_conversation)
elif isinstance(previous_message, str):
full_conversation.append(Message(role="user", text=previous_message))
full_conversation.append(assistant_message)
return AgentExecutorResponse(
executor_id=executor_id,
agent_response=agent_response,
full_conversation=full_conversation,
)
# ============================================================================
# Task Preparation Helpers
# ============================================================================
def _prepare_agent_task(
context: DurableOrchestrationContext,
executor_id: str,
message: Any,
) -> Any:
"""Prepare an agent task for execution.
Args:
context: The Durable Functions orchestration context
executor_id: The agent executor ID (agent name)
message: The input message for the agent
Returns:
A task that can be yielded to execute the agent
"""
message_content = _extract_message_content(message)
session_id = AgentSessionId(name=executor_id, key=context.instance_id)
session = DurableAgentSession(durable_session_id=session_id)
az_executor = AzureFunctionsAgentExecutor(context)
agent = DurableAIAgent(az_executor, executor_id)
return agent.run(message_content, session=session)
def _prepare_activity_task(
context: DurableOrchestrationContext,
executor_id: str,
message: Any,
source_executor_id: str,
shared_state_snapshot: dict[str, Any] | None,
) -> Any:
"""Prepare an activity task for execution.
Args:
context: The Durable Functions orchestration context
executor_id: The activity executor ID
message: The input message for the activity
source_executor_id: The ID of the executor that sent the message
shared_state_snapshot: Current shared state snapshot
Returns:
A task that can be yielded to execute the activity
"""
activity_input = {
"executor_id": executor_id,
"message": serialize_value(message),
"shared_state_snapshot": shared_state_snapshot,
"source_executor_ids": [source_executor_id],
}
activity_input_json = json.dumps(activity_input)
# Use the prefixed activity name that matches the registered function
activity_name = f"dafx-{executor_id}"
return context.call_activity(activity_name, activity_input_json)
# ============================================================================
# Result Processing Helpers
# ============================================================================
def _process_agent_response(
agent_response: AgentResponse,
executor_id: str,
message: Any,
) -> ExecutorResult:
"""Process an agent response into an ExecutorResult.
Args:
agent_response: The response from the agent
executor_id: The agent executor ID
message: The original input message
Returns:
ExecutorResult containing the processed response
"""
response_text = agent_response.text if agent_response else None
structured_response = None
if agent_response and agent_response.value is not None:
if hasattr(agent_response.value, "model_dump"):
structured_response = agent_response.value.model_dump()
elif isinstance(agent_response.value, dict):
structured_response = agent_response.value
output_message = build_agent_executor_response(
executor_id=executor_id,
response_text=response_text,
structured_response=structured_response,
previous_message=message,
)
return ExecutorResult(
executor_id=executor_id,
output_message=output_message,
activity_result=None,
task_type=TaskType.AGENT,
)
def _process_activity_result(
result_json: str | None,
executor_id: str,
shared_state: dict[str, Any] | None,
workflow_outputs: list[Any],
) -> ExecutorResult:
"""Process an activity result and apply shared state updates.
Args:
result_json: The JSON result from the activity
executor_id: The activity executor ID
shared_state: The shared state dict to update (mutated in place)
workflow_outputs: List to append outputs to (mutated in place)
Returns:
ExecutorResult containing the processed result
"""
result = json.loads(result_json) if result_json else None
# Apply shared state updates
if shared_state is not None and result:
if result.get("shared_state_updates"):
updates = result["shared_state_updates"]
logger.debug("[workflow] Applying SharedState updates from %s: %s", executor_id, updates)
shared_state.update(updates)
if result.get("shared_state_deletes"):
deletes = result["shared_state_deletes"]
logger.debug("[workflow] Applying SharedState deletes from %s: %s", executor_id, deletes)
for key in deletes:
shared_state.pop(key, None)
# Collect outputs
if result and result.get("outputs"):
workflow_outputs.extend(result["outputs"])
return ExecutorResult(
executor_id=executor_id,
output_message=None,
activity_result=result,
task_type=TaskType.ACTIVITY,
)
# ============================================================================
# Routing Helpers
# ============================================================================
def _route_result_messages(
result: ExecutorResult,
workflow: Workflow,
next_pending_messages: dict[str, list[tuple[Any, str]]],
fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]],
) -> None:
"""Route messages from an executor result to their targets.
Args:
result: The executor result containing messages to route
workflow: The workflow definition
next_pending_messages: Dict to accumulate next iteration's messages (mutated)
fan_in_pending: Dict tracking fan-in state (mutated)
"""
executor_id = result.executor_id
messages_to_route: list[tuple[Any, str | None]] = []
# Collect messages from agent response
if result.output_message:
messages_to_route.append((result.output_message, None))
# Collect sent_messages from activity results
if result.activity_result and result.activity_result.get("sent_messages"):
for msg_data in result.activity_result["sent_messages"]:
sent_msg = msg_data.get("message")
target_id = msg_data.get("target_id")
if sent_msg:
sent_msg = deserialize_value(sent_msg)
messages_to_route.append((sent_msg, target_id))
# Route each message
for msg_to_route, explicit_target in messages_to_route:
logger.debug("Routing output from %s", executor_id)
# If explicit target specified, route directly
if explicit_target:
if explicit_target not in next_pending_messages:
next_pending_messages[explicit_target] = []
next_pending_messages[explicit_target].append((msg_to_route, executor_id))
logger.debug("Routed message from %s to explicit target %s", executor_id, explicit_target)
continue
# Check for FanInEdgeGroup sources
for group in workflow.edge_groups:
if isinstance(group, FanInEdgeGroup) and executor_id in group.source_executor_ids:
fan_in_pending[group.id][executor_id].append((msg_to_route, executor_id))
logger.debug("Accumulated message for FanIn group %s from %s", group.id, executor_id)
# Use MAF's edge group routing for other edge types
targets = route_message_through_edge_groups(workflow.edge_groups, executor_id, msg_to_route)
for target_id in targets:
logger.debug("Routing to %s", target_id)
if target_id not in next_pending_messages:
next_pending_messages[target_id] = []
next_pending_messages[target_id].append((msg_to_route, executor_id))
def _check_fan_in_ready(
workflow: Workflow,
fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]],
next_pending_messages: dict[str, list[tuple[Any, str]]],
) -> None:
"""Check if any FanInEdgeGroups are ready and deliver their messages.
Args:
workflow: The workflow definition
fan_in_pending: Dict tracking fan-in state (mutated - cleared when delivered)
next_pending_messages: Dict to add aggregated messages to (mutated)
"""
for group in workflow.edge_groups:
if not isinstance(group, FanInEdgeGroup):
continue
pending_sources = fan_in_pending.get(group.id, {})
# Check if all sources have contributed at least one message
if not all(src in pending_sources and pending_sources[src] for src in group.source_executor_ids):
continue
# Aggregate all messages into a single list
aggregated: list[Any] = []
aggregated_sources: list[str] = []
for src in group.source_executor_ids:
for msg, msg_source in pending_sources[src]:
aggregated.append(msg)
aggregated_sources.append(msg_source)
target_id = group.target_executor_ids[0]
logger.debug("FanIn group %s ready, delivering %d messages to %s", group.id, len(aggregated), target_id)
if target_id not in next_pending_messages:
next_pending_messages[target_id] = []
first_source = aggregated_sources[0] if aggregated_sources else "__fan_in__"
next_pending_messages[target_id].append((aggregated, first_source))
# Clear the pending sources for this group
fan_in_pending[group.id] = defaultdict(list)
# ============================================================================
# HITL (Human-in-the-Loop) Helpers
# ============================================================================
def _collect_hitl_requests(
result: ExecutorResult,
pending_hitl_requests: dict[str, PendingHITLRequest],
) -> None:
"""Collect pending HITL requests from an activity result.
Args:
result: The executor result that may contain pending request info events
pending_hitl_requests: Dict to accumulate pending requests (mutated)
"""
if result.activity_result and result.activity_result.get("pending_request_info_events"):
for req_data in result.activity_result["pending_request_info_events"]:
request_id = req_data.get("request_id")
if request_id:
pending_hitl_requests[request_id] = PendingHITLRequest(
request_id=request_id,
source_executor_id=req_data.get("source_executor_id", result.executor_id),
request_data=req_data.get("data"),
request_type=req_data.get("request_type"),
response_type=req_data.get("response_type"),
)
logger.debug(
"Collected HITL request %s from executor %s",
request_id,
result.executor_id,
)
def _route_hitl_response(
hitl_request: PendingHITLRequest,
raw_response: Any,
pending_messages: dict[str, list[tuple[Any, str]]],
) -> None:
"""Route a HITL response back to the source executor's @response_handler.
The response is packaged as a special HITL response message that the executor
activity can recognize and route to the appropriate @response_handler method.
Args:
hitl_request: The original HITL request
raw_response: The raw response data from the external event
pending_messages: Dict to add the response message to (mutated)
"""
# Create a message structure that the executor can recognize
# This mimics what the InProcRunnerContext does for request_info responses
# Note: HITL origin is identified via source_executor_ids (starting with SOURCE_HITL_RESPONSE)
response_message = {
"request_id": hitl_request.request_id,
"original_request": hitl_request.request_data,
"response": raw_response,
"response_type": hitl_request.response_type,
}
target_id = hitl_request.source_executor_id
if target_id not in pending_messages:
pending_messages[target_id] = []
# Use a special source ID to indicate this is a HITL response
source_id = f"{SOURCE_HITL_RESPONSE}_{hitl_request.request_id}"
pending_messages[target_id].append((response_message, source_id))
logger.debug(
"Routed HITL response for request %s to executor %s",
hitl_request.request_id,
target_id,
)
# ============================================================================
# Main Orchestrator
# ============================================================================
def run_workflow_orchestrator(
context: DurableOrchestrationContext,
workflow: Workflow,
initial_message: Any,
shared_state: dict[str, Any] | None = None,
hitl_timeout_hours: float = DEFAULT_HITL_TIMEOUT_HOURS,
) -> Generator[Any, Any, list[Any]]:
"""Traverse and execute the workflow graph using Durable Functions.
This orchestrator reuses MAF's edge group routing logic while adapting
execution to the DF generator-based model (yield instead of await).
Supports:
- SingleEdgeGroup: Direct 1:1 routing with optional condition
- SwitchCaseEdgeGroup: First matching condition wins
- FanOutEdgeGroup: Broadcast to multiple targets - **executed in parallel**
- FanInEdgeGroup: Aggregates messages from multiple sources before delivery
- SharedState: Local shared state accessible to all executors
- HITL: Human-in-the-loop via request_info / @response_handler pattern
Execution model:
- All pending executors (agents AND activities) run in parallel via single task_all()
- Multiple messages to the SAME agent are processed sequentially for conversation coherence
- SharedState updates are applied in order after parallel tasks complete
- HITL requests pause the orchestration until external events are received
Args:
context: The Durable Functions orchestration context
workflow: The MAF Workflow instance to execute
initial_message: The initial message to send to the start executor
shared_state: Optional dict for cross-executor state sharing (local to orchestration)
hitl_timeout_hours: Timeout in hours for HITL requests (default: 72 hours)
Returns:
List of workflow outputs collected from executor activities
"""
pending_messages: dict[str, list[tuple[Any, str]]] = {
workflow.start_executor_id: [(initial_message, SOURCE_WORKFLOW_START)]
}
workflow_outputs: list[Any] = []
iteration = 0
# Track pending sources for FanInEdgeGroups using defaultdict for cleaner access
fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]] = {
group.id: defaultdict(list) for group in workflow.edge_groups if isinstance(group, FanInEdgeGroup)
}
# Track pending HITL requests
pending_hitl_requests: dict[str, PendingHITLRequest] = {}
while pending_messages and iteration < workflow.max_iterations:
logger.debug("Orchestrator iteration %d", iteration)
next_pending_messages: dict[str, list[tuple[Any, str]]] = {}
# Phase 1: Prepare all tasks (agents and activities unified)
all_tasks, task_metadata_list, remaining_agent_messages = _prepare_all_tasks(
context, workflow, pending_messages, shared_state
)
# Phase 2: Execute all tasks in parallel (single task_all for true parallelism)
all_results: list[ExecutorResult] = []
if all_tasks:
logger.debug("Executing %d tasks in parallel (agents + activities)", len(all_tasks))
raw_results = yield context.task_all(all_tasks)
logger.debug("All %d tasks completed", len(all_tasks))
# Process results based on task type
for idx, raw_result in enumerate(raw_results):
metadata = task_metadata_list[idx]
if metadata.task_type == TaskType.AGENT:
result = _process_agent_response(raw_result, metadata.executor_id, metadata.message)
else:
result = _process_activity_result(raw_result, metadata.executor_id, shared_state, workflow_outputs)
all_results.append(result)
# Phase 3: Process sequential agent messages (for same-agent conversation coherence)
for executor_id, message, _source_executor_id in remaining_agent_messages:
logger.debug("Processing sequential message for agent: %s", executor_id)
task = _prepare_agent_task(context, executor_id, message)
agent_response: AgentResponse = yield task
logger.debug("Agent %s sequential response completed", executor_id)
result = _process_agent_response(agent_response, executor_id, message)
all_results.append(result)
# Phase 4: Collect pending HITL requests from activity results
for result in all_results:
_collect_hitl_requests(result, pending_hitl_requests)
# Phase 5: Route all results to next iteration
for result in all_results:
_route_result_messages(result, workflow, next_pending_messages, fan_in_pending)
# Phase 6: Check if any FanInEdgeGroups are ready to deliver
_check_fan_in_ready(workflow, fan_in_pending, next_pending_messages)
pending_messages = next_pending_messages
# Phase 7: Handle HITL - if no pending work but HITL requests exist, wait for responses
if not pending_messages and pending_hitl_requests:
logger.debug("Workflow paused for HITL - %d pending requests", len(pending_hitl_requests))
# Update custom status to expose pending requests
context.set_custom_status({
"state": "waiting_for_human_input",
"pending_requests": {
req_id: {
"request_id": req.request_id,
"source_executor_id": req.source_executor_id,
"data": req.request_data,
"request_type": req.request_type,
"response_type": req.response_type,
}
for req_id, req in pending_hitl_requests.items()
},
})
# Wait for external events for each pending request
# Process responses one at a time to maintain ordering
for request_id, hitl_request in list(pending_hitl_requests.items()):
logger.debug("Waiting for HITL response for request: %s", request_id)
# Create tasks for approval and timeout
approval_task = context.wait_for_external_event(request_id)
timeout_task = context.create_timer(context.current_utc_datetime + timedelta(hours=hitl_timeout_hours))
winner = yield context.task_any([approval_task, timeout_task])
if winner == approval_task:
# Cancel the timeout
timeout_task.cancel()
# Get the response
raw_response = approval_task.result
logger.debug(
"Received HITL response for request %s. Type: %s, Value: %s",
request_id,
type(raw_response).__name__,
raw_response,
)
# Durable Functions may return a JSON string; parse it if so
if isinstance(raw_response, str):
try:
raw_response = json.loads(raw_response)
logger.debug("Parsed JSON string response to: %s", type(raw_response).__name__)
except (json.JSONDecodeError, TypeError):
logger.debug("Response is not JSON, keeping as string")
# Remove from pending
del pending_hitl_requests[request_id]
# Route the response back to the source executor's @response_handler
_route_hitl_response(
hitl_request,
raw_response,
pending_messages,
)
else:
# Timeout occurred — cancel the dangling external event listener
approval_task.cancel()
logger.warning("HITL request %s timed out after %s hours", request_id, hitl_timeout_hours)
raise TimeoutError(
f"Human-in-the-loop request '{request_id}' timed out after {hitl_timeout_hours} hours."
)
# Clear custom status after HITL is resolved
context.set_custom_status({"state": "running"})
iteration += 1
# Durable Functions runtime extracts return value from StopIteration
return workflow_outputs # noqa: B901
def _prepare_all_tasks(
context: DurableOrchestrationContext,
workflow: Workflow,
pending_messages: dict[str, list[tuple[Any, str]]],
shared_state: dict[str, Any] | None,
) -> tuple[list[Any], list[TaskMetadata], list[tuple[str, Any, str]]]:
"""Prepare all pending tasks for parallel execution.
Groups agent messages by executor ID so that only the first message per agent
runs in the parallel batch. Additional messages to the same agent are returned
for sequential processing.
Args:
context: The Durable Functions orchestration context
workflow: The workflow definition
pending_messages: Messages pending for each executor
shared_state: Current shared state snapshot
Returns:
Tuple of (tasks, metadata, remaining_agent_messages):
- tasks: List of tasks ready for task_all()
- metadata: TaskMetadata for each task (same order as tasks)
- remaining_agent_messages: Agent messages requiring sequential processing
"""
all_tasks: list[Any] = []
task_metadata_list: list[TaskMetadata] = []
remaining_agent_messages: list[tuple[str, Any, str]] = []
# Group agent messages by executor_id for sequential handling of same-agent messages
agent_messages_by_executor: dict[str, list[tuple[str, Any, str]]] = defaultdict(list)
# Categorize all pending messages
for executor_id, messages_with_sources in pending_messages.items():
executor = workflow.executors[executor_id]
is_agent = isinstance(executor, AgentExecutor)
for message, source_executor_id in messages_with_sources:
if is_agent:
agent_messages_by_executor[executor_id].append((executor_id, message, source_executor_id))
else:
# Activity tasks can all run in parallel
logger.debug("Preparing activity task: %s", executor_id)
task = _prepare_activity_task(context, executor_id, message, source_executor_id, shared_state)
all_tasks.append(task)
task_metadata_list.append(
TaskMetadata(
executor_id=executor_id,
message=message,
source_executor_id=source_executor_id,
task_type=TaskType.ACTIVITY,
)
)
# Process agent messages: first message per agent goes to parallel batch
for executor_id, messages_list in agent_messages_by_executor.items():
first_msg = messages_list[0]
remaining = messages_list[1:]
logger.debug("Preparing agent task: %s", executor_id)
task = _prepare_agent_task(context, first_msg[0], first_msg[1])
all_tasks.append(task)
task_metadata_list.append(
TaskMetadata(
executor_id=first_msg[0],
message=first_msg[1],
source_executor_id=first_msg[2],
task_type=TaskType.AGENT,
)
)
# Queue remaining messages for sequential processing
remaining_agent_messages.extend(remaining)
return all_tasks, task_metadata_list, remaining_agent_messages
# ============================================================================
# Message Content Extraction
# ============================================================================
def _extract_message_content(message: Any) -> str:
"""Extract text content from various message types."""
message_content = ""
if isinstance(message, AgentExecutorResponse) and message.agent_response:
if message.agent_response.text:
message_content = message.agent_response.text
elif message.agent_response.messages:
message_content = message.agent_response.messages[-1].text or ""
elif isinstance(message, AgentExecutorRequest) and message.messages:
# Extract text from the last message in the request
message_content = message.messages[-1].text or ""
elif isinstance(message, dict):
logger.warning("Unexpected dict message in _extract_message_content. Keys: %s", list(message.keys()))
elif isinstance(message, str):
message_content = message
return message_content
# ============================================================================
# HITL Response Handler Execution
# ============================================================================
async def execute_hitl_response_handler(
executor: Any,
hitl_message: dict[str, Any],
shared_state: Any,
runner_context: CapturingRunnerContext,
) -> None:
"""Execute a HITL response handler on an executor.
This function handles the delivery of a HITL response to the executor's
@response_handler method. It:
1. Deserializes the original request and response
2. Finds the matching response handler based on types
3. Creates a WorkflowContext and invokes the handler
Args:
executor: The executor instance that has a @response_handler
hitl_message: The HITL response message containing original_request and response
shared_state: The shared state for the workflow context
runner_context: The runner context for capturing outputs
"""
from agent_framework._workflows._workflow_context import WorkflowContext
# Extract the response data
original_request_data = hitl_message.get("original_request")
response_data = hitl_message.get("response")
response_type_str = hitl_message.get("response_type")
# Deserialize the original request
original_request = deserialize_value(original_request_data)
# Deserialize the response - try to match expected type
response = _deserialize_hitl_response(response_data, response_type_str)
# Find the matching response handler
handler = executor._find_response_handler(original_request, response)
if handler is None:
logger.warning(
"No response handler found for HITL response in executor %s. Request type: %s, Response type: %s",
executor.id,
type(original_request).__name__,
type(response).__name__,
)
return
# Create a WorkflowContext for the handler
# Use a special source ID to indicate this is a HITL response
ctx = WorkflowContext(
executor=executor,
source_executor_ids=[SOURCE_HITL_RESPONSE],
runner_context=runner_context,
state=shared_state,
)
# Call the response handler
# Note: handler is already a partial with original_request bound
logger.debug(
"Invoking response handler for HITL request in executor %s",
executor.id,
)
await handler(response, ctx)
def _deserialize_hitl_response(response_data: Any, response_type_str: str | None) -> Any:
"""Deserialize a HITL response to its expected type.
Args:
response_data: The raw response data (typically a dict from JSON)
response_type_str: The fully qualified type name (module:classname)
Returns:
The deserialized response, or the original data if deserialization fails
"""
logger.debug(
"Deserializing HITL response. response_type_str=%s, response_data type=%s",
response_type_str,
type(response_data).__name__,
)
if response_data is None:
return None
# If already a primitive, return as-is
if not isinstance(response_data, dict):
logger.debug("Response data is not a dict, returning as-is: %s", type(response_data).__name__)
return response_data
# Try to deserialize using the type hint
if response_type_str:
response_type = _resolve_type(response_type_str)
if response_type:
logger.debug("Found response type %s, attempting reconstruction", response_type)
result = reconstruct_to_type(response_data, response_type)
logger.debug("Reconstructed response type: %s", type(result).__name__)
return result
logger.warning("Could not resolve response type: %s", response_type_str)
# Fall back to generic deserialization
logger.debug("Falling back to generic deserialization")
return deserialize_value(response_data)
@@ -50,7 +50,7 @@ asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
timeout = 300
markers = [
"integration: marks tests as integration tests (require running function app)",
"orchestration: marks tests that use orchestrations (require Azurite)",
@@ -0,0 +1,95 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Workflow Shared State Sample
Tests the workflow shared state sample for conditional email processing
with shared state management.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_09_workflow_shared_state.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("09_workflow_shared_state"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowSharedState:
"""Tests for 09_workflow_shared_state sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def test_workflow_with_spam_email(self) -> None:
"""Test workflow with spam email content - should be detected and handled as spam."""
spam_content = "URGENT! You have won $1,000,000! Click here to claim your prize now before it expires!"
# Start orchestration with spam email
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", spam_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_with_legitimate_email(self) -> None:
"""Test workflow with legitimate email content - should generate response."""
legitimate_content = (
"Hi team, just a reminder about the sprint planning meeting tomorrow at 10 AM. "
"Please review the agenda items in Jira before the call."
)
# Start orchestration with legitimate email
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", legitimate_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_with_phishing_email(self) -> None:
"""Test workflow with phishing email - should be detected as spam."""
phishing_content = (
"Dear Customer, Your account has been compromised! "
"Click this link immediately to secure your account: http://totallylegit.suspicious.com/secure"
)
# Start orchestration with phishing email
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", phishing_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,111 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Workflow No Shared State Sample
Tests the workflow sample that runs without shared state,
demonstrating conditional routing with spam detection and email response.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_10_workflow_no_shared_state.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("10_workflow_no_shared_state"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowNoSharedState:
"""Tests for 10_workflow_no_shared_state sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def test_workflow_with_spam_email(self) -> None:
"""Test workflow with spam email - should detect and handle as spam."""
payload = {
"email_id": "email-test-001",
"email_content": (
"URGENT! You've won $1,000,000! Click here immediately to claim your prize! "
"Limited time offer - act now!"
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_with_legitimate_email(self) -> None:
"""Test workflow with legitimate email - should draft a response."""
payload = {
"email_id": "email-test-002",
"email_content": (
"Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. "
"Please review the agenda in Jira."
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_status_endpoint(self) -> None:
"""Test that the status endpoint works correctly."""
payload = {
"email_id": "email-test-003",
"email_content": "Quick question: When is the next team meeting scheduled?",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Check status using the workflow status endpoint
status_response = self.helper.get(f"{self.base_url}/api/workflow/status/{instance_id}")
assert status_response.status_code == 200
status = status_response.json()
assert "instanceId" in status
assert status["instanceId"] == instance_id
assert "runtimeStatus" in status
# Wait for completion to clean up
self.helper.wait_for_orchestration(data["statusQueryGetUri"])
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,138 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Parallel Workflow Sample
Tests the parallel workflow execution sample demonstrating:
- Two executors running concurrently (fan-out to activities)
- Two agents running concurrently (fan-out to entities)
- Mixed agent + executor running concurrently
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("11_workflow_parallel"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowParallel:
"""Tests for 11_workflow_parallel sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def test_parallel_workflow_document_analysis(self) -> None:
"""Test parallel workflow with a standard document."""
payload = {
"document_id": "doc-test-001",
"content": (
"The quarterly earnings report shows strong growth in our cloud services division. "
"Revenue increased by 25% compared to last year, driven by enterprise adoption. "
"Customer satisfaction remains high at 92%. However, we face challenges in the "
"mobile segment where competition is intense. Overall, the outlook is positive "
"with expected continued growth in the coming quarters."
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion - parallel workflows may take longer
status = self.helper.wait_for_orchestration_with_output(
data["statusQueryGetUri"],
max_wait=300, # 5 minutes for parallel execution
)
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_parallel_workflow_short_document(self) -> None:
"""Test parallel workflow with a short document."""
payload = {
"document_id": "doc-test-002",
"content": "Quick update: Project completed successfully. Team performance exceeded expectations.",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300)
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_parallel_workflow_technical_document(self) -> None:
"""Test parallel workflow with a technical document."""
payload = {
"document_id": "doc-test-003",
"content": (
"The new microservices architecture has been deployed to production. "
"Key improvements include: reduced latency by 40%, improved scalability "
"to handle 10x traffic spikes, and enhanced monitoring with distributed tracing. "
"The Kubernetes cluster is now running on version 1.28 with auto-scaling enabled. "
"Next steps include implementing service mesh and improving CI/CD pipelines."
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300)
assert status["runtimeStatus"] == "Completed"
def test_workflow_status_endpoint(self) -> None:
"""Test that the workflow status endpoint works correctly."""
payload = {
"document_id": "doc-test-004",
"content": "Brief status update for testing purposes.",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Check status
status_response = self.helper.get(f"{self.base_url}/api/workflow/status/{instance_id}")
assert status_response.status_code == 200
status = status_response.json()
assert "instanceId" in status
assert status["instanceId"] == instance_id
# Wait for completion
self.helper.wait_for_orchestration(data["statusQueryGetUri"], max_wait=300)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,214 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Workflow Human-in-the-Loop (HITL) Sample
Tests the workflow HITL sample demonstrating content moderation with human approval
using the MAF request_info / @response_handler pattern.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_12_workflow_hitl.py -v
"""
import time
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("12_workflow_hitl"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowHITL:
"""Tests for 12_workflow_hitl sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def _wait_for_hitl_request(self, instance_id: str, timeout: int = 40) -> dict:
"""Polls for a pending HITL request."""
start_time = time.time()
while time.time() - start_time < timeout:
status_response = self.helper.get(f"{self.base_url}/api/workflow/status/{instance_id}")
if status_response.status_code == 200:
status = status_response.json()
pending_requests = status.get("pendingHumanInputRequests", [])
if pending_requests:
return status
time.sleep(2)
raise AssertionError(f"Timed out waiting for HITL request for instance {instance_id}")
def test_hitl_workflow_approval(self) -> None:
"""Test HITL workflow with human approval."""
payload = {
"content_id": "article-test-001",
"title": "Introduction to AI in Healthcare",
"body": (
"Artificial intelligence is revolutionizing healthcare by enabling faster diagnosis, "
"personalized treatment plans, and improved patient outcomes. Machine learning algorithms "
"can analyze medical images with remarkable accuracy."
),
"author": "Dr. Jane Smith",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
instance_id = data["instanceId"]
# Wait for the workflow to reach the HITL pause point
status = self._wait_for_hitl_request(instance_id)
# Confirm status is valid
assert status["runtimeStatus"] in ["Running", "Pending"]
# Get the request ID from pending requests
pending_requests = status.get("pendingHumanInputRequests", [])
assert len(pending_requests) > 0, "Expected pending HITL request"
request_id = pending_requests[0]["requestId"]
# Send approval
approval_response = self.helper.post_json(
f"{self.base_url}/api/workflow/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": "Content is appropriate and well-written."},
)
assert approval_response.status_code == 200
# Wait for orchestration to complete
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
assert "output" in final_status
def test_hitl_workflow_rejection(self) -> None:
"""Test HITL workflow with human rejection."""
payload = {
"content_id": "article-test-002",
"title": "Get Rich Quick Scheme",
"body": (
"Click here NOW to make $10,000 overnight! This SECRET method is GUARANTEED to work! "
"Limited time offer - act NOW before it's too late!"
),
"author": "Definitely Not Spam",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for the workflow to reach the HITL pause point
status = self._wait_for_hitl_request(instance_id)
# Get the request ID from pending requests
pending_requests = status.get("pendingHumanInputRequests", [])
assert len(pending_requests) > 0, "Expected pending HITL request"
request_id = pending_requests[0]["requestId"]
# Send rejection
rejection_response = self.helper.post_json(
f"{self.base_url}/api/workflow/respond/{instance_id}/{request_id}",
{"approved": False, "reviewer_notes": "Content appears to be spam/scam material."},
)
assert rejection_response.status_code == 200
# Wait for orchestration to complete
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
assert "output" in final_status
# The output should indicate rejection
output = final_status["output"]
assert "rejected" in str(output).lower()
def test_hitl_workflow_status_endpoint(self) -> None:
"""Test that the workflow status endpoint shows pending HITL requests."""
payload = {
"content_id": "article-test-003",
"title": "Test Article",
"body": "This is a test article for checking status endpoint functionality.",
"author": "Test Author",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for HITL pause
status = self._wait_for_hitl_request(instance_id)
# Check status
assert "instanceId" in status
assert status["instanceId"] == instance_id
assert "runtimeStatus" in status
assert "pendingHumanInputRequests" in status
# Clean up: approve to complete
pending_requests = status.get("pendingHumanInputRequests", [])
if pending_requests:
request_id = pending_requests[0]["requestId"]
self.helper.post_json(
f"{self.base_url}/api/workflow/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": ""},
)
# Wait for completion
self.helper.wait_for_orchestration(data["statusQueryGetUri"])
def test_hitl_workflow_with_neutral_content(self) -> None:
"""Test HITL workflow with neutral content that should get medium risk."""
payload = {
"content_id": "article-test-004",
"title": "Product Review",
"body": (
"This product works as advertised. The build quality is average and the price "
"is reasonable. I would recommend it for basic use cases but not for professional work."
),
"author": "Regular User",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for HITL pause
status = self._wait_for_hitl_request(instance_id)
pending_requests = status.get("pendingHumanInputRequests", [])
assert len(pending_requests) > 0
request_id = pending_requests[0]["requestId"]
# Approve
self.helper.post_json(
f"{self.base_url}/api/workflow/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": "Approved after review."},
)
# Wait for completion
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -1317,5 +1317,129 @@ class TestAgentFunctionAppErrorPaths:
assert app._coerce_to_bool([]) is False
class TestAgentFunctionAppWorkflow:
"""Test suite for AgentFunctionApp workflow support."""
def test_init_with_workflow_stores_workflow(self) -> None:
"""Test that workflow is stored when provided."""
mock_workflow = Mock()
mock_workflow.executors = {}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
):
app = AgentFunctionApp(workflow=mock_workflow)
assert app.workflow is mock_workflow
def test_init_with_workflow_extracts_agents(self) -> None:
"""Test that agents are extracted from workflow executors."""
from agent_framework import AgentExecutor
mock_agent = Mock()
mock_agent.name = "WorkflowAgent"
mock_executor = Mock(spec=AgentExecutor)
mock_executor.agent = mock_agent
mock_workflow = Mock()
mock_workflow.executors = {"WorkflowAgent": mock_executor}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
patch.object(AgentFunctionApp, "_setup_agent_functions"),
):
app = AgentFunctionApp(workflow=mock_workflow)
assert "WorkflowAgent" in app.agents
def test_init_with_workflow_calls_setup_methods(self) -> None:
"""Test that workflow setup methods are called."""
mock_executor = Mock()
mock_executor.id = "TestExecutor"
mock_workflow = Mock()
# Include a non-AgentExecutor so _setup_executor_activity is called
mock_workflow.executors = {"TestExecutor": mock_executor}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
):
AgentFunctionApp(workflow=mock_workflow)
setup_exec.assert_called_once()
setup_orch.assert_called_once()
def test_init_without_workflow_does_not_call_workflow_setup(self) -> None:
"""Test that workflow setup is not called when no workflow provided."""
mock_agent = Mock()
mock_agent.name = "TestAgent"
with (
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
):
AgentFunctionApp(agents=[mock_agent])
setup_exec.assert_not_called()
setup_orch.assert_not_called()
def test_init_with_workflow_deduplicates_agents(self) -> None:
"""Test that agents in both 'agents' and workflow are not double-registered."""
from agent_framework import AgentExecutor
mock_agent = Mock()
mock_agent.name = "SharedAgent"
mock_executor = Mock(spec=AgentExecutor)
mock_executor.agent = mock_agent
mock_workflow = Mock()
mock_workflow.executors = {"SharedAgent": mock_executor}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
patch.object(AgentFunctionApp, "_setup_agent_functions"),
):
# Same agent passed explicitly AND present in workflow — should not raise
app = AgentFunctionApp(agents=[mock_agent], workflow=mock_workflow)
assert "SharedAgent" in app.agents
def test_build_status_url(self) -> None:
"""Test _build_status_url constructs correct URL."""
mock_workflow = Mock()
mock_workflow.executors = {}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
):
app = AgentFunctionApp(workflow=mock_workflow)
url = app._build_status_url("http://localhost:7071/api/workflow/run", "instance-123")
assert url == "http://localhost:7071/api/workflow/status/instance-123"
def test_build_status_url_handles_trailing_slash(self) -> None:
"""Test _build_status_url handles URLs without /api/ correctly."""
mock_workflow = Mock()
mock_workflow.executors = {}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
):
app = AgentFunctionApp(workflow=mock_workflow)
url = app._build_status_url("http://localhost:7071/", "instance-456")
assert "instance-456" in url
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -40,14 +40,17 @@ class TestMultiAgentInit:
assert len(app.agents) == 0
def test_init_with_duplicate_agent_names(self) -> None:
"""Test initialization with agents having the same name raises error."""
"""Test initialization with duplicate agent names deduplicates with warning."""
agent1 = Mock()
agent1.name = "TestAgent"
agent2 = Mock()
agent2.name = "TestAgent"
with pytest.raises(ValueError, match="already registered"):
AgentFunctionApp(agents=[agent1, agent2])
app = AgentFunctionApp(agents=[agent1, agent2])
# Duplicate is skipped, only the first agent is registered
assert len(app.agents) == 1
assert "TestAgent" in app.agents
def test_init_with_agent_without_name(self) -> None:
"""Test initialization with agent missing name attribute raises error."""
@@ -91,8 +94,8 @@ class TestAddAgentMethod:
assert "Agent1" in app.agents
assert "Agent2" in app.agents
def test_add_agent_with_duplicate_name_raises_error(self) -> None:
"""Test that adding agent with duplicate name raises ValueError."""
def test_add_agent_with_duplicate_name_skips(self) -> None:
"""Test that adding agent with duplicate name logs warning and skips."""
agent1 = Mock()
agent1.name = "MyAgent"
agent2 = Mock()
@@ -100,9 +103,11 @@ class TestAddAgentMethod:
app = AgentFunctionApp(agents=[agent1])
# Try to add another agent with the same name
with pytest.raises(ValueError, match="already registered"):
app.add_agent(agent2)
# Duplicate is silently skipped with a warning
app.add_agent(agent2)
# Only the original agent remains
assert len(app.agents) == 1
def test_add_agent_to_app_with_existing_agents(self) -> None:
"""Test adding agent to app that already has agents."""
@@ -0,0 +1,374 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for workflow utility functions."""
from dataclasses import dataclass
from unittest.mock import Mock
import pytest
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Message,
WorkflowEvent,
WorkflowMessage,
)
from pydantic import BaseModel
from agent_framework_azurefunctions._context import CapturingRunnerContext
from agent_framework_azurefunctions._serialization import (
deserialize_value,
reconstruct_to_type,
serialize_value,
)
# Module-level test types (must be importable for checkpoint encoding roundtrip)
@dataclass
class SampleData:
"""Sample dataclass for testing checkpoint encoding roundtrip."""
name: str
value: int
class SampleModel(BaseModel):
"""Sample Pydantic model for testing checkpoint encoding roundtrip."""
title: str
count: int
@dataclass
class DataclassWithPydanticField:
"""Dataclass containing a Pydantic model field for testing nested serialization."""
label: str
model: SampleModel
class TestCapturingRunnerContext:
"""Test suite for CapturingRunnerContext."""
@pytest.fixture
def context(self) -> CapturingRunnerContext:
"""Create a fresh CapturingRunnerContext for each test."""
return CapturingRunnerContext()
@pytest.mark.asyncio
async def test_send_message_captures_message(self, context: CapturingRunnerContext) -> None:
"""Test that send_message captures messages correctly."""
message = WorkflowMessage(data="test data", target_id="target_1", source_id="source_1")
await context.send_message(message)
messages = await context.drain_messages()
assert "source_1" in messages
assert len(messages["source_1"]) == 1
assert messages["source_1"][0].data == "test data"
@pytest.mark.asyncio
async def test_send_multiple_messages_groups_by_source(self, context: CapturingRunnerContext) -> None:
"""Test that messages are grouped by source_id."""
msg1 = WorkflowMessage(data="msg1", target_id="target", source_id="source_a")
msg2 = WorkflowMessage(data="msg2", target_id="target", source_id="source_a")
msg3 = WorkflowMessage(data="msg3", target_id="target", source_id="source_b")
await context.send_message(msg1)
await context.send_message(msg2)
await context.send_message(msg3)
messages = await context.drain_messages()
assert len(messages["source_a"]) == 2
assert len(messages["source_b"]) == 1
@pytest.mark.asyncio
async def test_drain_messages_clears_messages(self, context: CapturingRunnerContext) -> None:
"""Test that drain_messages clears the message store."""
message = WorkflowMessage(data="test", target_id="t", source_id="s")
await context.send_message(message)
await context.drain_messages() # First drain
messages = await context.drain_messages() # Second drain
assert messages == {}
@pytest.mark.asyncio
async def test_has_messages_returns_correct_status(self, context: CapturingRunnerContext) -> None:
"""Test has_messages returns correct boolean."""
assert await context.has_messages() is False
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
assert await context.has_messages() is True
@pytest.mark.asyncio
async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None:
"""Test that add_event queues events correctly."""
event = WorkflowEvent.output(executor_id="exec_1", data="output")
await context.add_event(event)
events = await context.drain_events()
assert len(events) == 1
assert isinstance(events[0], WorkflowEvent)
assert events[0].type == "output"
assert events[0].data == "output"
@pytest.mark.asyncio
async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None:
"""Test that drain_events clears the event queue."""
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
await context.drain_events() # First drain
events = await context.drain_events() # Second drain
assert events == []
@pytest.mark.asyncio
async def test_has_events_returns_correct_status(self, context: CapturingRunnerContext) -> None:
"""Test has_events returns correct boolean."""
assert await context.has_events() is False
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
assert await context.has_events() is True
@pytest.mark.asyncio
async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None:
"""Test that next_event returns queued events."""
event = WorkflowEvent.output(executor_id="e", data="waited")
await context.add_event(event)
result = await context.next_event()
assert result.data == "waited"
def test_has_checkpointing_returns_false(self, context: CapturingRunnerContext) -> None:
"""Test that checkpointing is not supported."""
assert context.has_checkpointing() is False
def test_is_streaming_returns_false_by_default(self, context: CapturingRunnerContext) -> None:
"""Test streaming is disabled by default."""
assert context.is_streaming() is False
def test_set_streaming(self, context: CapturingRunnerContext) -> None:
"""Test setting streaming mode."""
context.set_streaming(True)
assert context.is_streaming() is True
context.set_streaming(False)
assert context.is_streaming() is False
def test_set_workflow_id(self, context: CapturingRunnerContext) -> None:
"""Test setting workflow ID."""
context.set_workflow_id("workflow-123")
assert context._workflow_id == "workflow-123"
@pytest.mark.asyncio
async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None:
"""Test that reset_for_new_run clears all state."""
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
await context.add_event(WorkflowEvent.output(executor_id="e", data="event"))
context.set_streaming(True)
context.reset_for_new_run()
assert await context.has_messages() is False
assert await context.has_events() is False
assert context.is_streaming() is False
@pytest.mark.asyncio
async def test_create_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
"""Test that checkpointing methods raise NotImplementedError."""
from agent_framework._workflows import State
with pytest.raises(NotImplementedError):
await context.create_checkpoint("test_workflow", "abc123", State(), None, 1)
@pytest.mark.asyncio
async def test_load_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
"""Test that load_checkpoint raises NotImplementedError."""
with pytest.raises(NotImplementedError):
await context.load_checkpoint("some-id")
@pytest.mark.asyncio
async def test_apply_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
"""Test that apply_checkpoint raises NotImplementedError."""
with pytest.raises(NotImplementedError):
await context.apply_checkpoint(Mock())
class TestSerializationRoundtrip:
"""Test that serialization roundtrips correctly for types used in Azure Functions workflows."""
def test_roundtrip_chat_message(self) -> None:
"""Test Message survives encode → decode roundtrip."""
original = Message(role="user", text="Hello")
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, Message)
assert decoded.role == "user"
def test_roundtrip_agent_executor_request(self) -> None:
"""Test AgentExecutorRequest with nested Messages roundtrips."""
original = AgentExecutorRequest(
messages=[Message(role="user", text="Hi")],
should_respond=True,
)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, AgentExecutorRequest)
assert len(decoded.messages) == 1
assert isinstance(decoded.messages[0], Message)
assert decoded.should_respond is True
def test_roundtrip_agent_executor_response(self) -> None:
"""Test AgentExecutorResponse with nested AgentResponse roundtrips."""
original = AgentExecutorResponse(
executor_id="test_exec",
agent_response=AgentResponse(messages=[Message(role="assistant", text="Reply")]),
)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, AgentExecutorResponse)
assert decoded.executor_id == "test_exec"
assert isinstance(decoded.agent_response, AgentResponse)
def test_roundtrip_dataclass(self) -> None:
"""Test custom dataclass roundtrips."""
original = SampleData(name="test", value=42)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, SampleData)
assert decoded.name == "test"
assert decoded.value == 42
def test_roundtrip_pydantic_model(self) -> None:
"""Test Pydantic model roundtrips."""
original = SampleModel(title="Hello", count=5)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, SampleModel)
assert decoded.title == "Hello"
assert decoded.count == 5
def test_roundtrip_primitives(self) -> None:
"""Test primitives pass through unchanged."""
assert serialize_value(None) is None
assert serialize_value("hello") == "hello"
assert serialize_value(42) == 42
assert serialize_value(3.14) == 3.14
assert serialize_value(True) is True
def test_roundtrip_list_of_objects(self) -> None:
"""Test list of typed objects roundtrips."""
original = [
Message(role="user", text="Q"),
Message(role="assistant", text="A"),
]
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, list)
assert len(decoded) == 2
assert all(isinstance(m, Message) for m in decoded)
def test_roundtrip_dict_of_objects(self) -> None:
"""Test dict with typed values roundtrips (used for shared state)."""
original = {"count": 42, "msg": Message(role="user", text="Hi")}
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert decoded["count"] == 42
assert isinstance(decoded["msg"], Message)
def test_roundtrip_dataclass_with_nested_pydantic(self) -> None:
"""Test dataclass containing a Pydantic model field roundtrips correctly.
This covers the HITL pattern where AnalysisWithSubmission (dataclass)
contains a ContentAnalysisResult (Pydantic BaseModel) field.
"""
original = DataclassWithPydanticField(label="test", model=SampleModel(title="Nested", count=99))
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, DataclassWithPydanticField)
assert decoded.label == "test"
assert isinstance(decoded.model, SampleModel)
assert decoded.model.title == "Nested"
assert decoded.model.count == 99
class TestReconstructToType:
"""Test suite for reconstruct_to_type function (used for HITL responses)."""
def test_none_returns_none(self) -> None:
"""Test that None input returns None."""
assert reconstruct_to_type(None, str) is None
def test_already_correct_type(self) -> None:
"""Test that values already of the correct type are returned as-is."""
assert reconstruct_to_type("hello", str) == "hello"
assert reconstruct_to_type(42, int) == 42
def test_non_dict_returns_original(self) -> None:
"""Test that non-dict values are returned as-is."""
assert reconstruct_to_type("hello", int) == "hello"
assert reconstruct_to_type([1, 2], dict) == [1, 2]
def test_reconstruct_pydantic_model(self) -> None:
"""Test reconstruction of Pydantic model from plain dict."""
class ApprovalResponse(BaseModel):
approved: bool
reason: str
data = {"approved": True, "reason": "Looks good"}
result = reconstruct_to_type(data, ApprovalResponse)
assert isinstance(result, ApprovalResponse)
assert result.approved is True
assert result.reason == "Looks good"
def test_reconstruct_dataclass(self) -> None:
"""Test reconstruction of dataclass from plain dict."""
@dataclass
class Feedback:
score: int
comment: str
data = {"score": 5, "comment": "Great"}
result = reconstruct_to_type(data, Feedback)
assert isinstance(result, Feedback)
assert result.score == 5
assert result.comment == "Great"
def test_reconstruct_from_checkpoint_markers(self) -> None:
"""Test that data with checkpoint markers is decoded via deserialize_value."""
original = SampleData(value=99, name="marker-test")
encoded = serialize_value(original)
result = reconstruct_to_type(encoded, SampleData)
assert isinstance(result, SampleData)
assert result.value == 99
def test_unrecognized_dict_returns_original(self) -> None:
"""Test that unrecognized dicts are returned as-is."""
@dataclass
class Unrelated:
completely_different: str
data = {"some_key": "some_value"}
result = reconstruct_to_type(data, Unrelated)
assert result == data
@@ -0,0 +1,323 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for workflow orchestration functions."""
import json
from dataclasses import dataclass
from typing import Any
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Message,
)
from agent_framework._workflows._edge import (
FanInEdgeGroup,
FanOutEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
SwitchCaseEdgeGroupDefault,
)
from agent_framework_azurefunctions._workflow import (
_extract_message_content,
build_agent_executor_response,
route_message_through_edge_groups,
)
class TestRouteMessageThroughEdgeGroups:
"""Test suite for route_message_through_edge_groups function."""
def test_single_edge_group_routes_when_condition_matches(self) -> None:
"""Test SingleEdgeGroup routes when condition is satisfied."""
group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True)
targets = route_message_through_edge_groups([group], "src", "any message")
assert targets == ["tgt"]
def test_single_edge_group_does_not_route_when_condition_fails(self) -> None:
"""Test SingleEdgeGroup does not route when condition fails."""
group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: False)
targets = route_message_through_edge_groups([group], "src", "any message")
assert targets == []
def test_single_edge_group_ignores_different_source(self) -> None:
"""Test SingleEdgeGroup ignores messages from different sources."""
group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True)
targets = route_message_through_edge_groups([group], "other_src", "any message")
assert targets == []
def test_switch_case_with_selection_func(self) -> None:
"""Test SwitchCaseEdgeGroup uses selection_func."""
def select_first_target(msg: Any, targets: list[str]) -> list[str]:
return [targets[0]]
group = SwitchCaseEdgeGroup(
source_id="src",
cases=[
SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"),
SwitchCaseEdgeGroupDefault(target_id="target_b"),
],
)
# Manually set the selection function
group._selection_func = select_first_target
targets = route_message_through_edge_groups([group], "src", "test")
assert targets == ["target_a"]
def test_switch_case_without_selection_func_broadcasts(self) -> None:
"""Test SwitchCaseEdgeGroup without selection_func broadcasts to all."""
group = SwitchCaseEdgeGroup(
source_id="src",
cases=[
SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"),
SwitchCaseEdgeGroupDefault(target_id="target_b"),
],
)
group._selection_func = None
targets = route_message_through_edge_groups([group], "src", "test")
assert set(targets) == {"target_a", "target_b"}
def test_fan_out_with_selection_func(self) -> None:
"""Test FanOutEdgeGroup uses selection_func."""
def select_all(msg: Any, targets: list[str]) -> list[str]:
return targets
group = FanOutEdgeGroup(
source_id="src",
target_ids=["fan_a", "fan_b", "fan_c"],
selection_func=select_all,
)
targets = route_message_through_edge_groups([group], "src", "broadcast")
assert set(targets) == {"fan_a", "fan_b", "fan_c"}
def test_fan_in_is_not_routed_directly(self) -> None:
"""Test FanInEdgeGroup is handled separately (not routed here)."""
group = FanInEdgeGroup(
source_ids=["src_a", "src_b"],
target_id="aggregator",
)
# Fan-in should not add targets through this function
targets = route_message_through_edge_groups([group], "src_a", "message")
assert targets == []
def test_multiple_edge_groups_aggregated(self) -> None:
"""Test that targets from multiple edge groups are aggregated."""
group1 = SingleEdgeGroup(source_id="src", target_id="t1", condition=lambda m: True)
group2 = SingleEdgeGroup(source_id="src", target_id="t2", condition=lambda m: True)
targets = route_message_through_edge_groups([group1, group2], "src", "msg")
assert set(targets) == {"t1", "t2"}
class TestBuildAgentExecutorResponse:
"""Test suite for build_agent_executor_response function."""
def test_builds_response_with_text(self) -> None:
"""Test building response with plain text."""
response = build_agent_executor_response(
executor_id="my_executor",
response_text="Hello, world!",
structured_response=None,
previous_message="User input",
)
assert response.executor_id == "my_executor"
assert response.agent_response.text == "Hello, world!"
assert len(response.full_conversation) == 2 # User + Assistant
def test_builds_response_with_structured_response(self) -> None:
"""Test building response with structured JSON response."""
structured = {"answer": 42, "reason": "because"}
response = build_agent_executor_response(
executor_id="calc",
response_text="Original text",
structured_response=structured,
previous_message="Calculate",
)
# Structured response overrides text
assert response.agent_response.text == json.dumps(structured)
def test_conversation_includes_previous_string_message(self) -> None:
"""Test that string previous_message is included in conversation."""
response = build_agent_executor_response(
executor_id="exec",
response_text="Response",
structured_response=None,
previous_message="User said this",
)
assert len(response.full_conversation) == 2
assert response.full_conversation[0].role == "user"
assert response.full_conversation[0].text == "User said this"
assert response.full_conversation[1].role == "assistant"
def test_conversation_extends_previous_agent_executor_response(self) -> None:
"""Test that previous AgentExecutorResponse's conversation is extended."""
# Create a previous response with conversation history
previous = AgentExecutorResponse(
executor_id="prev",
agent_response=AgentResponse(messages=[Message(role="assistant", text="Previous")]),
full_conversation=[
Message(role="user", text="First"),
Message(role="assistant", text="Previous"),
],
)
response = build_agent_executor_response(
executor_id="current",
response_text="Current response",
structured_response=None,
previous_message=previous,
)
# Should have 3 messages: First + Previous + Current
assert len(response.full_conversation) == 3
assert response.full_conversation[0].text == "First"
assert response.full_conversation[1].text == "Previous"
assert response.full_conversation[2].text == "Current response"
class TestExtractMessageContent:
"""Test suite for _extract_message_content function."""
def test_extract_from_string(self) -> None:
"""Test extracting content from plain string."""
result = _extract_message_content("Hello, world!")
assert result == "Hello, world!"
def test_extract_from_agent_executor_response_with_text(self) -> None:
"""Test extracting from AgentExecutorResponse with text."""
response = AgentExecutorResponse(
executor_id="exec",
agent_response=AgentResponse(messages=[Message(role="assistant", text="Response text")]),
)
result = _extract_message_content(response)
assert result == "Response text"
def test_extract_from_agent_executor_response_with_messages(self) -> None:
"""Test extracting from AgentExecutorResponse with messages."""
response = AgentExecutorResponse(
executor_id="exec",
agent_response=AgentResponse(
messages=[
Message(role="user", text="First"),
Message(role="assistant", text="Last message"),
]
),
)
result = _extract_message_content(response)
# AgentResponse.text concatenates all message texts
assert result == "FirstLast message"
def test_extract_from_agent_executor_request(self) -> None:
"""Test extracting from AgentExecutorRequest."""
request = AgentExecutorRequest(
messages=[
Message(role="user", text="First"),
Message(role="user", text="Last request"),
]
)
result = _extract_message_content(request)
assert result == "Last request"
def test_extract_from_dict_returns_empty(self) -> None:
"""Test that dict messages return empty string (unexpected input)."""
msg_dict = {"messages": [{"text": "Hello"}]}
result = _extract_message_content(msg_dict)
assert result == ""
def test_extract_returns_empty_for_unknown_type(self) -> None:
"""Test that unknown types return empty string."""
result = _extract_message_content(12345)
assert result == ""
class TestEdgeGroupIntegration:
"""Integration tests for edge group routing with realistic scenarios."""
def test_conditional_routing_by_message_type(self) -> None:
"""Test routing based on message content/type."""
@dataclass
class SpamResult:
is_spam: bool
reason: str
def is_spam_condition(msg: Any) -> bool:
if isinstance(msg, SpamResult):
return msg.is_spam
return False
def is_not_spam_condition(msg: Any) -> bool:
if isinstance(msg, SpamResult):
return not msg.is_spam
return False
spam_group = SingleEdgeGroup(
source_id="detector",
target_id="spam_handler",
condition=is_spam_condition,
)
legit_group = SingleEdgeGroup(
source_id="detector",
target_id="email_handler",
condition=is_not_spam_condition,
)
# Test spam message
spam_msg = SpamResult(is_spam=True, reason="Suspicious content")
targets = route_message_through_edge_groups([spam_group, legit_group], "detector", spam_msg)
assert targets == ["spam_handler"]
# Test legitimate message
legit_msg = SpamResult(is_spam=False, reason="Clean")
targets = route_message_through_edge_groups([spam_group, legit_group], "detector", legit_msg)
assert targets == ["email_handler"]
def test_fan_out_to_multiple_workers(self) -> None:
"""Test fan-out to multiple parallel workers."""
def select_all_workers(msg: Any, targets: list[str]) -> list[str]:
return targets
group = FanOutEdgeGroup(
source_id="coordinator",
target_ids=["worker_1", "worker_2", "worker_3"],
selection_func=select_all_workers,
)
targets = route_message_through_edge_groups([group], "coordinator", {"task": "process"})
assert len(targets) == 3
assert set(targets) == {"worker_1", "worker_2", "worker_3"}
@@ -26,6 +26,10 @@ from ._checkpoint import (
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
from ._checkpoint_encoding import (
decode_checkpoint_value,
encode_checkpoint_value,
)
from ._const import (
DEFAULT_MAX_ITERATIONS,
)
@@ -67,6 +71,7 @@ from ._runner_context import (
RunnerContext,
WorkflowMessage,
)
from ._state import State
from ._validation import (
EdgeDuplicationError,
GraphConnectivityError,
@@ -107,6 +112,7 @@ __all__ = [
"Runner",
"RunnerContext",
"SingleEdgeGroup",
"State",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
"SwitchCaseEdgeGroup",
@@ -134,6 +140,8 @@ __all__ = [
"WorkflowValidationError",
"WorkflowViz",
"create_edge_runner",
"decode_checkpoint_value",
"encode_checkpoint_value",
"executor",
"handler",
"resolve_agent_id",
@@ -107,6 +107,11 @@ class AgentExecutor(Executor):
# This tracks the full conversation after each run
self._full_conversation: list[Message] = []
@property
def agent(self) -> SupportsAgentRun:
"""Get the underlying agent wrapped by this executor."""
return self._agent
@property
def description(self) -> str | None:
"""Get the description of the underlying agent."""