mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
9a369c69c0
commit
bb3d3c2efc
@@ -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)",
|
||||
|
||||
+95
@@ -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"])
|
||||
+111
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user