mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Replace RequestInfoExecutor with request_info API and @response_handler (#1466)
* Prototype: Add request_info API and @response_handler * Add original_request as a parameter to the response handler * Prototype: request interception in sub workflows * Prototype: request interception in sub workflows 2 * WIP: Make checkpointing work * checkpointing with sub workflow * Fix function executor * Allow sub-workflow to output directly * Remove ReqeustInfoExecutor and related classes; Debugging checkpoint_with_human_in_the_loop * Fix Handoff and sample * fix pending requests in checkpoint * Fix unit tests * Fix formatting * Resolve comments * Address comment * Add checkpoint tests * Add tests * misc * fix mypy * fix mypy * Use request type as part of the key * Log warning if there is not response handler for a request * Update Internal edge group comments * REcord message type in executor processing span * Update sample * Improve tests
This commit is contained in:
@@ -75,12 +75,7 @@ from ._magentic import (
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from ._orchestration_state import OrchestrationState
|
||||
from ._request_info_executor import (
|
||||
PendingRequestDetails,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
)
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner import Runner
|
||||
from ._runner_context import (
|
||||
InProcRunnerContext,
|
||||
@@ -91,7 +86,6 @@ from ._sequential import SequentialBuilder
|
||||
from ._shared_state import SharedState
|
||||
from ._validation import (
|
||||
EdgeDuplicationError,
|
||||
ExecutorDuplicationError,
|
||||
GraphConnectivityError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
@@ -102,7 +96,7 @@ from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowRunResult
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MANAGER_INSTRUCTIONS",
|
||||
@@ -121,7 +115,6 @@ __all__ = [
|
||||
"EdgeDuplicationError",
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorDuplicationError",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokedEvent",
|
||||
@@ -150,17 +143,15 @@ __all__ = [
|
||||
"ManagerDirectiveModel",
|
||||
"Message",
|
||||
"OrchestrationState",
|
||||
"PendingRequestDetails",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoExecutor",
|
||||
"RequestInfoMessage",
|
||||
"RequestResponse",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SequentialBuilder",
|
||||
"SharedState",
|
||||
"SingleEdgeGroup",
|
||||
"StandardMagenticManager",
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
@@ -189,5 +180,6 @@ __all__ = [
|
||||
"executor",
|
||||
"get_checkpoint_summary",
|
||||
"handler",
|
||||
"response_handler",
|
||||
"validate_workflow_graph",
|
||||
]
|
||||
|
||||
@@ -72,12 +72,7 @@ from ._magentic import (
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from ._orchestration_state import OrchestrationState
|
||||
from ._request_info_executor import (
|
||||
PendingRequestDetails,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
)
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner import Runner
|
||||
from ._runner_context import (
|
||||
InProcRunnerContext,
|
||||
@@ -88,7 +83,6 @@ from ._sequential import SequentialBuilder
|
||||
from ._shared_state import SharedState
|
||||
from ._validation import (
|
||||
EdgeDuplicationError,
|
||||
ExecutorDuplicationError,
|
||||
GraphConnectivityError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
@@ -99,7 +93,7 @@ from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowRunResult
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MANAGER_INSTRUCTIONS",
|
||||
@@ -118,7 +112,6 @@ __all__ = [
|
||||
"EdgeDuplicationError",
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorDuplicationError",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokedEvent",
|
||||
@@ -146,17 +139,15 @@ __all__ = [
|
||||
"MagenticPlanReviewRequest",
|
||||
"Message",
|
||||
"OrchestrationState",
|
||||
"PendingRequestDetails",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoExecutor",
|
||||
"RequestInfoMessage",
|
||||
"RequestResponse",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SequentialBuilder",
|
||||
"SharedState",
|
||||
"SingleEdgeGroup",
|
||||
"StandardMagenticManager",
|
||||
"SubWorkflowRequestMessage",
|
||||
"SubWorkflowResponseMessage",
|
||||
"SwitchCaseEdgeGroup",
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
@@ -185,5 +176,6 @@ __all__ = [
|
||||
"executor",
|
||||
"get_checkpoint_summary",
|
||||
"handler",
|
||||
"response_handler",
|
||||
"validate_workflow_graph",
|
||||
]
|
||||
|
||||
@@ -44,6 +44,7 @@ class WorkflowCheckpoint:
|
||||
# Core workflow state
|
||||
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
|
||||
shared_state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
|
||||
pending_request_info_events: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
|
||||
|
||||
# Runtime state
|
||||
iteration_count: int = 0
|
||||
|
||||
@@ -15,7 +15,6 @@ DATACLASS_MARKER = "__af_dataclass__"
|
||||
_MAX_ENCODE_DEPTH = 100
|
||||
_CYCLE_SENTINEL = "<cycle>"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -75,7 +74,7 @@ def encode_checkpoint_value(value: Any) -> Any:
|
||||
# type(v) already narrows sufficiently; cast was redundant
|
||||
dc_cls: type[Any] = type(v)
|
||||
field_values: dict[str, Any] = {}
|
||||
for f in fields(v): # type: ignore[arg-type]
|
||||
for f in fields(v):
|
||||
field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
|
||||
return {
|
||||
DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
|
||||
@@ -189,6 +188,35 @@ def decode_checkpoint_value(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _supports_model_protocol(obj: object) -> bool:
|
||||
"""Detect objects that expose dictionary serialization hooks."""
|
||||
try:
|
||||
obj_type: type[Any] = type(obj)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
has_to_dict = hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict", None)) # type: ignore[arg-type]
|
||||
has_from_dict = hasattr(obj_type, "from_dict") and callable(getattr(obj_type, "from_dict", None))
|
||||
|
||||
has_to_json = hasattr(obj, "to_json") and callable(getattr(obj, "to_json", None)) # type: ignore[arg-type]
|
||||
has_from_json = hasattr(obj_type, "from_json") and callable(getattr(obj_type, "from_json", None))
|
||||
|
||||
return (has_to_dict and has_from_dict) or (has_to_json and has_from_json)
|
||||
|
||||
|
||||
def _import_qualified_name(qualname: str) -> type[Any] | None:
|
||||
if ":" not in qualname:
|
||||
return None
|
||||
module_name, class_name = qualname.split(":", 1)
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
module = importlib.import_module(module_name)
|
||||
attr: Any = module
|
||||
for part in class_name.split("."):
|
||||
attr = getattr(attr, part)
|
||||
return attr if isinstance(attr, type) else None
|
||||
|
||||
|
||||
def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | None:
|
||||
if not isinstance(cls, type):
|
||||
logger.debug(f"Checkpoint decoder received non-type dataclass reference: {cls!r}")
|
||||
@@ -220,32 +248,3 @@ def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | Non
|
||||
except Exception as exc:
|
||||
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}({payload!r}): {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _supports_model_protocol(obj: object) -> bool:
|
||||
"""Detect objects that expose dictionary serialization hooks."""
|
||||
try:
|
||||
obj_type: type[Any] = type(obj)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
has_to_dict = hasattr(obj, "to_dict") and callable(getattr(obj, "to_dict", None)) # type: ignore[arg-type]
|
||||
has_from_dict = hasattr(obj_type, "from_dict") and callable(getattr(obj_type, "from_dict", None))
|
||||
|
||||
has_to_json = hasattr(obj, "to_json") and callable(getattr(obj, "to_json", None)) # type: ignore[arg-type]
|
||||
has_from_json = hasattr(obj_type, "from_json") and callable(getattr(obj_type, "from_json", None))
|
||||
|
||||
return (has_to_dict and has_from_dict) or (has_to_json and has_from_json)
|
||||
|
||||
|
||||
def _import_qualified_name(qualname: str) -> type[Any] | None:
|
||||
if ":" not in qualname:
|
||||
return None
|
||||
module_name, class_name = qualname.split(":", 1)
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
module = importlib.import_module(module_name)
|
||||
attr: Any = module
|
||||
for part in class_name.split("."):
|
||||
attr = getattr(attr, part)
|
||||
return attr if isinstance(attr, type) else None
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from textwrap import shorten
|
||||
from typing import Any
|
||||
|
||||
from ._checkpoint import WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._request_info_executor import PendingRequestDetails, RequestInfoMessage, RequestResponse
|
||||
from ._events import RequestInfoEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,174 +15,35 @@ class WorkflowCheckpointSummary:
|
||||
"""Human-readable summary of a workflow checkpoint."""
|
||||
|
||||
checkpoint_id: str
|
||||
timestamp: str
|
||||
iteration_count: int
|
||||
targets: list[str]
|
||||
executor_ids: list[str]
|
||||
status: str
|
||||
draft_preview: str | None
|
||||
pending_requests: list[PendingRequestDetails]
|
||||
pending_request_info_events: list[RequestInfoEvent]
|
||||
|
||||
|
||||
def get_checkpoint_summary(
|
||||
checkpoint: WorkflowCheckpoint,
|
||||
*,
|
||||
request_executor_ids: Iterable[str] | None = None,
|
||||
preview_width: int = 70,
|
||||
) -> WorkflowCheckpointSummary:
|
||||
def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
|
||||
targets = sorted(checkpoint.messages.keys())
|
||||
executor_ids = sorted(checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}).keys())
|
||||
pending = _pending_requests_from_checkpoint(checkpoint, request_executor_ids=request_executor_ids)
|
||||
|
||||
draft_preview: str | None = None
|
||||
for entry in pending:
|
||||
if entry.draft:
|
||||
draft_preview = shorten(entry.draft, width=preview_width, placeholder="…")
|
||||
break
|
||||
pending_request_info_events = [
|
||||
RequestInfoEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
|
||||
]
|
||||
|
||||
status = "idle"
|
||||
if pending:
|
||||
if pending_request_info_events:
|
||||
status = "awaiting request response"
|
||||
elif not checkpoint.messages and "finalise" in executor_ids:
|
||||
status = "completed"
|
||||
elif checkpoint.messages:
|
||||
status = "awaiting next superstep"
|
||||
elif request_executor_ids is not None and any(tid in targets for tid in request_executor_ids):
|
||||
status = "awaiting request delivery"
|
||||
|
||||
return WorkflowCheckpointSummary(
|
||||
checkpoint_id=checkpoint.checkpoint_id,
|
||||
timestamp=checkpoint.timestamp,
|
||||
iteration_count=checkpoint.iteration_count,
|
||||
targets=targets,
|
||||
executor_ids=executor_ids,
|
||||
status=status,
|
||||
draft_preview=draft_preview,
|
||||
pending_requests=pending,
|
||||
pending_request_info_events=pending_request_info_events,
|
||||
)
|
||||
|
||||
|
||||
def _pending_requests_from_checkpoint(
|
||||
checkpoint: WorkflowCheckpoint,
|
||||
*,
|
||||
request_executor_ids: Iterable[str] | None = None,
|
||||
) -> list[PendingRequestDetails]:
|
||||
executor_filter: set[str] | None = None
|
||||
if request_executor_ids is not None:
|
||||
executor_filter = {str(value) for value in request_executor_ids}
|
||||
|
||||
pending: dict[str, PendingRequestDetails] = {}
|
||||
|
||||
for state in checkpoint.shared_state.get(EXECUTOR_STATE_KEY, {}).values():
|
||||
if not isinstance(state, Mapping):
|
||||
continue
|
||||
inner = state.get("pending_requests")
|
||||
if isinstance(inner, Mapping):
|
||||
for request_id, snapshot in inner.items(): # type: ignore[attr-defined]
|
||||
_merge_snapshot(pending, str(request_id), snapshot) # type: ignore[arg-type]
|
||||
|
||||
for source_id, message_list in checkpoint.messages.items():
|
||||
if executor_filter is not None and source_id not in executor_filter:
|
||||
continue
|
||||
if not isinstance(message_list, list):
|
||||
continue
|
||||
for message in message_list:
|
||||
if not isinstance(message, Mapping):
|
||||
continue
|
||||
payload = decode_checkpoint_value(message.get("data"))
|
||||
_merge_message_payload(pending, payload, message)
|
||||
|
||||
return list(pending.values())
|
||||
|
||||
|
||||
def _merge_snapshot(pending: dict[str, PendingRequestDetails], request_id: str, snapshot: Any) -> None:
|
||||
if not request_id or not isinstance(snapshot, Mapping):
|
||||
return
|
||||
|
||||
details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id))
|
||||
|
||||
_apply_update(
|
||||
details,
|
||||
prompt=snapshot.get("prompt"), # type: ignore[attr-defined]
|
||||
draft=snapshot.get("draft"), # type: ignore[attr-defined]
|
||||
iteration=snapshot.get("iteration"), # type: ignore[attr-defined]
|
||||
source_executor_id=snapshot.get("source_executor_id"), # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
extra = snapshot.get("details") # type: ignore[attr-defined]
|
||||
if isinstance(extra, Mapping):
|
||||
_apply_update(
|
||||
details,
|
||||
prompt=extra.get("prompt"), # type: ignore[attr-defined]
|
||||
draft=extra.get("draft"), # type: ignore[attr-defined]
|
||||
iteration=extra.get("iteration"), # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
|
||||
def _merge_message_payload(
|
||||
pending: dict[str, PendingRequestDetails],
|
||||
payload: Any,
|
||||
raw_message: Mapping[str, Any],
|
||||
) -> None:
|
||||
if isinstance(payload, RequestResponse):
|
||||
request_id = payload.request_id or _get_field(payload.original_request, "request_id") # type: ignore[arg-type]
|
||||
if not request_id:
|
||||
return
|
||||
details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id))
|
||||
_apply_update(
|
||||
details,
|
||||
prompt=_get_field(payload.original_request, "prompt"), # type: ignore[arg-type]
|
||||
draft=_get_field(payload.original_request, "draft"), # type: ignore[arg-type]
|
||||
iteration=_get_field(payload.original_request, "iteration"), # type: ignore[arg-type]
|
||||
source_executor_id=raw_message.get("source_id"),
|
||||
original_request=payload.original_request, # type: ignore[arg-type]
|
||||
)
|
||||
elif isinstance(payload, RequestInfoMessage):
|
||||
request_id = getattr(payload, "request_id", None)
|
||||
if not request_id:
|
||||
return
|
||||
details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id))
|
||||
_apply_update(
|
||||
details,
|
||||
prompt=getattr(payload, "prompt", None),
|
||||
draft=getattr(payload, "draft", None),
|
||||
iteration=getattr(payload, "iteration", None),
|
||||
source_executor_id=raw_message.get("source_id"),
|
||||
original_request=payload,
|
||||
)
|
||||
|
||||
|
||||
def _apply_update(
|
||||
details: PendingRequestDetails,
|
||||
*,
|
||||
prompt: Any = None,
|
||||
draft: Any = None,
|
||||
iteration: Any = None,
|
||||
source_executor_id: Any = None,
|
||||
original_request: Any = None,
|
||||
) -> None:
|
||||
if prompt and not details.prompt:
|
||||
details.prompt = str(prompt)
|
||||
if draft and not details.draft:
|
||||
details.draft = str(draft)
|
||||
if iteration is not None and details.iteration is None:
|
||||
coerced = _coerce_int(iteration)
|
||||
if coerced is not None:
|
||||
details.iteration = coerced
|
||||
if source_executor_id and not details.source_executor_id:
|
||||
details.source_executor_id = str(source_executor_id)
|
||||
if original_request is not None and details.original_request is None:
|
||||
details.original_request = original_request
|
||||
|
||||
|
||||
def _get_field(obj: Any, key: str) -> Any:
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, Mapping):
|
||||
return obj.get(key) # type: ignore[attr-defined,return-value]
|
||||
return getattr(obj, key, None)
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -5,3 +5,11 @@ DEFAULT_MAX_ITERATIONS = 100
|
||||
|
||||
# Key used to store executor state in shared state.
|
||||
EXECUTOR_STATE_KEY = "_executor_state"
|
||||
|
||||
# Source identifier for internal workflow messages.
|
||||
INTERNAL_SOURCE_PREFIX = "internal"
|
||||
|
||||
|
||||
def INTERNAL_SOURCE_ID(executor_id: str) -> str:
|
||||
"""Generate an internal source ID for a given executor."""
|
||||
return f"{INTERNAL_SOURCE_PREFIX}:{executor_id}"
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._executor import Executor
|
||||
from ._model_utils import DictConvertible, encode_value
|
||||
|
||||
@@ -865,3 +866,41 @@ class SwitchCaseEdgeGroup(FanOutEdgeGroup):
|
||||
payload = super().to_dict()
|
||||
payload["cases"] = [encode_value(case) for case in self.cases]
|
||||
return payload
|
||||
|
||||
|
||||
@EdgeGroup.register
|
||||
@dataclass(init=False)
|
||||
class InternalEdgeGroup(EdgeGroup):
|
||||
"""Special edge group used to route internal messages to executors.
|
||||
|
||||
This group is created automatically when a new executor is added to the workflow
|
||||
builder. It contains a single edge that routes messages from the internal source
|
||||
to the executor itself. Internal source represent messages that are generated by
|
||||
the system rather than by another executor. This includes request and response
|
||||
handling.
|
||||
|
||||
This edge group only contains one edge from the internal source to the executor.
|
||||
And it does not support any conditions or complex routing logic.
|
||||
|
||||
During workflow serialization and deserialization, the internal edge group is
|
||||
preserved and visible to systems consuming the workflow definition.
|
||||
|
||||
Messages sent along this edge will also be captured by monitoring and logging systems,
|
||||
allowing for observability into internal message flows (when tracing is enabled).
|
||||
"""
|
||||
|
||||
def __init__(self, executor_id: str) -> None:
|
||||
"""Create an internal edge group from the given edges.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
executor_id:
|
||||
Identifier of the internal executor that should receive messages.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
edge_group = InternalEdgeGroup("executor_a")
|
||||
"""
|
||||
edge = Edge(source_id=INTERNAL_SOURCE_ID(executor_id), target_id=executor_id)
|
||||
super().__init__([edge])
|
||||
|
||||
@@ -8,7 +8,15 @@ from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from ..observability import EdgeGroupDeliveryStatus, OtelAttr, create_edge_group_processing_span
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeGroup, SwitchCaseEdgeGroup
|
||||
from ._edge import (
|
||||
Edge,
|
||||
EdgeGroup,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
InternalEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
)
|
||||
from ._executor import Executor
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
@@ -44,11 +52,11 @@ class EdgeRunner(ABC):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _can_handle(self, executor_id: str, message_data: Any) -> bool:
|
||||
def _can_handle(self, executor_id: str, message: Message) -> bool:
|
||||
"""Check if an executor can handle the given message data."""
|
||||
if executor_id not in self._executors:
|
||||
return False
|
||||
return self._executors[executor_id].can_handle(message_data)
|
||||
return self._executors[executor_id].can_handle(message)
|
||||
|
||||
async def _execute_on_target(
|
||||
self,
|
||||
@@ -66,7 +74,7 @@ class EdgeRunner(ABC):
|
||||
|
||||
# Execute with trace context parameters
|
||||
await target_executor.execute(
|
||||
message.data,
|
||||
message,
|
||||
source_ids, # source_executor_ids
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
@@ -78,15 +86,15 @@ class EdgeRunner(ABC):
|
||||
class SingleEdgeRunner(EdgeRunner):
|
||||
"""Runner for single edge groups."""
|
||||
|
||||
def __init__(self, edge_group: SingleEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
def __init__(self, edge_group: SingleEdgeGroup | InternalEdgeGroup, executors: dict[str, Executor]) -> None:
|
||||
super().__init__(edge_group, executors)
|
||||
self._edge = edge_group.edges[0]
|
||||
|
||||
async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool:
|
||||
"""Send a message through the single edge."""
|
||||
should_execute = False
|
||||
target_id = None
|
||||
source_id = None
|
||||
target_id: str | None = None
|
||||
source_id: str | None = None
|
||||
with create_edge_group_processing_span(
|
||||
self._edge_group.__class__.__name__,
|
||||
edge_group_id=self._edge_group.id,
|
||||
@@ -103,7 +111,7 @@ class SingleEdgeRunner(EdgeRunner):
|
||||
})
|
||||
return False
|
||||
|
||||
if self._can_handle(self._edge.target_id, message.data):
|
||||
if self._can_handle(self._edge.target_id, message):
|
||||
if self._edge.should_route(message.data):
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
@@ -183,7 +191,7 @@ class FanOutEdgeRunner(EdgeRunner):
|
||||
# If the target ID is specified and the selection result contains it, send the message to that edge
|
||||
if message.target_id in selection_results:
|
||||
edge = self._target_map.get(message.target_id)
|
||||
if edge and self._can_handle(edge.target_id, message.data):
|
||||
if edge and self._can_handle(edge.target_id, message):
|
||||
if edge.should_route(message.data):
|
||||
span.set_attributes({
|
||||
OtelAttr.EDGE_GROUP_DELIVERED: True,
|
||||
@@ -215,7 +223,7 @@ class FanOutEdgeRunner(EdgeRunner):
|
||||
# If no target ID, send the message to the selected targets
|
||||
for target_id in selection_results:
|
||||
edge = self._target_map[target_id]
|
||||
if self._can_handle(edge.target_id, message.data) and edge.should_route(message.data):
|
||||
if self._can_handle(edge.target_id, message) and edge.should_route(message.data):
|
||||
deliverable_edges.append(edge)
|
||||
|
||||
if len(deliverable_edges) > 0:
|
||||
@@ -291,7 +299,9 @@ class FanInEdgeRunner(EdgeRunner):
|
||||
return False
|
||||
|
||||
# Check if target can handle list of message data (fan-in aggregates multiple messages)
|
||||
if self._can_handle(self._edges[0].target_id, [message.data]):
|
||||
if self._can_handle(
|
||||
self._edges[0].target_id, Message(data=[message.data], source_id=message.source_id)
|
||||
):
|
||||
# If the edge can handle the data, buffer the message
|
||||
self._buffer[message.source_id].append(message)
|
||||
span.set_attributes({
|
||||
@@ -374,7 +384,7 @@ def create_edge_runner(edge_group: EdgeGroup, executors: dict[str, Executor]) ->
|
||||
Returns:
|
||||
The appropriate EdgeRunner instance.
|
||||
"""
|
||||
if isinstance(edge_group, SingleEdgeGroup):
|
||||
if isinstance(edge_group, (SingleEdgeGroup, InternalEdgeGroup)):
|
||||
return SingleEdgeRunner(edge_group, executors)
|
||||
if isinstance(edge_group, SwitchCaseEdgeGroup):
|
||||
return SwitchCaseEdgeRunner(edge_group, executors)
|
||||
|
||||
@@ -6,12 +6,12 @@ from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._request_info_executor import RequestInfoMessage
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._typing_utils import deserialize_type, serialize_type
|
||||
|
||||
|
||||
class WorkflowEventSource(str, Enum):
|
||||
@@ -211,7 +211,8 @@ class RequestInfoEvent(WorkflowEvent):
|
||||
request_id: str,
|
||||
source_executor_id: str,
|
||||
request_type: type,
|
||||
request_data: "RequestInfoMessage",
|
||||
request_data: Any,
|
||||
response_type: type,
|
||||
):
|
||||
"""Initialize the request info event.
|
||||
|
||||
@@ -220,11 +221,13 @@ class RequestInfoEvent(WorkflowEvent):
|
||||
source_executor_id: ID of the executor that made the request.
|
||||
request_type: Type of the request (e.g., a specific data type).
|
||||
request_data: The data associated with the request.
|
||||
response_type: Expected type of the response.
|
||||
"""
|
||||
super().__init__(request_data)
|
||||
self.request_id = request_id
|
||||
self.source_executor_id = source_executor_id
|
||||
self.request_type = request_type
|
||||
self.response_type = response_type
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the request info event."""
|
||||
@@ -233,7 +236,34 @@ class RequestInfoEvent(WorkflowEvent):
|
||||
f"request_id={self.request_id}, "
|
||||
f"source_executor_id={self.source_executor_id}, "
|
||||
f"request_type={self.request_type.__name__}, "
|
||||
f"data={self.data})"
|
||||
f"data={self.data}, "
|
||||
f"response_type={self.response_type.__name__})"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the request info event to a dictionary for serialization."""
|
||||
return {
|
||||
"data": encode_checkpoint_value(self.data),
|
||||
"request_id": self.request_id,
|
||||
"source_executor_id": self.source_executor_id,
|
||||
"request_type": serialize_type(self.request_type),
|
||||
"response_type": serialize_type(self.response_type),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "RequestInfoEvent":
|
||||
"""Create a RequestInfoEvent from a dictionary."""
|
||||
# Validation
|
||||
for property in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
|
||||
if property not in data:
|
||||
raise KeyError(f"Missing '{property}' field in RequestInfoEvent dictionary.")
|
||||
|
||||
return RequestInfoEvent(
|
||||
request_id=data["request_id"],
|
||||
source_executor_id=data["source_executor_id"],
|
||||
request_type=deserialize_type(data["request_type"]),
|
||||
request_data=decode_checkpoint_value(data["data"]),
|
||||
response_type=deserialize_type(data["response_type"]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,16 +16,17 @@ from ._events import (
|
||||
_framework_event_origin, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._model_utils import DictConvertible
|
||||
from ._runner_context import Message, RunnerContext # type: ignore
|
||||
from ._request_info_mixin import RequestInfoMixin
|
||||
from ._runner_context import Message, MessageType, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._workflow_context import WorkflowContext, validate_function_signature
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# region Executor
|
||||
class Executor(DictConvertible):
|
||||
class Executor(RequestInfoMixin, DictConvertible):
|
||||
"""Base class for all workflow executors that process messages and perform computations.
|
||||
|
||||
## Overview
|
||||
@@ -89,16 +90,16 @@ class Executor(DictConvertible):
|
||||
|
||||
class ParentExecutor(Executor):
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
async def handle_subworkflow_request(
|
||||
self,
|
||||
request: DomainRequest, # Subclass of RequestInfoMessage
|
||||
ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any] | DomainRequest],
|
||||
request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
if self.is_allowed(request.domain):
|
||||
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
response = request.create_response(data=True)
|
||||
await ctx.send_message(response, target_id=request.executor_id)
|
||||
else:
|
||||
await ctx.send_message(request) # Forward to external
|
||||
await ctx.request_info(request.source_event)
|
||||
|
||||
## Context Types
|
||||
Handler methods receive different WorkflowContext variants based on their type annotations:
|
||||
@@ -204,6 +205,9 @@ class Executor(DictConvertible):
|
||||
"Please define at least one handler using the @handler decorator."
|
||||
)
|
||||
|
||||
# Initialize RequestInfoMixin to discover response handlers
|
||||
self._discover_response_handlers()
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
message: Any,
|
||||
@@ -230,40 +234,25 @@ class Executor(DictConvertible):
|
||||
An awaitable that resolves to the result of the execution.
|
||||
"""
|
||||
# Create processing span for tracing (gracefully handles disabled tracing)
|
||||
|
||||
# Handle case where Message wrapper is passed instead of raw data
|
||||
if isinstance(message, Message):
|
||||
message = message.data
|
||||
|
||||
with create_processing_span(
|
||||
self.id,
|
||||
self.__class__.__name__,
|
||||
str(MessageType.STANDARD if not isinstance(message, Message) else message.type),
|
||||
type(message).__name__,
|
||||
source_trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
):
|
||||
# Find the handler and handler spec that matches the message type.
|
||||
handler: Callable[[Any, WorkflowContext[Any, Any]], Awaitable[None]] | None = None
|
||||
ctx_annotation = None
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message, message_type):
|
||||
handler = self._handlers[message_type]
|
||||
# Find the corresponding handler spec for context annotation
|
||||
for spec in self._handler_specs:
|
||||
if spec.get("message_type") == message_type:
|
||||
ctx_annotation = spec.get("ctx_annotation")
|
||||
break
|
||||
break
|
||||
|
||||
if handler is None:
|
||||
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
|
||||
handler = self._find_handler(message)
|
||||
if isinstance(message, Message):
|
||||
# Unwrap raw data for handler call
|
||||
message = message.data
|
||||
|
||||
# Create the appropriate WorkflowContext based on handler specs
|
||||
context = self._create_context_for_handler(
|
||||
source_executor_ids=source_executor_ids,
|
||||
shared_state=shared_state,
|
||||
runner_context=runner_context,
|
||||
ctx_annotation=ctx_annotation,
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
)
|
||||
@@ -289,7 +278,6 @@ class Executor(DictConvertible):
|
||||
source_executor_ids: list[str],
|
||||
shared_state: SharedState,
|
||||
runner_context: RunnerContext,
|
||||
ctx_annotation: Any,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
) -> WorkflowContext[Any]:
|
||||
@@ -299,7 +287,6 @@ class Executor(DictConvertible):
|
||||
source_executor_ids: The IDs of the source executors that sent messages to this executor.
|
||||
shared_state: The shared state for the workflow.
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
ctx_annotation: The context annotation from the handler spec to determine which context type to create.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking.
|
||||
|
||||
@@ -308,7 +295,7 @@ class Executor(DictConvertible):
|
||||
"""
|
||||
# Create WorkflowContext
|
||||
return WorkflowContext(
|
||||
executor_id=self.id,
|
||||
executor=self,
|
||||
source_executor_ids=source_executor_ids,
|
||||
shared_state=shared_state,
|
||||
runner_context=runner_context,
|
||||
@@ -328,8 +315,6 @@ class Executor(DictConvertible):
|
||||
message_type = handler_spec["message_type"]
|
||||
|
||||
# Keep full generic types for handler registration to avoid conflicts
|
||||
# Different RequestResponse[T, U] specializations are distinct handler types
|
||||
|
||||
if self._handlers.get(message_type) is not None:
|
||||
raise ValueError(f"Duplicate handler for type {message_type} in {self.__class__.__name__}")
|
||||
|
||||
@@ -350,7 +335,7 @@ class Executor(DictConvertible):
|
||||
# Skip attributes that may not be accessible
|
||||
continue
|
||||
|
||||
def can_handle(self, message: Any) -> bool:
|
||||
def can_handle(self, message: Message) -> bool:
|
||||
"""Check if the executor can handle a given message type.
|
||||
|
||||
Args:
|
||||
@@ -359,7 +344,10 @@ class Executor(DictConvertible):
|
||||
Returns:
|
||||
True if the executor can handle the message type, False otherwise.
|
||||
"""
|
||||
return any(is_instance_of(message, message_type) for message_type in self._handlers)
|
||||
if message.type == MessageType.RESPONSE:
|
||||
return any(is_instance_of(message.data, message_type) for message_type in self._response_handlers)
|
||||
|
||||
return any(is_instance_of(message.data, message_type) for message_type in self._handlers)
|
||||
|
||||
def _register_instance_handler(
|
||||
self,
|
||||
@@ -412,7 +400,7 @@ class Executor(DictConvertible):
|
||||
output_types: set[type[Any]] = set()
|
||||
|
||||
# Collect output types from all handlers
|
||||
for handler_spec in self._handler_specs:
|
||||
for handler_spec in self._handler_specs + self._response_handler_specs:
|
||||
handler_output_types = handler_spec.get("output_types", [])
|
||||
output_types.update(handler_output_types)
|
||||
|
||||
@@ -438,6 +426,40 @@ class Executor(DictConvertible):
|
||||
"""Serialize executor definition for workflow topology export."""
|
||||
return {"id": self.id, "type": self.type}
|
||||
|
||||
def _find_handler(self, message: Any) -> Callable[[Any, WorkflowContext[Any, Any]], Awaitable[None]]:
|
||||
"""Find the handler for a given message.
|
||||
|
||||
Args:
|
||||
message: The message to find the handler for.
|
||||
|
||||
Returns:
|
||||
The handler function if found, None otherwise
|
||||
"""
|
||||
if isinstance(message, Message):
|
||||
# Case where Message wrapper is passed instead of raw data
|
||||
# Handler can be a standard handler or a response handler
|
||||
if message.type == MessageType.STANDARD:
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message.data, message_type):
|
||||
return self._handlers[message_type]
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} cannot handle message of type {type(message.data)}."
|
||||
)
|
||||
# Response message case - find response handler based on original request and response types
|
||||
handler = self._find_response_handler(message.original_request, message.data)
|
||||
if not handler:
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} cannot handle request of type "
|
||||
f"{type(message.original_request)} and response of type {type(message.data)}."
|
||||
)
|
||||
return handler
|
||||
|
||||
# Standard raw message data case - only standard handlers apply
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message, message_type):
|
||||
return self._handlers[message_type]
|
||||
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
|
||||
|
||||
|
||||
# endregion: Executor
|
||||
|
||||
@@ -474,7 +496,7 @@ def handler(
|
||||
) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]:
|
||||
# Extract the message type and validate using unified validation
|
||||
message_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
validate_function_signature(func, "Handler method")
|
||||
_validate_handler_signature(func)
|
||||
)
|
||||
|
||||
# Get signature for preservation
|
||||
@@ -504,3 +526,45 @@ def handler(
|
||||
|
||||
|
||||
# endregion: Handler Decorator
|
||||
|
||||
# region Handler Validation
|
||||
|
||||
|
||||
def _validate_handler_signature(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
expected_counts = 3 # self, message, ctx
|
||||
param_description = "(self, message: T, ctx: WorkflowContext[U, V])"
|
||||
if len(params) != expected_counts:
|
||||
raise ValueError(f"Handler {func.__name__} must have {param_description}. Got {len(params)} parameters.")
|
||||
|
||||
# Check message parameter has type annotation
|
||||
message_param = params[1]
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args
|
||||
ctx_param = params[2]
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler"
|
||||
)
|
||||
|
||||
message_type = message_param.annotation
|
||||
ctx_annotation = ctx_param.annotation
|
||||
|
||||
return message_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
# endregion: Handler Validation
|
||||
|
||||
@@ -16,11 +16,12 @@ Design Pattern:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, overload
|
||||
|
||||
from ._executor import Executor
|
||||
from ._workflow_context import WorkflowContext, validate_function_signature
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
|
||||
class FunctionExecutor(Executor):
|
||||
@@ -33,21 +34,6 @@ class FunctionExecutor(Executor):
|
||||
blocking the event loop.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _validate_function(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
|
||||
"""Validate that the function has the correct signature for an executor.
|
||||
|
||||
Args:
|
||||
func: The function to validate (can be sync or async)
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is incorrect
|
||||
"""
|
||||
return validate_function_signature(func, "Function")
|
||||
|
||||
def __init__(self, func: Callable[..., Any], id: str | None = None):
|
||||
"""Initialize the FunctionExecutor with a user-defined function.
|
||||
|
||||
@@ -68,13 +54,14 @@ class FunctionExecutor(Executor):
|
||||
)
|
||||
|
||||
# Validate function signature and extract types
|
||||
message_type, ctx_annotation, output_types, workflow_output_types = self._validate_function(func)
|
||||
message_type, ctx_annotation, output_types, workflow_output_types = _validate_function_signature(func)
|
||||
|
||||
# Store the original function
|
||||
self._original_func = func
|
||||
# Determine if function has WorkflowContext parameter
|
||||
has_context = ctx_annotation is not None
|
||||
|
||||
# Check if function is async
|
||||
is_async = asyncio.iscoroutinefunction(func)
|
||||
self._has_context = ctx_annotation is not None
|
||||
# Determine if the function is an async function
|
||||
self._is_async = asyncio.iscoroutinefunction(func)
|
||||
|
||||
# Initialize parent WITHOUT calling _discover_handlers yet
|
||||
# We'll manually set up the attributes first
|
||||
@@ -82,25 +69,18 @@ class FunctionExecutor(Executor):
|
||||
kwargs = {"type": "FunctionExecutor"}
|
||||
|
||||
super().__init__(id=executor_id, defer_discovery=True, **kwargs)
|
||||
self._handlers = {}
|
||||
self._handler_specs = []
|
||||
|
||||
# Store the original function and whether it has context
|
||||
self._original_func = func
|
||||
self._has_context = has_context
|
||||
self._is_async = is_async
|
||||
|
||||
# Create a wrapper function that always accepts both message and context
|
||||
if has_context and is_async:
|
||||
if self._has_context and self._is_async:
|
||||
# Async function with context - already has the right signature
|
||||
wrapped_func: Callable[[Any, WorkflowContext[Any]], Awaitable[Any]] = func # type: ignore
|
||||
elif has_context and not is_async:
|
||||
elif self._has_context and not self._is_async:
|
||||
# Sync function with context - wrap to make async using thread pool
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the sync function with both parameters in a thread
|
||||
return await asyncio.to_thread(func, message, ctx) # type: ignore
|
||||
|
||||
elif not has_context and is_async:
|
||||
elif not self._has_context and self._is_async:
|
||||
# Async function without context - wrap to ignore context
|
||||
async def wrapped_func(message: Any, ctx: WorkflowContext[Any]) -> Any:
|
||||
# Call the async function with just the message
|
||||
@@ -124,6 +104,7 @@ class FunctionExecutor(Executor):
|
||||
|
||||
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
|
||||
self._discover_handlers()
|
||||
self._discover_response_handlers()
|
||||
|
||||
if not self._handlers:
|
||||
raise ValueError(
|
||||
@@ -131,6 +112,9 @@ class FunctionExecutor(Executor):
|
||||
)
|
||||
|
||||
|
||||
# region Decorator
|
||||
|
||||
|
||||
@overload
|
||||
def executor(func: Callable[..., Any]) -> FunctionExecutor: ...
|
||||
|
||||
@@ -200,3 +184,55 @@ def executor(
|
||||
|
||||
# Otherwise, return the wrapper for @executor() or @executor(id="...")
|
||||
return wrapper
|
||||
|
||||
|
||||
# endregion: Decorator
|
||||
|
||||
# region Function Validation
|
||||
|
||||
|
||||
def _validate_function_signature(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
expected_counts = (1, 2) # Function executor: (message) or (message, ctx)
|
||||
param_description = "(message: T) or (message: T, ctx: WorkflowContext[U])"
|
||||
if len(params) not in expected_counts:
|
||||
raise ValueError(
|
||||
f"Function instance {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Check message parameter has type annotation
|
||||
message_param = params[0]
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function instance {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
message_type = message_param.annotation
|
||||
|
||||
# Check if there's a context parameter
|
||||
if len(params) == 2:
|
||||
ctx_param = params[1]
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Function instance"
|
||||
)
|
||||
ctx_annotation = ctx_param.annotation
|
||||
else:
|
||||
# No context parameter (only valid for function executors)
|
||||
output_types, workflow_output_types = [], []
|
||||
ctx_annotation = None
|
||||
|
||||
return message_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
# endregion: Function Validation
|
||||
|
||||
@@ -45,7 +45,7 @@ from ._group_chat import (
|
||||
)
|
||||
from ._orchestrator_helpers import clean_conversation_for_handoff
|
||||
from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier
|
||||
from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -109,12 +109,13 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
|
||||
|
||||
|
||||
@dataclass
|
||||
class HandoffUserInputRequest(RequestInfoMessage):
|
||||
class HandoffUserInputRequest:
|
||||
"""Request message emitted when the workflow needs fresh user input."""
|
||||
|
||||
conversation: list[ChatMessage] = field(default_factory=lambda: []) # type: ignore[misc]
|
||||
awaiting_agent_id: str | None = None
|
||||
prompt: str | None = None
|
||||
conversation: list[ChatMessage]
|
||||
awaiting_agent_id: str
|
||||
prompt: str
|
||||
source_executor_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -478,28 +479,22 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
|
||||
|
||||
|
||||
class _UserInputGateway(Executor):
|
||||
"""Bridges conversation context with RequestInfoExecutor and re-enters the loop."""
|
||||
"""Bridges conversation context with the request & response cycle and re-enters the loop."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_executor_id: str,
|
||||
starting_agent_id: str,
|
||||
prompt: str | None,
|
||||
id: str,
|
||||
) -> None:
|
||||
"""Initialise the gateway that requests user input and forwards responses."""
|
||||
super().__init__(id)
|
||||
self._request_executor_id = request_executor_id
|
||||
self._starting_agent_id = starting_agent_id
|
||||
self._prompt = prompt or "Provide your next input for the conversation."
|
||||
|
||||
@handler
|
||||
async def request_input(
|
||||
self,
|
||||
conversation: list[ChatMessage],
|
||||
ctx: WorkflowContext[HandoffUserInputRequest],
|
||||
) -> None:
|
||||
async def request_input(self, conversation: list[ChatMessage], ctx: WorkflowContext) -> None:
|
||||
"""Emit a `HandoffUserInputRequest` capturing the conversation snapshot."""
|
||||
if not conversation:
|
||||
raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.")
|
||||
@@ -507,27 +502,26 @@ class _UserInputGateway(Executor):
|
||||
conversation=list(conversation),
|
||||
awaiting_agent_id=self._starting_agent_id,
|
||||
prompt=self._prompt,
|
||||
source_executor_id=self.id,
|
||||
)
|
||||
request.source_executor_id = self.id
|
||||
await ctx.send_message(request, target_id=self._request_executor_id)
|
||||
await ctx.request_info(request, HandoffUserInputRequest, object)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def resume_from_user(
|
||||
self,
|
||||
response: RequestResponse[HandoffUserInputRequest, Any],
|
||||
original_request: HandoffUserInputRequest,
|
||||
response: object,
|
||||
ctx: WorkflowContext[_ConversationWithUserInput],
|
||||
) -> None:
|
||||
"""Convert user input responses back into chat messages and resume the workflow."""
|
||||
# Reconstruct full conversation with new user input
|
||||
conversation = list(response.original_request.conversation)
|
||||
user_messages = _as_user_messages(response.data)
|
||||
conversation = list(original_request.conversation)
|
||||
user_messages = _as_user_messages(response)
|
||||
conversation.extend(user_messages)
|
||||
|
||||
# Send full conversation back to coordinator (not trimmed)
|
||||
# Coordinator will update its authoritative history and trim for agent
|
||||
message = _ConversationWithUserInput(full_conversation=conversation)
|
||||
# CRITICAL: Must specify target to avoid broadcasting to all connected executors
|
||||
# Gateway is connected to both request_info and coordinator, we want coordinator only
|
||||
await ctx.send_message(message, target_id="handoff-coordinator")
|
||||
|
||||
|
||||
@@ -1303,9 +1297,7 @@ class HandoffBuilder:
|
||||
}
|
||||
|
||||
input_node = _InputToConversation(id="input-conversation")
|
||||
request_info = RequestInfoExecutor(id=f"{starting_executor.id}_handoff_requests")
|
||||
user_gateway = _UserInputGateway(
|
||||
request_executor_id=request_info.id,
|
||||
starting_agent_id=starting_executor.id,
|
||||
prompt=self._request_prompt,
|
||||
id="handoff-user-input",
|
||||
@@ -1348,8 +1340,6 @@ class HandoffBuilder:
|
||||
builder = builder.set_start_executor(input_node)
|
||||
builder = builder.add_edge(input_node, starting_executor)
|
||||
builder = builder.add_edge(coordinator, user_gateway)
|
||||
builder = builder.add_edge(user_gateway, request_info)
|
||||
builder = builder.add_edge(request_info, user_gateway)
|
||||
builder = builder.add_edge(user_gateway, coordinator)
|
||||
|
||||
return builder.build()
|
||||
|
||||
@@ -40,7 +40,7 @@ from ._group_chat import (
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._model_utils import DictConvertible, encode_value
|
||||
from ._participant_utils import GroupChatParticipantSpec, participant_description
|
||||
from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._workflow import Workflow, WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
@@ -421,11 +421,10 @@ class _MagenticResponseMessage(_GroupChatResponseMessage):
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MagenticPlanReviewRequest(RequestInfoMessage):
|
||||
class _MagenticPlanReviewRequest:
|
||||
"""Internal: Human-in-the-loop request to review and optionally edit the plan before execution."""
|
||||
|
||||
# Because RequestInfoMessage defines a default field (request_id),
|
||||
# subclass fields must also have defaults to satisfy dataclass rules.
|
||||
request_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
task_text: str = ""
|
||||
facts_text: str = ""
|
||||
plan_text: str = ""
|
||||
@@ -1214,7 +1213,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
# If a human must sign off, ask now and return. The response handler will resume.
|
||||
if self._require_plan_signoff:
|
||||
await self._send_plan_review_request(context)
|
||||
await self._send_plan_review_request(cast(WorkflowContext, context))
|
||||
return
|
||||
|
||||
# Add task ledger to conversation history
|
||||
@@ -1290,10 +1289,11 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
# Continue with inner loop
|
||||
await self._run_inner_loop(context)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def handle_plan_review_response(
|
||||
self,
|
||||
response: RequestResponse[_MagenticPlanReviewRequest, _MagenticPlanReviewReply],
|
||||
original_request: _MagenticPlanReviewRequest,
|
||||
response: _MagenticPlanReviewReply,
|
||||
context: WorkflowContext[
|
||||
# may broadcast ledger next, or ask for another round of review
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
@@ -1305,26 +1305,21 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
if self._context is None:
|
||||
return
|
||||
|
||||
human = response.data
|
||||
if human is None: # type: ignore[unreachable]
|
||||
# Defensive fallback: treat as revise with empty comments
|
||||
human = _MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.REVISE, comments="")
|
||||
|
||||
if human.decision == MagenticPlanReviewDecision.APPROVE:
|
||||
if response.decision == MagenticPlanReviewDecision.APPROVE:
|
||||
# Close the review loop on approval (no further plan review requests this run)
|
||||
self._require_plan_signoff = False
|
||||
# If the user supplied an edited plan, adopt it
|
||||
if human.edited_plan_text:
|
||||
if response.edited_plan_text:
|
||||
# Update the manager's internal ledger and rebuild the combined message
|
||||
mgr_ledger = getattr(self._manager, "task_ledger", None)
|
||||
if mgr_ledger is not None:
|
||||
mgr_ledger.plan.text = human.edited_plan_text
|
||||
mgr_ledger.plan.text = response.edited_plan_text
|
||||
team_text = _team_block(self._participants)
|
||||
combined = self._manager.task_ledger_full_prompt.format(
|
||||
task=self._context.task.text,
|
||||
team=team_text,
|
||||
facts=(mgr_ledger.facts.text if mgr_ledger else ""),
|
||||
plan=human.edited_plan_text,
|
||||
plan=response.edited_plan_text,
|
||||
)
|
||||
self._task_ledger = ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
@@ -1332,10 +1327,10 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
author_name=MAGENTIC_MANAGER_NAME,
|
||||
)
|
||||
# If approved with comments but no edited text, apply comments via replan and proceed (no extra review)
|
||||
elif human.comments:
|
||||
elif response.comments:
|
||||
# Record the human feedback for grounding
|
||||
self._context.chat_history.append(
|
||||
ChatMessage(role=Role.USER, text=f"Human plan feedback: {human.comments}")
|
||||
ChatMessage(role=Role.USER, text=f"Human plan feedback: {response.comments}")
|
||||
)
|
||||
# Ask the manager to replan based on comments; proceed immediately
|
||||
self._task_ledger = await self._manager.replan(self._context.clone(deep=True))
|
||||
@@ -1381,31 +1376,31 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
return
|
||||
|
||||
# If the user provided an edited plan, adopt it directly and ask them to confirm once more
|
||||
if human.edited_plan_text:
|
||||
if response.edited_plan_text:
|
||||
mgr_ledger2 = getattr(self._manager, "task_ledger", None)
|
||||
if mgr_ledger2 is not None:
|
||||
mgr_ledger2.plan.text = human.edited_plan_text
|
||||
mgr_ledger2.plan.text = response.edited_plan_text
|
||||
# Rebuild combined message for preview in the next review request
|
||||
team_text = _team_block(self._participants)
|
||||
combined = self._manager.task_ledger_full_prompt.format(
|
||||
task=self._context.task.text,
|
||||
team=team_text,
|
||||
facts=(mgr_ledger2.facts.text if mgr_ledger2 else ""),
|
||||
plan=human.edited_plan_text,
|
||||
plan=response.edited_plan_text,
|
||||
)
|
||||
self._task_ledger = ChatMessage(role=Role.ASSISTANT, text=combined, author_name=MAGENTIC_MANAGER_NAME)
|
||||
await self._send_plan_review_request(context)
|
||||
await self._send_plan_review_request(cast(WorkflowContext, context))
|
||||
return
|
||||
|
||||
# Else pass comments into the chat history and replan with the manager
|
||||
if human.comments:
|
||||
if response.comments:
|
||||
self._context.chat_history.append(
|
||||
ChatMessage(role=Role.USER, text=f"Human plan feedback: {human.comments}")
|
||||
ChatMessage(role=Role.USER, text=f"Human plan feedback: {response.comments}")
|
||||
)
|
||||
|
||||
# Ask the manager to replan; this only adjusts the plan stage, not a full reset
|
||||
self._task_ledger = await self._manager.replan(self._context.clone(deep=True))
|
||||
await self._send_plan_review_request(context)
|
||||
await self._send_plan_review_request(cast(WorkflowContext, context))
|
||||
|
||||
async def _run_outer_loop(
|
||||
self,
|
||||
@@ -1601,13 +1596,8 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
|
||||
return True
|
||||
|
||||
async def _send_plan_review_request(
|
||||
self,
|
||||
context: WorkflowContext[
|
||||
_MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage
|
||||
],
|
||||
) -> None:
|
||||
"""Emit a PlanReviewRequest via RequestInfoExecutor."""
|
||||
async def _send_plan_review_request(self, context: WorkflowContext) -> None:
|
||||
"""Send a PlanReviewRequest."""
|
||||
# If plan sign-off is disabled (e.g., ran out of review rounds), do nothing
|
||||
if not self._require_plan_signoff:
|
||||
return
|
||||
@@ -1622,7 +1612,7 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
|
||||
plan_text=plan_text,
|
||||
round_index=self._plan_review_round,
|
||||
)
|
||||
await context.send_message(req)
|
||||
await context.request_info(req, _MagenticPlanReviewRequest, _MagenticPlanReviewReply)
|
||||
|
||||
|
||||
# region Magentic Executors
|
||||
@@ -2271,12 +2261,6 @@ class MagenticBuilder:
|
||||
if self._checkpoint_storage is not None:
|
||||
group_builder = group_builder.with_checkpointing(self._checkpoint_storage)
|
||||
|
||||
if self._enable_plan_review:
|
||||
group_builder = group_builder.with_request_handler(
|
||||
lambda _wiring: RequestInfoExecutor(id="magentic_plan_review"),
|
||||
condition=lambda msg: isinstance(msg, _MagenticPlanReviewRequest),
|
||||
)
|
||||
|
||||
return group_builder.build()
|
||||
|
||||
def start_with_string(self, task: str) -> "MagenticWorkflow":
|
||||
@@ -2462,11 +2446,10 @@ class MagenticWorkflow:
|
||||
self,
|
||||
checkpoint_id: str,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
responses: dict[str, Any] | None = None,
|
||||
) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Resume orchestration from a checkpoint and stream resulting events."""
|
||||
await self._validate_checkpoint_participants(checkpoint_id, checkpoint_storage)
|
||||
async for event in self._workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage, responses):
|
||||
async for event in self._workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
|
||||
yield event
|
||||
|
||||
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
|
||||
@@ -2516,11 +2499,10 @@ class MagenticWorkflow:
|
||||
self,
|
||||
checkpoint_id: str,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
responses: dict[str, Any] | None = None,
|
||||
) -> WorkflowRunResult:
|
||||
"""Resume orchestration from a checkpoint and collect all resulting events."""
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage, responses):
|
||||
async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
|
||||
events.append(event)
|
||||
return WorkflowRunResult(events)
|
||||
|
||||
|
||||
@@ -1,573 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
||||
from typing import Any, ClassVar, Generic, TypeVar, cast
|
||||
|
||||
from ._events import (
|
||||
RequestInfoEvent, # type: ignore[reportPrivateUsage]
|
||||
)
|
||||
from ._executor import Executor, handler
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingRequestDetails:
|
||||
"""Lightweight information about a pending request captured in a checkpoint."""
|
||||
|
||||
request_id: str
|
||||
prompt: str | None = None
|
||||
draft: str | None = None
|
||||
iteration: int | None = None
|
||||
source_executor_id: str | None = None
|
||||
original_request: "RequestInfoMessage | dict[str, Any] | None" = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingRequestSnapshot:
|
||||
"""Snapshot of a pending request for internal tracking.
|
||||
|
||||
This snapshot should be JSON-serializable and contain enough
|
||||
information to reconstruct the original request if needed.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
source_executor_id: str
|
||||
request_type: str
|
||||
request_as_json_safe_dict: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestInfoMessage:
|
||||
"""Base class for all request messages in workflows.
|
||||
|
||||
Any message that should be routed to the RequestInfoExecutor for external
|
||||
handling must inherit from this class. This ensures type safety and makes
|
||||
the request/response pattern explicit.
|
||||
"""
|
||||
|
||||
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""Unique identifier for correlating requests and responses."""
|
||||
|
||||
source_executor_id: str | None = None
|
||||
"""ID of the executor expecting a response to this request.
|
||||
May differ from the executor that sent the request if intercepted and forwarded."""
|
||||
|
||||
|
||||
TRequest = TypeVar("TRequest", bound="RequestInfoMessage")
|
||||
TResponse = TypeVar("TResponse")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestResponse(Generic[TRequest, TResponse]):
|
||||
"""Response type for request/response correlation in workflows.
|
||||
|
||||
This type is used by RequestInfoExecutor to create correlated responses
|
||||
that include the original request context for proper message routing.
|
||||
"""
|
||||
|
||||
data: TResponse
|
||||
"""The response data returned from handling the request."""
|
||||
|
||||
original_request: TRequest
|
||||
"""The original request that this response corresponds to."""
|
||||
|
||||
request_id: str
|
||||
"""The ID of the original request."""
|
||||
|
||||
|
||||
# endregion: Request/Response Types
|
||||
|
||||
|
||||
# region Request Info Executor
|
||||
class RequestInfoExecutor(Executor):
|
||||
"""Built-in executor that handles request/response patterns in workflows.
|
||||
|
||||
This executor acts as a gateway for external information requests. When it receives
|
||||
a request message, it saves the request details and emits a RequestInfoEvent. When
|
||||
a response is provided externally, it emits the response as a message.
|
||||
"""
|
||||
|
||||
_PENDING_SHARED_STATE_KEY: ClassVar[str] = "_af_pending_request_info"
|
||||
|
||||
def __init__(self, id: str):
|
||||
"""Initialize the RequestInfoExecutor with a unique ID.
|
||||
|
||||
Args:
|
||||
id: Unique ID for this RequestInfoExecutor.
|
||||
"""
|
||||
super().__init__(id=id)
|
||||
self._request_events: dict[str, RequestInfoEvent] = {}
|
||||
|
||||
# region Public Methods
|
||||
|
||||
@handler
|
||||
async def handle_request(self, message: RequestInfoMessage, ctx: WorkflowContext) -> None:
|
||||
"""Run the RequestInfoExecutor with the given message."""
|
||||
# Use source_executor_id from message if available, otherwise fall back to context
|
||||
source_executor_id = message.source_executor_id or ctx.get_source_executor_id()
|
||||
|
||||
event = RequestInfoEvent(
|
||||
request_id=message.request_id,
|
||||
source_executor_id=source_executor_id,
|
||||
request_type=type(message),
|
||||
request_data=message,
|
||||
)
|
||||
self._request_events[message.request_id] = event
|
||||
await self._record_pending_request(message, source_executor_id, ctx)
|
||||
await ctx.add_event(event)
|
||||
|
||||
async def handle_response(
|
||||
self,
|
||||
response_data: Any,
|
||||
request_id: str,
|
||||
ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any]],
|
||||
) -> None:
|
||||
"""Handle a response to a request.
|
||||
|
||||
Args:
|
||||
request_id: The ID of the request to which this response corresponds.
|
||||
response_data: The data returned in the response.
|
||||
ctx: The workflow context for sending the response.
|
||||
"""
|
||||
event = self._request_events.get(request_id)
|
||||
if event is None:
|
||||
event = await self._rehydrate_request_event(request_id, cast(WorkflowContext, ctx))
|
||||
if event is None:
|
||||
raise ValueError(f"No request found with ID: {request_id}")
|
||||
|
||||
self._request_events.pop(request_id, None)
|
||||
|
||||
# Create a correlated response that includes both the response data and original request
|
||||
if not isinstance(event.data, RequestInfoMessage):
|
||||
raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}")
|
||||
correlated_response = RequestResponse(data=response_data, original_request=event.data, request_id=request_id)
|
||||
await ctx.send_message(correlated_response, target_id=event.source_executor_id)
|
||||
|
||||
await self._erase_pending_request(request_id, cast(WorkflowContext, ctx))
|
||||
|
||||
def snapshot_state(self) -> dict[str, Any]:
|
||||
"""Serialize pending requests so checkpoint restoration can resume seamlessly."""
|
||||
|
||||
def _encode_event(event: RequestInfoEvent) -> dict[str, Any] | None:
|
||||
if event.data is None or not isinstance(event.data, RequestInfoMessage):
|
||||
logger.warning(
|
||||
f"RequestInfoExecutor {self.id} encountered invalid event data for request ID {event.request_id}: "
|
||||
f"{type(event.data).__name__}. This request will be skipped in the checkpoint."
|
||||
)
|
||||
return None
|
||||
|
||||
payload = self._encode_request_payload(event.data, event.data.__class__)
|
||||
|
||||
return {
|
||||
"source_executor_id": event.source_executor_id,
|
||||
"request_type": f"{event.request_type.__module__}:{event.request_type.__qualname__}",
|
||||
"request_data": payload,
|
||||
}
|
||||
|
||||
return {
|
||||
"request_events": {
|
||||
rid: encoded
|
||||
for rid, event in self._request_events.items()
|
||||
if (encoded := _encode_event(event)) is not None
|
||||
},
|
||||
}
|
||||
|
||||
def restore_state(self, state: dict[str, Any]) -> None:
|
||||
"""Restore pending request bookkeeping from checkpoint state."""
|
||||
self._request_events.clear()
|
||||
stored_events = state.get("request_events", {})
|
||||
|
||||
for request_id, payload in stored_events.items():
|
||||
request_type_qual = payload.get("request_type", "")
|
||||
try:
|
||||
request_type = _import_qualname(request_type_qual)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug(
|
||||
"RequestInfoExecutor %s failed to import %s during restore: %s",
|
||||
self.id,
|
||||
request_type_qual,
|
||||
exc,
|
||||
)
|
||||
request_type = RequestInfoMessage
|
||||
request_data_meta = payload.get("request_data", {})
|
||||
request_data = self._decode_request_data(request_data_meta)
|
||||
event = RequestInfoEvent(
|
||||
request_id=request_id,
|
||||
source_executor_id=payload.get("source_executor_id", ""),
|
||||
request_type=request_type,
|
||||
request_data=request_data,
|
||||
)
|
||||
self._request_events[request_id] = event
|
||||
|
||||
async def has_pending_request(self, request_id: str, ctx: WorkflowContext) -> bool:
|
||||
"""Check if there is a pending request with the given ID.
|
||||
|
||||
Args:
|
||||
request_id: The ID of the request to check.
|
||||
ctx: The workflow context for accessing state if needed.
|
||||
|
||||
Returns: True if the request is pending, False otherwise.
|
||||
"""
|
||||
if request_id in self._request_events:
|
||||
return True
|
||||
|
||||
pending_requests = await self._retrieve_existing_pending_requests(ctx)
|
||||
return request_id in pending_requests
|
||||
|
||||
# endregion: Public Methods
|
||||
|
||||
# region: Internal Methods
|
||||
|
||||
async def _record_pending_request(
|
||||
self,
|
||||
message: RequestInfoMessage,
|
||||
source_executor_id: str,
|
||||
ctx: WorkflowContext,
|
||||
) -> None:
|
||||
"""Record a pending request to the executor's state for checkpointing purposes."""
|
||||
pending_request_snapshot = self._build_pending_request_snapshot(message, source_executor_id)
|
||||
|
||||
existing_pending_requests = await self._retrieve_existing_pending_requests(ctx)
|
||||
existing_pending_requests[message.request_id] = pending_request_snapshot
|
||||
|
||||
await self._persist_to_executor_state(existing_pending_requests, ctx)
|
||||
|
||||
async def _erase_pending_request(self, request_id: str, ctx: WorkflowContext) -> None:
|
||||
"""Erase a pending request from the executor's state after it has been handled for checkpointing purposes."""
|
||||
existing_pending_requests = await self._retrieve_existing_pending_requests(ctx)
|
||||
if request_id in existing_pending_requests:
|
||||
existing_pending_requests.pop(request_id)
|
||||
await self._persist_to_executor_state(existing_pending_requests, ctx)
|
||||
|
||||
async def _retrieve_existing_pending_requests(self, ctx: WorkflowContext) -> dict[str, PendingRequestSnapshot]:
|
||||
"""Retrieve existing pending requests from executor state."""
|
||||
executor_state = await ctx.get_executor_state()
|
||||
if executor_state is None:
|
||||
return {}
|
||||
|
||||
stored_requests = executor_state.get(self._PENDING_SHARED_STATE_KEY, {})
|
||||
if not isinstance(stored_requests, dict):
|
||||
raise TypeError(f"Unexpected type for pending requests: {type(stored_requests).__name__}")
|
||||
|
||||
# Validate contents
|
||||
for key, value in stored_requests.items(): # type: ignore
|
||||
if not isinstance(key, str) or not isinstance(value, PendingRequestSnapshot):
|
||||
raise TypeError(
|
||||
"Invalid pending request entry in executor state. "
|
||||
"Key must be `str` and value must be `PendingRequestSnapshot`."
|
||||
)
|
||||
|
||||
return stored_requests # type: ignore
|
||||
|
||||
async def _persist_to_executor_state(
|
||||
self, pending: dict[str, PendingRequestSnapshot], ctx: WorkflowContext
|
||||
) -> None:
|
||||
"""Persist the current pending requests to the executor's state."""
|
||||
executor_state = await ctx.get_executor_state() or {}
|
||||
executor_state[self._PENDING_SHARED_STATE_KEY] = pending
|
||||
await ctx.set_executor_state(executor_state)
|
||||
|
||||
def _build_pending_request_snapshot(
|
||||
self, request: RequestInfoMessage, source_executor_id: str
|
||||
) -> PendingRequestSnapshot:
|
||||
"""Build a snapshot of the pending request for checkpointing."""
|
||||
request_as_json_safe_dict = self._convert_request_to_json_safe_dict(request)
|
||||
|
||||
return PendingRequestSnapshot(
|
||||
request_id=request.request_id,
|
||||
source_executor_id=source_executor_id,
|
||||
request_type=f"{type(request).__module__}:{type(request).__name__}",
|
||||
request_as_json_safe_dict=request_as_json_safe_dict,
|
||||
)
|
||||
|
||||
def _encode_request_payload(self, request_data: RequestInfoMessage, data_cls: type[Any]) -> dict[str, Any]:
|
||||
if is_dataclass(request_data) and not isinstance(request_data, type):
|
||||
dataclass_instance = cast(Any, request_data)
|
||||
safe_value = _make_json_safe(asdict(dataclass_instance))
|
||||
return {
|
||||
"kind": "dataclass",
|
||||
"type": f"{data_cls.__module__}:{data_cls.__qualname__}",
|
||||
"value": safe_value,
|
||||
}
|
||||
|
||||
to_dict_fn = getattr(request_data, "to_dict", None)
|
||||
if callable(to_dict_fn):
|
||||
try:
|
||||
dumped = to_dict_fn()
|
||||
except TypeError:
|
||||
dumped = to_dict_fn()
|
||||
safe_value = _make_json_safe(dumped)
|
||||
return {
|
||||
"kind": "dict",
|
||||
"type": f"{data_cls.__module__}:{data_cls.__qualname__}",
|
||||
"value": safe_value,
|
||||
}
|
||||
|
||||
to_json_fn = getattr(request_data, "to_json", None)
|
||||
if callable(to_json_fn):
|
||||
try:
|
||||
dumped = to_json_fn()
|
||||
except TypeError:
|
||||
dumped = to_json_fn()
|
||||
converted = dumped
|
||||
if isinstance(dumped, (str, bytes, bytearray)):
|
||||
decoded: str | bytes | bytearray
|
||||
if isinstance(dumped, (bytes, bytearray)):
|
||||
try:
|
||||
decoded = dumped.decode()
|
||||
except Exception:
|
||||
decoded = dumped
|
||||
else:
|
||||
decoded = dumped
|
||||
try:
|
||||
converted = json.loads(decoded)
|
||||
except Exception:
|
||||
converted = decoded
|
||||
safe_value = _make_json_safe(converted)
|
||||
return {
|
||||
"kind": "dict" if isinstance(converted, dict) else "json",
|
||||
"type": f"{data_cls.__module__}:{data_cls.__qualname__}",
|
||||
"value": safe_value,
|
||||
}
|
||||
|
||||
return {
|
||||
"kind": "raw",
|
||||
"type": f"{data_cls.__module__}:{data_cls.__qualname__}",
|
||||
"value": self._convert_request_to_json_safe_dict(request_data),
|
||||
}
|
||||
|
||||
def _decode_request_data(self, metadata: dict[str, Any]) -> RequestInfoMessage:
|
||||
kind = metadata.get("kind")
|
||||
type_name = metadata.get("type", "")
|
||||
value: Any = metadata.get("value", {})
|
||||
if type_name:
|
||||
try:
|
||||
imported = _import_qualname(type_name)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug(
|
||||
"RequestInfoExecutor %s failed to import %s during decode: %s",
|
||||
self.id,
|
||||
type_name,
|
||||
exc,
|
||||
)
|
||||
imported = RequestInfoMessage
|
||||
else:
|
||||
imported = RequestInfoMessage
|
||||
target_cls: type[RequestInfoMessage]
|
||||
if isinstance(imported, type) and issubclass(imported, RequestInfoMessage):
|
||||
target_cls = imported
|
||||
else:
|
||||
target_cls = RequestInfoMessage
|
||||
|
||||
if kind == "dataclass" and isinstance(value, dict):
|
||||
with contextlib.suppress(TypeError):
|
||||
return target_cls(**value) # type: ignore[arg-type]
|
||||
|
||||
# Backwards-compat handling for checkpoints that used to store pydantic as "dict"
|
||||
if kind in {"dict", "pydantic", "json"} and isinstance(value, dict):
|
||||
from_dict = getattr(target_cls, "from_dict", None)
|
||||
if callable(from_dict):
|
||||
with contextlib.suppress(Exception):
|
||||
return cast(RequestInfoMessage, from_dict(value))
|
||||
|
||||
if kind == "json" and isinstance(value, str):
|
||||
from_json = getattr(target_cls, "from_json", None)
|
||||
if callable(from_json):
|
||||
with contextlib.suppress(Exception):
|
||||
return cast(RequestInfoMessage, from_json(value))
|
||||
with contextlib.suppress(Exception):
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return self._decode_request_data({"kind": "dict", "type": type_name, "value": parsed})
|
||||
|
||||
if isinstance(value, dict):
|
||||
with contextlib.suppress(TypeError):
|
||||
return target_cls(**value) # type: ignore[arg-type]
|
||||
instance = object.__new__(target_cls)
|
||||
instance.__dict__.update(value) # type: ignore[arg-type]
|
||||
return instance
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
return target_cls()
|
||||
return RequestInfoMessage()
|
||||
|
||||
def _convert_request_to_json_safe_dict(self, request: RequestInfoMessage) -> dict[str, Any]:
|
||||
try:
|
||||
data = _make_json_safe(asdict(request))
|
||||
if isinstance(data, dict):
|
||||
return cast(dict[str, Any], data)
|
||||
raise ValueError(f"Failed to convert {type(request).__name__} to dict")
|
||||
except Exception as exc:
|
||||
logger.error(f"RequestInfoExecutor {self.id} failed to serialize request: {exc}")
|
||||
raise RuntimeError(
|
||||
f"Failed to serialize request `{type(request).__name__}`: {exc}\n"
|
||||
"Make sure request is a dataclass and derive from `RequestInfoMessage`."
|
||||
) from exc
|
||||
|
||||
async def _rehydrate_request_event(self, request_id: str, ctx: WorkflowContext) -> RequestInfoEvent | None:
|
||||
pending_requests = await self._retrieve_existing_pending_requests(ctx)
|
||||
if (snapshot := pending_requests.get(request_id)) is None:
|
||||
return None
|
||||
|
||||
request = self._construct_request_from_snapshot(snapshot)
|
||||
if request is None:
|
||||
return None
|
||||
|
||||
event = RequestInfoEvent(
|
||||
request_id=request_id,
|
||||
source_executor_id=snapshot.source_executor_id,
|
||||
request_type=type(request),
|
||||
request_data=request,
|
||||
)
|
||||
self._request_events[request_id] = event
|
||||
return event
|
||||
|
||||
def _construct_request_from_snapshot(self, snapshot: PendingRequestSnapshot) -> RequestInfoMessage | None:
|
||||
json_safe_dict = snapshot.request_as_json_safe_dict
|
||||
|
||||
request_cls: type[RequestInfoMessage] = RequestInfoMessage
|
||||
request_type_str = snapshot.request_type
|
||||
if isinstance(request_type_str, str) and ":" in request_type_str:
|
||||
module_name, class_name = request_type_str.split(":", 1)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
candidate = getattr(module, class_name)
|
||||
if isinstance(candidate, type) and issubclass(candidate, RequestInfoMessage):
|
||||
request_cls = candidate
|
||||
except Exception as exc:
|
||||
logger.warning(f"RequestInfoExecutor {self.id} could not import {module_name}.{class_name}: {exc}")
|
||||
request_cls = RequestInfoMessage
|
||||
|
||||
request: RequestInfoMessage | None = self._instantiate_request(request_cls, json_safe_dict)
|
||||
|
||||
if request is None and request_cls is not RequestInfoMessage:
|
||||
request = self._instantiate_request(RequestInfoMessage, json_safe_dict)
|
||||
|
||||
if request is None:
|
||||
logger.warning(
|
||||
f"RequestInfoExecutor {self.id} could not reconstruct request "
|
||||
f"{request_type_str or RequestInfoMessage.__name__} from snapshot keys {sorted(json_safe_dict.keys())}"
|
||||
)
|
||||
return None
|
||||
|
||||
for key, value in json_safe_dict.items():
|
||||
if key == "request_id":
|
||||
continue
|
||||
try:
|
||||
setattr(request, key, value)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"RequestInfoExecutor {self.id} could not set attribute {key} on {type(request).__name__}: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
snapshot_request_id = snapshot.request_id
|
||||
if isinstance(snapshot_request_id, str) and snapshot_request_id:
|
||||
try:
|
||||
request.request_id = snapshot_request_id
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"RequestInfoExecutor {self.id} could not apply snapshot "
|
||||
f"request_id to {type(request).__name__}: {exc}"
|
||||
)
|
||||
|
||||
return request
|
||||
|
||||
def _instantiate_request(
|
||||
self,
|
||||
request_cls: type[RequestInfoMessage],
|
||||
details: dict[str, Any],
|
||||
) -> RequestInfoMessage | None:
|
||||
try:
|
||||
from_dict = getattr(request_cls, "from_dict", None)
|
||||
if callable(from_dict):
|
||||
return cast(RequestInfoMessage, from_dict(details))
|
||||
except (TypeError, ValueError) as exc:
|
||||
logger.debug(f"RequestInfoExecutor {self.id} failed to hydrate {request_cls.__name__} via from_dict: {exc}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"RequestInfoExecutor {self.id} encountered unexpected error during "
|
||||
f"{request_cls.__name__}.from_dict: {exc}"
|
||||
)
|
||||
|
||||
if is_dataclass(request_cls):
|
||||
try:
|
||||
field_names = {f.name for f in fields(request_cls)}
|
||||
ctor_kwargs = {name: details[name] for name in field_names if name in details}
|
||||
return request_cls(**ctor_kwargs)
|
||||
except (TypeError, ValueError) as exc:
|
||||
logger.debug(
|
||||
f"RequestInfoExecutor {self.id} could not instantiate dataclass "
|
||||
f"{request_cls.__name__} with snapshot data: {exc}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"RequestInfoExecutor {self.id} encountered unexpected error "
|
||||
f"constructing dataclass {request_cls.__name__}: {exc}"
|
||||
)
|
||||
|
||||
try:
|
||||
instance = request_cls()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"RequestInfoExecutor {self.id} could not instantiate {request_cls.__name__} without arguments: {exc}"
|
||||
)
|
||||
return None
|
||||
|
||||
for key, value in details.items():
|
||||
if key == "request_id":
|
||||
continue
|
||||
try:
|
||||
setattr(instance, key, value)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"RequestInfoExecutor {self.id} could not set attribute {key} on "
|
||||
f"{request_cls.__name__} during instantiation: {exc}"
|
||||
)
|
||||
continue
|
||||
|
||||
return instance
|
||||
|
||||
# endregion: Internal Methods
|
||||
|
||||
|
||||
# region: Utility Functions
|
||||
|
||||
|
||||
def _make_json_safe(value: Any) -> Any:
|
||||
"""Recursively convert a value to a JSON-safe representation."""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
safe_dict: dict[str, Any] = {}
|
||||
for key, val in value.items(): # type: ignore[attr-defined]
|
||||
safe_dict[str(key)] = _make_json_safe(val) # type: ignore[arg-type]
|
||||
return safe_dict
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [_make_json_safe(item) for item in value] # type: ignore[misc]
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _import_qualname(qualname: str) -> type[Any]:
|
||||
"""Import a type given its qualified name in the format 'module:TypeName'."""
|
||||
module_name, _, type_name = qualname.partition(":")
|
||||
if not module_name or not type_name:
|
||||
raise ValueError(f"Invalid qualified name: {qualname}")
|
||||
module = importlib.import_module(module_name)
|
||||
attr: Any = module
|
||||
for part in type_name.split("."):
|
||||
attr = getattr(attr, part)
|
||||
if not isinstance(attr, type):
|
||||
raise TypeError(f"Resolved object is not a type: {qualname}")
|
||||
return attr
|
||||
|
||||
|
||||
# endregion: Utility Functions
|
||||
@@ -0,0 +1,249 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
from builtins import type as builtin_type
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from ._typing_utils import is_instance_of, is_type_compatible
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import Executor
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestInfoMixin:
|
||||
"""Mixin providing common functionality for request info handling."""
|
||||
|
||||
def is_request_supported(self, request_type: builtin_type[Any], response_type: builtin_type[Any]) -> bool:
|
||||
"""Check if the executor supports request of the given type and handling a response of the given type.
|
||||
|
||||
Args:
|
||||
request_type: The type of the request message
|
||||
response_type: The type of the expected response message
|
||||
Returns:
|
||||
True if a response handler is registered for the given request and response types, False otherwise
|
||||
"""
|
||||
if not hasattr(self, "_response_handlers"):
|
||||
return False
|
||||
|
||||
for request_type_key, response_type_key in self._response_handlers:
|
||||
if is_type_compatible(request_type, request_type_key) and is_type_compatible(
|
||||
response_type, response_type_key
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _find_response_handler(self, request: Any, response: Any) -> Callable[..., Awaitable[None]] | None:
|
||||
"""Find a registered response handler for the given request and response types.
|
||||
|
||||
Args:
|
||||
request: The original request
|
||||
response: The response message
|
||||
Returns:
|
||||
The response handler function with the request bound as the first argument, or None if not found
|
||||
"""
|
||||
if not hasattr(self, "_response_handlers"):
|
||||
return None
|
||||
|
||||
for (request_type, response_type), handler in self._response_handlers.items():
|
||||
if is_instance_of(request, request_type) and is_instance_of(response, response_type):
|
||||
return functools.partial(handler, request)
|
||||
|
||||
return None
|
||||
|
||||
def _discover_response_handlers(self) -> None:
|
||||
"""Discover and register response handlers defined in the class."""
|
||||
# Initialize handler storage if not already present
|
||||
if not hasattr(self, "_response_handlers"):
|
||||
self._response_handlers: dict[
|
||||
tuple[builtin_type[Any], builtin_type[Any]], # key
|
||||
Callable[[Any, Any, WorkflowContext[Any, Any]], Awaitable[None]], # value
|
||||
] = {}
|
||||
if not hasattr(self, "_response_handler_specs"):
|
||||
self._response_handler_specs: list[dict[str, Any]] = []
|
||||
|
||||
for attr_name in dir(self.__class__):
|
||||
try:
|
||||
attr = getattr(self.__class__, attr_name)
|
||||
if callable(attr) and hasattr(attr, "_response_handler_spec"):
|
||||
handler_spec = attr._response_handler_spec # type: ignore
|
||||
|
||||
request_type = handler_spec["request_type"]
|
||||
response_type = handler_spec["response_type"]
|
||||
|
||||
if self._response_handlers.get((request_type, response_type)):
|
||||
raise ValueError(
|
||||
f"Duplicate response handler for request type {request_type} "
|
||||
f"and response type {response_type} in {self.__class__.__name__}"
|
||||
)
|
||||
|
||||
self._response_handlers[request_type, response_type] = getattr(self, attr_name)
|
||||
self._response_handler_specs.append({
|
||||
"name": handler_spec["name"],
|
||||
"request_type": request_type,
|
||||
"response_type": response_type,
|
||||
"output_types": handler_spec.get("output_types", []),
|
||||
"workflow_output_types": handler_spec.get("workflow_output_types", []),
|
||||
"ctx_annotation": handler_spec.get("ctx_annotation"),
|
||||
"source": "class_method", # Distinguish from instance handlers if needed
|
||||
})
|
||||
except AttributeError:
|
||||
continue # Skip non-callable attributes or those without handler spec
|
||||
|
||||
# A request sent via `request_info` must be handled by a response handler inside the same executor.
|
||||
# It is safe to assume that an executor is request-response capable if it has at least one response
|
||||
# handler, and that the executor could send a request.
|
||||
self.is_request_response_capable = bool(self._response_handlers)
|
||||
|
||||
|
||||
ExecutorT = TypeVar("ExecutorT", bound="Executor")
|
||||
ContextT = TypeVar("ContextT", bound="WorkflowContext[Any, Any]")
|
||||
|
||||
# region Handler Decorator
|
||||
|
||||
|
||||
def response_handler(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]:
|
||||
"""Decorator to register a handler to handle responses for a request.
|
||||
|
||||
Args:
|
||||
func: The function to decorate.
|
||||
|
||||
Returns:
|
||||
The decorated function with handler metadata.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
@handler
|
||||
async def run(self, message: int, context: WorkflowContext[str]) -> None:
|
||||
# Example of a handler that sends a request
|
||||
...
|
||||
# Send a request with a `CustomRequest` payload and expect a `str` response.
|
||||
await context.request_info(CustomRequest(...), CustomRequest, str)
|
||||
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: CustomRequest,
|
||||
response: str,
|
||||
context: WorkflowContext[str],
|
||||
) -> None:
|
||||
# Example of a response handler for the above request
|
||||
...
|
||||
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: CustomRequest,
|
||||
response: dict,
|
||||
context: WorkflowContext[int],
|
||||
) -> None:
|
||||
# Example of a response handler for a request expecting a dict response
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]:
|
||||
request_type, response_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_response_handler_signature(func)
|
||||
)
|
||||
|
||||
# Get signature for preservation
|
||||
sig = inspect.signature(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self: ExecutorT, original_request: Any, response: Any, ctx: ContextT) -> Any:
|
||||
"""Wrapper function to call the handler."""
|
||||
return await func(self, original_request, response, ctx)
|
||||
|
||||
# Preserve the original function signature for introspection during validation
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
wrapper.__signature__ = sig # type: ignore[attr-defined]
|
||||
|
||||
wrapper._response_handler_spec = { # type: ignore
|
||||
"name": func.__name__,
|
||||
"request_type": request_type,
|
||||
"response_type": response_type,
|
||||
# Keep output_types and workflow_output_types in spec for validators
|
||||
"output_types": inferred_output_types,
|
||||
"workflow_output_types": inferred_workflow_output_types,
|
||||
"ctx_annotation": ctx_annotation,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator(func)
|
||||
|
||||
|
||||
# endregion: Handler Decorator
|
||||
|
||||
# region Response Handler Validation
|
||||
|
||||
|
||||
def _validate_response_handler_signature(
|
||||
func: Callable[..., Any],
|
||||
) -> tuple[type, type, Any, list[type[Any]], list[type[Any]]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (request_type, response_type, ctx_annotation, output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
# Note that the original_request parameter must be the second parameter
|
||||
# such that we can wrap the handler with functools.partial to bind it
|
||||
# to the original request when registering the handler, while maintaining
|
||||
# the order of parameters as if the response handler is a normal handler.
|
||||
expected_counts = 4 # self, original_request, message, ctx
|
||||
param_description = "(self, original_request: TRequest, message: TResponse, ctx: WorkflowContext[U, V])"
|
||||
if len(params) != expected_counts:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Check original_request parameter exists
|
||||
original_request_param = params[1]
|
||||
if original_request_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} must have a type annotation for the original_request parameter"
|
||||
)
|
||||
|
||||
# Check response parameter has type annotation
|
||||
response_param = params[2]
|
||||
if response_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Response handler {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args
|
||||
ctx_param = params[3]
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Response handler"
|
||||
)
|
||||
|
||||
request_type = original_request_param.annotation
|
||||
response_type = response_param.annotation
|
||||
ctx_annotation = ctx_param.annotation
|
||||
|
||||
return request_type, response_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
# endregion: Response Handler Validation
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import DATACLASS_MARKER, MODEL_MARKER, decode_checkpoint_value
|
||||
@@ -19,9 +19,6 @@ from ._runner_context import (
|
||||
)
|
||||
from ._shared_state import SharedState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._request_info_executor import RequestInfoExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -136,6 +133,8 @@ class Runner:
|
||||
raise RuntimeError(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
# TODO(@taochen): iteration is reset to zero, even in the event of a request info event.
|
||||
# Should iteration be preserved in the event of a request info event?
|
||||
self._iteration = 0
|
||||
self._resumed_from_checkpoint = False # Reset resume flag for next run
|
||||
finally:
|
||||
@@ -164,6 +163,10 @@ class Runner:
|
||||
|
||||
# Route all messages through normal workflow edges
|
||||
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
|
||||
if not associated_edge_runners:
|
||||
logger.warning(f"No outgoing edges found for executor {source_executor_id}; dropping messages.")
|
||||
return
|
||||
|
||||
for message in messages:
|
||||
_normalize_message_payload(message)
|
||||
# Deliver a message through all edge runners associated with the source executor concurrently.
|
||||
@@ -281,7 +284,7 @@ class Runner:
|
||||
|
||||
self._workflow_id = checkpoint.workflow_id
|
||||
# Restore shared state
|
||||
await self._shared_state.import_state(checkpoint.shared_state)
|
||||
await self._shared_state.import_state(decode_checkpoint_value(checkpoint.shared_state))
|
||||
# Restore executor states using the restored shared state
|
||||
await self._restore_executor_states()
|
||||
# Apply the checkpoint to the context
|
||||
@@ -347,39 +350,6 @@ class Runner:
|
||||
|
||||
return parsed
|
||||
|
||||
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
|
||||
"""Find the RequestInfoExecutor instance in this workflow.
|
||||
|
||||
Returns:
|
||||
The RequestInfoExecutor instance if found, None otherwise.
|
||||
"""
|
||||
from ._request_info_executor import RequestInfoExecutor
|
||||
|
||||
for executor in self._executors.values():
|
||||
if isinstance(executor, RequestInfoExecutor):
|
||||
return executor
|
||||
return None
|
||||
|
||||
def _is_message_to_request_info_executor(self, msg: "Message") -> bool:
|
||||
"""Check if message targets any RequestInfoExecutor in this workflow.
|
||||
|
||||
Args:
|
||||
msg: The message to check.
|
||||
|
||||
Returns:
|
||||
True if the message targets a RequestInfoExecutor, False otherwise.
|
||||
"""
|
||||
from ._request_info_executor import RequestInfoExecutor
|
||||
|
||||
if not msg.target_id:
|
||||
return False
|
||||
|
||||
# Check all executors to see if target_id matches a RequestInfoExecutor
|
||||
for executor in self._executors.values():
|
||||
if executor.id == msg.target_id and isinstance(executor, RequestInfoExecutor):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _mark_resumed(self, iteration: int) -> None:
|
||||
"""Mark the runner as having resumed from a checkpoint.
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import logging
|
||||
import uuid
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
|
||||
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._events import WorkflowEvent
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._events import RequestInfoEvent, WorkflowEvent
|
||||
from ._shared_state import SharedState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,6 +19,16 @@ logger = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Enumeration of message types in the workflow."""
|
||||
|
||||
STANDARD = "standard"
|
||||
"""A standard message between executors."""
|
||||
|
||||
RESPONSE = "response"
|
||||
"""A response message to a pending request."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""A class representing a message in the workflow."""
|
||||
@@ -24,12 +36,16 @@ class Message:
|
||||
data: Any
|
||||
source_id: str
|
||||
target_id: str | None = None
|
||||
type: MessageType = MessageType.STANDARD
|
||||
|
||||
# OpenTelemetry trace context fields for message propagation
|
||||
# These are plural to support fan-in scenarios where multiple messages are aggregated
|
||||
trace_contexts: list[dict[str, str]] | None = None # W3C Trace Context headers from multiple sources
|
||||
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
|
||||
|
||||
# For response messages, the original request data
|
||||
original_request: Any = None
|
||||
|
||||
# Backward compatibility properties
|
||||
@property
|
||||
def trace_context(self) -> dict[str, str] | None:
|
||||
@@ -41,6 +57,38 @@ class Message:
|
||||
"""Get the first source span ID for backward compatibility."""
|
||||
return self.source_span_ids[0] if self.source_span_ids else None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the Message to a dictionary for serialization."""
|
||||
return {
|
||||
"data": encode_checkpoint_value(self.data),
|
||||
"source_id": self.source_id,
|
||||
"target_id": self.target_id,
|
||||
"type": self.type.value,
|
||||
"trace_contexts": self.trace_contexts,
|
||||
"source_span_ids": self.source_span_ids,
|
||||
"original_request": self.original_request,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict[str, Any]) -> "Message":
|
||||
"""Create a Message from a dictionary."""
|
||||
# Validation
|
||||
if "data" not in data:
|
||||
raise KeyError("Missing 'data' field in Message dictionary.")
|
||||
|
||||
if "source_id" not in data:
|
||||
raise KeyError("Missing 'source_id' field in Message dictionary.")
|
||||
|
||||
return Message(
|
||||
data=decode_checkpoint_value(data["data"]),
|
||||
source_id=data["source_id"],
|
||||
target_id=data.get("target_id"),
|
||||
type=MessageType(data.get("type", "standard")),
|
||||
trace_contexts=data.get("trace_contexts"),
|
||||
source_span_ids=data.get("source_span_ids"),
|
||||
original_request=data.get("original_request"),
|
||||
)
|
||||
|
||||
|
||||
class _WorkflowState(TypedDict):
|
||||
"""TypedDict representing the serializable state of a workflow execution.
|
||||
@@ -51,6 +99,7 @@ class _WorkflowState(TypedDict):
|
||||
messages: dict[str, list[dict[str, Any]]]
|
||||
shared_state: dict[str, Any]
|
||||
iteration_count: int
|
||||
pending_request_info_events: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -186,6 +235,31 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def add_request_info_event(self, event: RequestInfoEvent) -> None:
|
||||
"""Add a RequestInfoEvent to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The RequestInfoEvent to be added.
|
||||
"""
|
||||
...
|
||||
|
||||
async def send_request_info_response(self, request_id: str, response: Any) -> None:
|
||||
"""Send a response correlated to a pending request.
|
||||
|
||||
Args:
|
||||
request_id: The ID of the original request.
|
||||
response: The response data to be sent.
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]:
|
||||
"""Get the mapping of request IDs to their corresponding RequestInfoEvent.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their corresponding RequestInfoEvent.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InProcRunnerContext:
|
||||
"""In-process execution context for local execution and optional checkpointing."""
|
||||
@@ -200,6 +274,9 @@ class InProcRunnerContext:
|
||||
# Event queue for immediate streaming of events (e.g., AgentRunUpdateEvent)
|
||||
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
|
||||
|
||||
# An additional storage for pending request info events
|
||||
self._pending_request_info_events: dict[str, RequestInfoEvent] = {}
|
||||
|
||||
# Checkpointing configuration/state
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
self._workflow_id: str | None = None
|
||||
@@ -271,6 +348,7 @@ class InProcRunnerContext:
|
||||
workflow_id=self._workflow_id,
|
||||
messages=state["messages"],
|
||||
shared_state=state["shared_state"],
|
||||
pending_request_info_events=state["pending_request_info_events"],
|
||||
iteration_count=state["iteration_count"],
|
||||
metadata=metadata or {},
|
||||
)
|
||||
@@ -294,6 +372,8 @@ class InProcRunnerContext:
|
||||
self._streaming = False # Reset streaming flag
|
||||
|
||||
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Apply a checkpoint to the current context, mutating its state."""
|
||||
# Restore messages
|
||||
self._messages.clear()
|
||||
messages_data = checkpoint.messages
|
||||
for source_id, message_list in messages_data.items():
|
||||
@@ -308,6 +388,15 @@ class InProcRunnerContext:
|
||||
for msg in message_list
|
||||
]
|
||||
|
||||
# Restore pending request info events
|
||||
self._pending_request_info_events.clear()
|
||||
pending_requests_data = checkpoint.pending_request_info_events
|
||||
for request_id, request_data in pending_requests_data.items():
|
||||
request_info_event = RequestInfoEvent.from_dict(request_data)
|
||||
self._pending_request_info_events[request_id] = request_info_event
|
||||
await self.add_event(request_info_event)
|
||||
|
||||
# Restore workflow ID
|
||||
self._workflow_id = checkpoint.workflow_id
|
||||
|
||||
# endregion Checkpointing
|
||||
@@ -332,21 +421,63 @@ class InProcRunnerContext:
|
||||
return self._streaming
|
||||
|
||||
async def _get_serialized_workflow_state(self, shared_state: SharedState, iteration_count: int) -> _WorkflowState:
|
||||
serializable_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
serialized_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
for source_id, message_list in self._messages.items():
|
||||
serializable_messages[source_id] = [
|
||||
{
|
||||
"data": encode_checkpoint_value(msg.data),
|
||||
"source_id": msg.source_id,
|
||||
"target_id": msg.target_id,
|
||||
"trace_contexts": msg.trace_contexts,
|
||||
"source_span_ids": msg.source_span_ids,
|
||||
}
|
||||
for msg in message_list
|
||||
]
|
||||
serialized_messages[source_id] = [msg.to_dict() for msg in message_list]
|
||||
|
||||
serialized_pending_request_info_events: dict[str, dict[str, Any]] = {
|
||||
request_id: request.to_dict() for request_id, request in self._pending_request_info_events.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"messages": serializable_messages,
|
||||
"messages": serialized_messages,
|
||||
"shared_state": encode_checkpoint_value(await shared_state.export_state()),
|
||||
"iteration_count": iteration_count,
|
||||
"pending_request_info_events": serialized_pending_request_info_events,
|
||||
}
|
||||
|
||||
async def add_request_info_event(self, event: RequestInfoEvent) -> None:
|
||||
"""Add a RequestInfoEvent to the context and track it for correlation.
|
||||
|
||||
Args:
|
||||
event: The RequestInfoEvent to be added.
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
request_id: The ID of the original request.
|
||||
response: The response data to be sent.
|
||||
"""
|
||||
event = self._pending_request_info_events.pop(request_id, None)
|
||||
if not event:
|
||||
raise ValueError(f"No pending request found for request_id: {request_id}")
|
||||
|
||||
# Validate response type if specified
|
||||
if event.response_type and not isinstance(response, event.response_type):
|
||||
raise TypeError(
|
||||
f"Response type mismatch for request_id {request_id}: "
|
||||
f"expected {event.response_type.__name__}, got {type(response).__name__}"
|
||||
)
|
||||
|
||||
# Create ResponseMessage instance
|
||||
response_msg = Message(
|
||||
data=response,
|
||||
source_id=INTERNAL_SOURCE_ID(event.source_executor_id),
|
||||
target_id=event.source_executor_id,
|
||||
type=MessageType.RESPONSE,
|
||||
original_request=event.data,
|
||||
)
|
||||
|
||||
await self.send_message(response_msg)
|
||||
|
||||
async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]:
|
||||
"""Get the mapping of request IDs to their corresponding RequestInfoEvent.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping request IDs to their corresponding RequestInfoEvent.
|
||||
"""
|
||||
return dict(self._pending_request_info_events)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import fields, is_dataclass
|
||||
from types import UnionType
|
||||
from typing import Any, TypeVar, Union, cast, get_args, get_origin
|
||||
@@ -133,35 +132,7 @@ def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
|
||||
)
|
||||
)
|
||||
|
||||
# Case 6: target_type is RequestResponse[T, U] - validate generic parameters
|
||||
if origin and hasattr(origin, "__name__") and origin.__name__ == "RequestResponse":
|
||||
if not isinstance(data, origin):
|
||||
return False
|
||||
# Validate generic parameters for RequestResponse[TRequest, TResponse]
|
||||
if len(args) >= 2:
|
||||
request_type, response_type = args[0], args[1]
|
||||
# Check if the original_request matches TRequest and data matches TResponse
|
||||
if (
|
||||
hasattr(data, "original_request")
|
||||
and data.original_request is not None
|
||||
and not is_instance_of(data.original_request, request_type)
|
||||
):
|
||||
# Checkpoint decoding can leave original_request as a plain mapping. In that
|
||||
# case we coerce it back into the expected request type so downstream handlers
|
||||
# and validators still receive a fully typed RequestResponse instance.
|
||||
original_request = data.original_request
|
||||
if isinstance(original_request, Mapping):
|
||||
coerced = _coerce_to_type(dict(original_request), request_type) # type: ignore[arg-type]
|
||||
if coerced is None or not isinstance(coerced, request_type):
|
||||
return False
|
||||
data.original_request = coerced
|
||||
else:
|
||||
return False
|
||||
if hasattr(data, "data") and data.data is not None and not is_instance_of(data.data, response_type):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Case 7: Other custom generic classes - check origin type only
|
||||
# Case 6: Other custom generic classes - check origin type only
|
||||
# For generic classes, we check if data is an instance of the origin type
|
||||
# We don't validate the generic parameters at runtime since that's handled by type system
|
||||
if origin and hasattr(origin, "__name__"):
|
||||
@@ -171,6 +142,31 @@ def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
|
||||
return isinstance(data, target_type)
|
||||
|
||||
|
||||
def serialize_type(t: type) -> str:
|
||||
"""Serialize a type to a string.
|
||||
|
||||
For example,
|
||||
|
||||
serialize_type(int) => "builtins.int"
|
||||
"""
|
||||
return f"{t.__module__}.{t.__qualname__}"
|
||||
|
||||
|
||||
def deserialize_type(serialized_type_string: str) -> type:
|
||||
"""Deserialize a serialized type string.
|
||||
|
||||
For example,
|
||||
|
||||
deserialize_type("builtins.int") => int
|
||||
"""
|
||||
import importlib
|
||||
|
||||
module_name, _, type_name = serialized_type_string.rpartition(".")
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
return cast(type, getattr(module, type_name))
|
||||
|
||||
|
||||
def is_type_compatible(source_type: type | UnionType | Any, target_type: type | UnionType | Any) -> bool:
|
||||
"""Check if source_type is compatible with target_type.
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup, InternalEdgeGroup
|
||||
from ._executor import Executor
|
||||
from ._request_info_executor import RequestInfoExecutor
|
||||
from ._typing_utils import is_type_compatible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,20 +52,6 @@ class EdgeDuplicationError(WorkflowValidationError):
|
||||
self.edge_id = edge_id
|
||||
|
||||
|
||||
class ExecutorDuplicationError(WorkflowValidationError):
|
||||
"""Exception raised when duplicate executor identifiers are detected."""
|
||||
|
||||
def __init__(self, executor_id: str):
|
||||
super().__init__(
|
||||
message=(
|
||||
f"Duplicate executor id detected: '{executor_id}'. Executor ids must be globally unique within a "
|
||||
"workflow."
|
||||
),
|
||||
validation_type=ValidationTypeEnum.EXECUTOR_DUPLICATION,
|
||||
)
|
||||
self.executor_id = executor_id
|
||||
|
||||
|
||||
class TypeCompatibilityError(WorkflowValidationError):
|
||||
"""Exception raised when type incompatibility is detected between connected executors."""
|
||||
|
||||
@@ -120,7 +105,6 @@ class WorkflowGraphValidator:
|
||||
def __init__(self) -> None:
|
||||
self._edges: list[Edge] = []
|
||||
self._executors: dict[str, Executor] = {}
|
||||
self._duplicate_executor_ids: set[str] = set()
|
||||
self._start_executor_ref: Executor | str | None = None
|
||||
|
||||
# region Core Validation Methods
|
||||
@@ -129,8 +113,6 @@ class WorkflowGraphValidator:
|
||||
edge_groups: Sequence[EdgeGroup],
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor | str,
|
||||
*,
|
||||
duplicate_executor_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Validate the entire workflow graph.
|
||||
|
||||
@@ -139,16 +121,12 @@ class WorkflowGraphValidator:
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor (can be instance or ID)
|
||||
|
||||
Keyword Args:
|
||||
duplicate_executor_ids: Optional list of known duplicate executor IDs to pre-populate
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
"""
|
||||
self._executors = executors
|
||||
self._edges = [edge for group in edge_groups for edge in group.edges]
|
||||
self._edge_groups = edge_groups
|
||||
self._duplicate_executor_ids = set(duplicate_executor_ids or [])
|
||||
self._start_executor_ref = start_executor
|
||||
|
||||
# If only the start executor exists, add it to the executor map
|
||||
@@ -184,7 +162,6 @@ class WorkflowGraphValidator:
|
||||
)
|
||||
|
||||
# Run all checks
|
||||
self._validate_executor_id_uniqueness(start_executor_id)
|
||||
self._validate_edge_duplication()
|
||||
self._validate_handler_output_annotations()
|
||||
self._validate_type_compatibility()
|
||||
@@ -201,8 +178,8 @@ class WorkflowGraphValidator:
|
||||
decorator is applied. This method is kept minimal for any edge cases.
|
||||
"""
|
||||
# The comprehensive validation is already done during handler registration:
|
||||
# 1. @handler decorator calls validate_function_signature()
|
||||
# 2. FunctionExecutor constructor calls validate_function_signature()
|
||||
# 1. @handler and @response_handler decorators already have validation logic
|
||||
# 2. FunctionExecutor constructor also has validation logic
|
||||
# 3. Both use validate_workflow_context_annotation() for WorkflowContext validation
|
||||
#
|
||||
# All executors in the workflow must have gone through one of these paths,
|
||||
@@ -211,26 +188,6 @@ class WorkflowGraphValidator:
|
||||
|
||||
# endregion
|
||||
|
||||
def _validate_executor_id_uniqueness(self, start_executor_id: str) -> None:
|
||||
"""Ensure executor identifiers are unique throughout the workflow graph."""
|
||||
duplicates: set[str] = set(self._duplicate_executor_ids)
|
||||
|
||||
id_counts: defaultdict[str, int] = defaultdict(int)
|
||||
for key, executor in self._executors.items():
|
||||
id_counts[executor.id] += 1
|
||||
if key != executor.id:
|
||||
duplicates.add(executor.id)
|
||||
|
||||
duplicates.update({executor_id for executor_id, count in id_counts.items() if count > 1})
|
||||
|
||||
if isinstance(self._start_executor_ref, Executor):
|
||||
mapped = self._executors.get(start_executor_id)
|
||||
if mapped is not None and mapped is not self._start_executor_ref:
|
||||
duplicates.add(start_executor_id)
|
||||
|
||||
if duplicates:
|
||||
raise ExecutorDuplicationError(sorted(duplicates)[0])
|
||||
|
||||
# region Edge and Type Validation
|
||||
def _validate_edge_duplication(self) -> None:
|
||||
"""Validate that there are no duplicate edges in the workflow.
|
||||
@@ -272,6 +229,10 @@ class WorkflowGraphValidator:
|
||||
Raises:
|
||||
TypeCompatibilityError: If type incompatibility is detected
|
||||
"""
|
||||
if isinstance(edge_group, InternalEdgeGroup):
|
||||
# Skip type compatibility validation for internal edges
|
||||
return
|
||||
|
||||
source_executor = self._executors[edge.source_id]
|
||||
target_executor = self._executors[edge.target_id]
|
||||
|
||||
@@ -284,14 +245,13 @@ class WorkflowGraphValidator:
|
||||
# If either executor has no type information, log warning and skip validation
|
||||
# This allows for dynamic typing scenarios but warns about reduced validation coverage
|
||||
if not source_output_types or not target_input_types:
|
||||
# Suppress warnings for RequestInfoExecutor where dynamic typing is expected
|
||||
if not source_output_types and not isinstance(source_executor, RequestInfoExecutor):
|
||||
if not source_output_types:
|
||||
logger.warning(
|
||||
f"Executor '{source_executor.id}' has no output type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges from this executor. "
|
||||
f"Consider adding WorkflowContext[T] generics in handlers for better validation."
|
||||
)
|
||||
if not target_input_types and not isinstance(target_executor, RequestInfoExecutor):
|
||||
if not target_input_types:
|
||||
logger.warning(
|
||||
f"Executor '{target_executor.id}' has no input type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges to this executor. "
|
||||
@@ -534,8 +494,6 @@ def validate_workflow_graph(
|
||||
edge_groups: Sequence[EdgeGroup],
|
||||
executors: dict[str, Executor],
|
||||
start_executor: Executor | str,
|
||||
*,
|
||||
duplicate_executor_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Convenience function to validate a workflow graph.
|
||||
|
||||
@@ -544,9 +502,6 @@ def validate_workflow_graph(
|
||||
executors: Map of executor IDs to executor instances
|
||||
start_executor: The starting executor (can be instance or ID)
|
||||
|
||||
Keyword Args:
|
||||
duplicate_executor_ids: Optional list of known duplicate executor IDs to pre-populate
|
||||
|
||||
Raises:
|
||||
WorkflowValidationError: If any validation fails
|
||||
"""
|
||||
@@ -555,5 +510,4 @@ def validate_workflow_graph(
|
||||
edge_groups,
|
||||
executors,
|
||||
start_executor,
|
||||
duplicate_executor_ids=duplicate_executor_ids,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -30,11 +31,9 @@ from ._events import (
|
||||
)
|
||||
from ._executor import Executor
|
||||
from ._model_utils import DictConvertible
|
||||
from ._request_info_executor import RequestInfoExecutor
|
||||
from ._runner import Runner
|
||||
from ._runner_context import RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
pass # pragma: no cover
|
||||
@@ -138,11 +137,13 @@ class Workflow(DictConvertible):
|
||||
- run_stream_from_checkpoint(): Resume from checkpoint with streaming
|
||||
|
||||
## External Input Requests
|
||||
Workflows can request external input using a RequestInfoExecutor:
|
||||
1. Executor connects to RequestInfoExecutor via edge group and back to itself
|
||||
2. Executor sends RequestInfoMessage to RequestInfoExecutor
|
||||
3. RequestInfoExecutor emits RequestInfoEvent and workflow enters IDLE_WITH_PENDING_REQUESTS
|
||||
4. Caller handles requests and uses send_responses()/send_responses_streaming() to continue
|
||||
Executors within a workflow can request external input using `ctx.request_info()`:
|
||||
1. Executor calls `ctx.request_info()` to request input
|
||||
2. Executor implements `response_handler()` to process the response
|
||||
3. Requests are emitted as RequestInfoEvent instances in the event stream
|
||||
4. Workflow enters IDLE_WITH_PENDING_REQUESTS state
|
||||
5. Caller handles requests and uses send_responses()/send_responses_streaming() to continue
|
||||
6. Responses are routed back to the requesting executors and response handlers are invoked
|
||||
|
||||
## Checkpointing
|
||||
When enabled, checkpoints are created at the end of each superstep, capturing:
|
||||
@@ -369,6 +370,7 @@ class Workflow(DictConvertible):
|
||||
capture_exception(span, exception=exc)
|
||||
raise
|
||||
|
||||
# region Streaming Run
|
||||
async def run_stream(self, message: Any) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Run the workflow with a starting message and stream events.
|
||||
|
||||
@@ -379,19 +381,19 @@ class Workflow(DictConvertible):
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
self._ensure_not_running()
|
||||
|
||||
async def initial_execution() -> None:
|
||||
executor = self.get_start_executor()
|
||||
await executor.execute(
|
||||
message,
|
||||
[self.__class__.__name__], # source_executor_ids
|
||||
self._shared_state, # shared_state
|
||||
self._runner.context, # runner_context
|
||||
trace_contexts=None, # No parent trace context for workflow start
|
||||
source_span_ids=None, # No source span for workflow start
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
async def initial_execution() -> None:
|
||||
executor = self.get_start_executor()
|
||||
await executor.execute(
|
||||
message,
|
||||
[self.__class__.__name__], # source_executor_ids
|
||||
self._shared_state, # shared_state
|
||||
self._runner.context, # runner_context
|
||||
trace_contexts=None, # No parent trace context for workflow start
|
||||
source_span_ids=None, # No source span for workflow start
|
||||
)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=initial_execution, reset_context=True, streaming=True
|
||||
):
|
||||
@@ -403,16 +405,13 @@ class Workflow(DictConvertible):
|
||||
self,
|
||||
checkpoint_id: str,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
responses: dict[str, Any] | None = None,
|
||||
) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Resume workflow execution from a checkpoint and stream events.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to restore from.
|
||||
checkpoint_storage: Optional checkpoint storage to use for restoration.
|
||||
If not provided, the workflow must have been built with checkpointing enabled.
|
||||
responses: Optional dictionary of responses to inject into the workflow
|
||||
after restoration. Keys are request IDs, values are response data.
|
||||
If not provided, the workflow must have been built with checkpointing enabled.
|
||||
|
||||
Yields:
|
||||
WorkflowEvent: Events generated during workflow execution.
|
||||
@@ -423,57 +422,8 @@ class Workflow(DictConvertible):
|
||||
"""
|
||||
self._ensure_not_running()
|
||||
try:
|
||||
|
||||
async def checkpoint_restoration() -> None:
|
||||
has_checkpointing = self._runner.context.has_checkpointing()
|
||||
|
||||
if not has_checkpointing and checkpoint_storage is None:
|
||||
raise ValueError(
|
||||
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
|
||||
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
|
||||
)
|
||||
|
||||
restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
if not restored:
|
||||
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
|
||||
|
||||
# Process any pending messages from the checkpoint first
|
||||
# This ensures that RequestInfoExecutor state is properly populated
|
||||
# before we try to handle responses
|
||||
if await self._runner.context.has_messages():
|
||||
# Run one iteration to process pending messages
|
||||
# This will populate RequestInfoExecutor._request_events properly
|
||||
await self._runner._run_iteration() # type: ignore
|
||||
|
||||
if responses:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if request_info_executor:
|
||||
for request_id, response_data in responses.items():
|
||||
ctx: WorkflowContext[Any] = WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for new workflow span
|
||||
source_span_ids=None, # No source span for response handling
|
||||
)
|
||||
|
||||
if not await request_info_executor.has_pending_request(request_id, ctx):
|
||||
logger.debug(
|
||||
f"Skipping pre-supplied response for request {request_id}; "
|
||||
f"no pending request found after checkpoint restoration."
|
||||
)
|
||||
continue
|
||||
|
||||
await request_info_executor.handle_response(
|
||||
response_data,
|
||||
request_id,
|
||||
ctx,
|
||||
)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=checkpoint_restoration,
|
||||
initial_executor_fn=functools.partial(self._checkpoint_restoration, checkpoint_id, checkpoint_storage),
|
||||
reset_context=False, # Don't reset context when resuming from checkpoint
|
||||
streaming=True,
|
||||
):
|
||||
@@ -493,33 +443,8 @@ class Workflow(DictConvertible):
|
||||
"""
|
||||
self._ensure_not_running()
|
||||
try:
|
||||
|
||||
async def send_responses() -> None:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if not request_info_executor:
|
||||
raise ValueError("No RequestInfoExecutor found in workflow.")
|
||||
|
||||
async def _handle_response(response: Any, request_id: str) -> None:
|
||||
"""Handle the response from the RequestInfoExecutor."""
|
||||
await request_info_executor.handle_response(
|
||||
response,
|
||||
request_id,
|
||||
WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for new workflow span
|
||||
source_span_ids=None, # No source span for response handling
|
||||
),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[
|
||||
_handle_response(response, request_id) for request_id, response in responses.items()
|
||||
])
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=send_responses,
|
||||
initial_executor_fn=functools.partial(self._send_responses_internal, responses),
|
||||
reset_context=False, # Don't reset context when sending responses
|
||||
streaming=True,
|
||||
):
|
||||
@@ -527,6 +452,10 @@ class Workflow(DictConvertible):
|
||||
finally:
|
||||
self._reset_running_flag()
|
||||
|
||||
# endregion: Streaming Run
|
||||
|
||||
# region: Run
|
||||
|
||||
async def run(self, message: Any, *, include_status_events: bool = False) -> WorkflowRunResult:
|
||||
"""Run the workflow with the given message.
|
||||
|
||||
@@ -583,7 +512,6 @@ class Workflow(DictConvertible):
|
||||
self,
|
||||
checkpoint_id: str,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
responses: dict[str, Any] | None = None,
|
||||
) -> WorkflowRunResult:
|
||||
"""Resume workflow execution from a checkpoint.
|
||||
|
||||
@@ -591,8 +519,6 @@ class Workflow(DictConvertible):
|
||||
checkpoint_id: The ID of the checkpoint to restore from.
|
||||
checkpoint_storage: Optional checkpoint storage to use for restoration.
|
||||
If not provided, the workflow must have been built with checkpointing enabled.
|
||||
responses: Optional dictionary of responses to inject into the workflow
|
||||
after restoration. Keys are request IDs, values are response data.
|
||||
|
||||
Returns:
|
||||
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
|
||||
@@ -603,59 +529,12 @@ class Workflow(DictConvertible):
|
||||
"""
|
||||
self._ensure_not_running()
|
||||
try:
|
||||
|
||||
async def checkpoint_restoration() -> None:
|
||||
has_checkpointing = self._runner.context.has_checkpointing()
|
||||
|
||||
if not has_checkpointing and checkpoint_storage is None:
|
||||
raise ValueError(
|
||||
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
|
||||
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
|
||||
)
|
||||
|
||||
restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
if not restored:
|
||||
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
|
||||
|
||||
# Process any pending messages from the checkpoint first
|
||||
# This ensures that RequestInfoExecutor state is properly populated
|
||||
# before we try to handle responses
|
||||
if await self._runner.context.has_messages():
|
||||
# Run one iteration to process pending messages
|
||||
# This will populate RequestInfoExecutor._request_events properly
|
||||
await self._runner._run_iteration() # type: ignore
|
||||
|
||||
if responses:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if request_info_executor:
|
||||
for request_id, response_data in responses.items():
|
||||
ctx: WorkflowContext[Any] = WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for new workflow span
|
||||
source_span_ids=None, # No source span for response handling
|
||||
)
|
||||
|
||||
if not await request_info_executor.has_pending_request(request_id, ctx):
|
||||
logger.debug(
|
||||
f"Skipping pre-supplied response for request {request_id}; "
|
||||
f"no pending request found after checkpoint restoration."
|
||||
)
|
||||
continue
|
||||
|
||||
await request_info_executor.handle_response(
|
||||
response_data,
|
||||
request_id,
|
||||
ctx,
|
||||
)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=checkpoint_restoration,
|
||||
initial_executor_fn=functools.partial(
|
||||
self._checkpoint_restoration, checkpoint_id, checkpoint_storage
|
||||
),
|
||||
reset_context=False, # Don't reset context when resuming from checkpoint
|
||||
)
|
||||
]
|
||||
@@ -676,35 +555,10 @@ class Workflow(DictConvertible):
|
||||
"""
|
||||
self._ensure_not_running()
|
||||
try:
|
||||
|
||||
async def send_responses_internal() -> None:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if not request_info_executor:
|
||||
raise ValueError("No RequestInfoExecutor found in workflow.")
|
||||
|
||||
async def _handle_response(response: Any, request_id: str) -> None:
|
||||
"""Handle the response from the RequestInfoExecutor."""
|
||||
await request_info_executor.handle_response(
|
||||
response,
|
||||
request_id,
|
||||
WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for new workflow span
|
||||
source_span_ids=None, # No source span for response handling
|
||||
),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[
|
||||
_handle_response(response, request_id) for request_id, response in responses.items()
|
||||
])
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=send_responses_internal,
|
||||
initial_executor_fn=functools.partial(self._send_responses_internal, responses),
|
||||
reset_context=False, # Don't reset context when sending responses
|
||||
)
|
||||
]
|
||||
@@ -714,6 +568,45 @@ class Workflow(DictConvertible):
|
||||
finally:
|
||||
self._reset_running_flag()
|
||||
|
||||
# endregion: Run
|
||||
|
||||
async def _send_responses_internal(self, responses: dict[str, Any]) -> None:
|
||||
"""Internal method to validate and send responses to the executors."""
|
||||
pending_requests = await self._runner_context.get_pending_request_info_events()
|
||||
if not pending_requests:
|
||||
raise RuntimeError("No pending requests found in workflow context.")
|
||||
|
||||
# Validate responses against pending requests
|
||||
for request_id, response in responses.items():
|
||||
if request_id not in pending_requests:
|
||||
raise ValueError(f"Response provided for unknown request ID: {request_id}")
|
||||
pending_request = pending_requests[request_id]
|
||||
if not isinstance(response, pending_request.response_type):
|
||||
raise ValueError(
|
||||
f"Response type mismatch for request ID {request_id}: "
|
||||
f"expected {pending_request.response_type}, got {type(response)}"
|
||||
)
|
||||
|
||||
await asyncio.gather(*[
|
||||
self._runner_context.send_request_info_response(request_id, response)
|
||||
for request_id, response in responses.items()
|
||||
])
|
||||
|
||||
async def _checkpoint_restoration(self, checkpoint_id: str, checkpoint_storage: CheckpointStorage | None) -> None:
|
||||
"""Internal method to restore a run from a checkpoint."""
|
||||
has_checkpointing = self._runner.context.has_checkpointing()
|
||||
|
||||
if not has_checkpointing and checkpoint_storage is None:
|
||||
raise ValueError(
|
||||
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
|
||||
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
|
||||
)
|
||||
|
||||
restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
if not restored:
|
||||
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
|
||||
|
||||
def _get_executor_by_id(self, executor_id: str) -> Executor:
|
||||
"""Get an executor by its ID.
|
||||
|
||||
@@ -727,19 +620,6 @@ class Workflow(DictConvertible):
|
||||
raise ValueError(f"Executor with ID {executor_id} not found.")
|
||||
return self.executors[executor_id]
|
||||
|
||||
def _find_request_info_executor(self) -> RequestInfoExecutor | None:
|
||||
"""Find the RequestInfoExecutor instance in this workflow.
|
||||
|
||||
Returns:
|
||||
The RequestInfoExecutor instance if found, None otherwise.
|
||||
"""
|
||||
from ._request_info_executor import RequestInfoExecutor
|
||||
|
||||
for executor in self.executors.values():
|
||||
if isinstance(executor, RequestInfoExecutor):
|
||||
return executor
|
||||
return None
|
||||
|
||||
# Graph signature helpers
|
||||
|
||||
def _compute_graph_signature(self) -> dict[str, Any]:
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._edge import (
|
||||
EdgeGroup,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
InternalEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
SwitchCaseEdgeGroupCase,
|
||||
@@ -56,7 +57,6 @@ class WorkflowBuilder:
|
||||
"""
|
||||
self._edge_groups: list[EdgeGroup] = []
|
||||
self._executors: dict[str, Executor] = {}
|
||||
self._duplicate_executor_ids: set[str] = set()
|
||||
self._start_executor: Executor | str | None = None
|
||||
self._checkpoint_storage: CheckpointStorage | None = None
|
||||
self._max_iterations: int = max_iterations
|
||||
@@ -73,10 +73,18 @@ class WorkflowBuilder:
|
||||
def _add_executor(self, executor: Executor) -> str:
|
||||
"""Add an executor to the map and return its ID."""
|
||||
existing = self._executors.get(executor.id)
|
||||
if existing is not None and existing is not executor:
|
||||
self._duplicate_executor_ids.add(executor.id)
|
||||
else:
|
||||
self._executors[executor.id] = executor
|
||||
if existing is not None:
|
||||
if existing is executor:
|
||||
# Already added
|
||||
return executor.id
|
||||
# ID conflict
|
||||
raise ValueError(f"Duplicate executor ID '{executor.id}' detected in workflow.")
|
||||
|
||||
# New executor
|
||||
self._executors[executor.id] = executor
|
||||
# Add an internal edge group for each unique executor
|
||||
self._edge_groups.append(InternalEdgeGroup(executor.id)) # type: ignore[call-arg]
|
||||
|
||||
return executor.id
|
||||
|
||||
def _maybe_wrap_agent(
|
||||
@@ -408,7 +416,6 @@ class WorkflowBuilder:
|
||||
self._edge_groups,
|
||||
self._executors,
|
||||
self._start_executor,
|
||||
duplicate_executor_ids=tuple(self._duplicate_executor_ids),
|
||||
)
|
||||
|
||||
# Add validation completed event
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
import uuid
|
||||
from types import UnionType
|
||||
from typing import Any, Generic, Union, cast, get_args, get_origin
|
||||
from typing import TYPE_CHECKING, Any, Generic, Union, cast, get_args, get_origin
|
||||
|
||||
from opentelemetry.propagate import inject
|
||||
from opentelemetry.trace import SpanKind
|
||||
@@ -13,6 +13,7 @@ from typing_extensions import Never, TypeVar
|
||||
from ..observability import OtelAttr, create_workflow_span
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
@@ -26,6 +27,9 @@ from ._events import (
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import Executor
|
||||
|
||||
T_Out = TypeVar("T_Out", default=Never)
|
||||
T_W_Out = TypeVar("T_W_Out", default=Never)
|
||||
|
||||
@@ -198,68 +202,6 @@ def validate_workflow_context_annotation(
|
||||
return infer_output_types_from_ctx_annotation(annotation)
|
||||
|
||||
|
||||
def validate_function_signature(
|
||||
func: Callable[..., Any], context_description: str
|
||||
) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
context_description: Description for error messages (e.g., "Function", "Handler method")
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
# Determine expected parameter count based on context
|
||||
expected_counts: tuple[int, ...]
|
||||
if context_description.startswith("Function"):
|
||||
# Function executor: (message) or (message, ctx)
|
||||
expected_counts = (1, 2)
|
||||
param_description = "(message: T) or (message: T, ctx: WorkflowContext[U])"
|
||||
else:
|
||||
# Handler method: (self, message, ctx)
|
||||
expected_counts = (3,)
|
||||
param_description = "(self, message: T, ctx: WorkflowContext[U])"
|
||||
|
||||
if len(params) not in expected_counts:
|
||||
raise ValueError(
|
||||
f"{context_description} {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Extract message parameter (index 0 for functions, index 1 for methods)
|
||||
message_param_idx = 0 if context_description.startswith("Function") else 1
|
||||
message_param = params[message_param_idx]
|
||||
|
||||
# Check message parameter has type annotation
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"{context_description} {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
message_type = message_param.annotation
|
||||
|
||||
# Check if there's a context parameter
|
||||
ctx_param_idx = message_param_idx + 1
|
||||
if len(params) > ctx_param_idx:
|
||||
ctx_param = params[ctx_param_idx]
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", context_description
|
||||
)
|
||||
ctx_annotation = ctx_param.annotation
|
||||
else:
|
||||
# No context parameter (only valid for function executors)
|
||||
if not context_description.startswith("Function"):
|
||||
raise ValueError(f"{context_description} {func.__name__} must have a WorkflowContext parameter")
|
||||
output_types, workflow_output_types = [], []
|
||||
ctx_annotation = None
|
||||
|
||||
return message_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
|
||||
tuple[type[WorkflowEvent], ...],
|
||||
tuple(get_args(WorkflowLifecycleEvent))
|
||||
@@ -320,7 +262,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor_id: str,
|
||||
executor: "Executor",
|
||||
source_executor_ids: list[str],
|
||||
shared_state: SharedState,
|
||||
runner_context: RunnerContext,
|
||||
@@ -330,7 +272,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
"""Initialize the executor context with the given workflow context.
|
||||
|
||||
Args:
|
||||
executor_id: The unique identifier of the executor that this context belongs to.
|
||||
executor: The executor instance that this context belongs to.
|
||||
source_executor_ids: The IDs of the source executors that sent messages to this executor.
|
||||
This is a list to support fan_in scenarios where multiple sources send aggregated
|
||||
messages to the same executor.
|
||||
@@ -339,7 +281,8 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
|
||||
"""
|
||||
self._executor_id = executor_id
|
||||
self._executor = executor
|
||||
self._executor_id = executor.id
|
||||
self._source_executor_ids = source_executor_ids
|
||||
self._runner_context = runner_context
|
||||
self._shared_state = shared_state
|
||||
@@ -405,6 +348,38 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
return
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def request_info(self, request_data: Any, request_type: type, response_type: type) -> None:
|
||||
"""Request information from outside of the workflow.
|
||||
|
||||
Calling this method will cause the workflow to emit a RequestInfoEvent, carrying the
|
||||
provided request_data and request_type. External systems listening for such events
|
||||
can then process the request and respond accordingly.
|
||||
|
||||
Executors must have the corresponding response handlers defined using the
|
||||
@response_handler decorator to handle the incoming responses.
|
||||
|
||||
Args:
|
||||
request_data: The data associated with the information request.
|
||||
request_type: The type of the request, used to match with response handlers.
|
||||
response_type: The expected type of the response, used for validation.
|
||||
"""
|
||||
if not self._executor.is_request_supported(request_type, response_type):
|
||||
logger.warning(
|
||||
f"Executor '{self._executor_id}' requested info of type {request_type.__name__} "
|
||||
f"with expected response type {response_type.__name__}, but no matching "
|
||||
"response handler is defined. The request will not be ignored but responses will "
|
||||
"not be processed. Please define a response handler using the @response_handler decorator."
|
||||
)
|
||||
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_id=str(uuid.uuid4()),
|
||||
source_executor_id=self._executor_id,
|
||||
request_type=request_type,
|
||||
request_data=request_data,
|
||||
response_type=response_type,
|
||||
)
|
||||
await self._runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
async def get_shared_state(self, key: str) -> Any:
|
||||
"""Get a value from the shared state."""
|
||||
return await self._shared_state.get(key)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowErrorEvent,
|
||||
@@ -21,12 +21,9 @@ from ._executor import (
|
||||
Executor,
|
||||
handler,
|
||||
)
|
||||
from ._request_info_executor import (
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
)
|
||||
from ._runner_context import Message
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._workflow import WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -36,10 +33,66 @@ logger = logging.getLogger(__name__)
|
||||
class ExecutionContext:
|
||||
"""Context for tracking a single sub-workflow execution."""
|
||||
|
||||
# The ID of the execution context
|
||||
execution_id: str
|
||||
|
||||
# Responses that have been collected so far for requests that
|
||||
# were sent out in the previous iteration
|
||||
collected_responses: dict[str, Any] # request_id -> response_data
|
||||
|
||||
# Number of responses to be expected. If the WorkflowExecutor has
|
||||
# not received all responses, it won't run the sub workflow.
|
||||
expected_response_count: int
|
||||
pending_requests: dict[str, Any] # request_id -> original request data
|
||||
|
||||
# Pending requests to be fulfilled. This will get updated as the
|
||||
# WorkflowExecutor receives responses.
|
||||
pending_requests: dict[str, RequestInfoEvent] # request_id -> request_info_event
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubWorkflowResponseMessage:
|
||||
"""Message sent from a parent workflow to a sub-workflow via WorkflowExecutor to provide requested information.
|
||||
|
||||
This message wraps the response data along with the original RequestInfoEvent emitted by the sub-workflow executor.
|
||||
|
||||
Attributes:
|
||||
data: The response data to the original request.
|
||||
source_event: The original RequestInfoEvent emitted by the sub-workflow executor.
|
||||
"""
|
||||
|
||||
data: Any
|
||||
source_event: RequestInfoEvent
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubWorkflowRequestMessage:
|
||||
"""Message sent from a sub-workflow to an executor in the parent workflow to request information.
|
||||
|
||||
This message wraps a RequestInfoEvent emitted by the executor in the sub-workflow.
|
||||
|
||||
Attributes:
|
||||
source_event: The original RequestInfoEvent emitted by the sub-workflow executor.
|
||||
executor_id: The ID of the WorkflowExecutor in the parent workflow that is
|
||||
responsible for this sub-workflow. This can be used to ensure that the response
|
||||
is sent back to the correct sub-workflow instance.
|
||||
"""
|
||||
|
||||
source_event: RequestInfoEvent
|
||||
executor_id: str
|
||||
|
||||
def create_response(self, data: Any) -> SubWorkflowResponseMessage:
|
||||
"""Validate and wrap response data into a SubWorkflowResponseMessage.
|
||||
|
||||
Validation ensures the response data type matches the expected type from the original request.
|
||||
"""
|
||||
expected_data_type = self.source_event.response_type
|
||||
if not is_instance_of(data, expected_data_type):
|
||||
raise TypeError(
|
||||
f"Response data type {type(data)} does not match expected type {expected_data_type} "
|
||||
f"for request_id {self.source_event.request_id}"
|
||||
)
|
||||
|
||||
return SubWorkflowResponseMessage(data=data, source_event=self.source_event)
|
||||
|
||||
|
||||
class WorkflowExecutor(Executor):
|
||||
@@ -56,7 +109,7 @@ class WorkflowExecutor(Executor):
|
||||
1. Starts the wrapped workflow with the input message
|
||||
2. Runs the sub-workflow to completion or until it needs external input
|
||||
3. Processes the sub-workflow's complete event stream after execution
|
||||
4. Forwards outputs to the parent workflow's event stream
|
||||
4. Forwards outputs to the parent workflow as messages
|
||||
5. Handles external requests by routing them to the parent workflow
|
||||
6. Accumulates responses and resumes sub-workflow execution
|
||||
|
||||
@@ -66,28 +119,41 @@ class WorkflowExecutor(Executor):
|
||||
### Output Forwarding
|
||||
All outputs from the sub-workflow are automatically forwarded to the parent:
|
||||
|
||||
#### When `allow_direct_output` is False (default):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Sub-workflow yields outputs
|
||||
# An executor in the sub-workflow yields outputs
|
||||
await ctx.yield_output("sub-workflow result")
|
||||
|
||||
# WorkflowExecutor forwards to parent via ctx.send_message()
|
||||
# Parent receives the output as a regular message
|
||||
|
||||
#### When `allow_direct_output` is True:
|
||||
|
||||
.. code-block:: python
|
||||
# An executor in the sub-workflow yields outputs
|
||||
await ctx.yield_output("sub-workflow result")
|
||||
|
||||
# WorkflowExecutor yields output directly to parent workflow's event stream
|
||||
# The output of the sub-workflow is considered the output of the parent workflow
|
||||
# Caller of the parent workflow receives the output directly
|
||||
|
||||
### Request/Response Coordination
|
||||
When sub-workflows need external information:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Sub-workflow makes request
|
||||
# An executor in the sub-workflow makes request
|
||||
request = MyDataRequest(query="user info")
|
||||
# RequestInfoExecutor emits RequestInfoEvent
|
||||
|
||||
# WorkflowExecutor sets source_executor_id and forwards to parent
|
||||
request.source_executor_id = "child_workflow_executor_id"
|
||||
# Parent workflow can handle via @handler for RequestInfoMessage subclasses,
|
||||
# or directly forward to external source via a RequestInfoExecutor in the parent
|
||||
# workflow.
|
||||
# WorkflowExecutor captures RequestInfoEvent and wraps it in a SubWorkflowRequestMessage
|
||||
# then send it to the receiving executor in parent workflow. The executor in parent workflow
|
||||
# can handle the request locally or forward it to an external source.
|
||||
# The WorkflowExecutor tracks the pending request, and implements a response handler.
|
||||
# When the response is received, it executes the response handler to accumulate responses
|
||||
# and resume the sub-workflow when all expected responses are received.
|
||||
# The response handler expects a SubWorkflowResponseMessage wrapping the response data.
|
||||
|
||||
### State Management
|
||||
WorkflowExecutor maintains execution state across request/response cycles:
|
||||
@@ -114,8 +180,8 @@ class WorkflowExecutor(Executor):
|
||||
.. code-block:: python
|
||||
|
||||
# Includes all sub-workflow output types
|
||||
# Plus RequestInfoMessage if sub-workflow can make requests
|
||||
output_types = workflow.output_types + [RequestInfoMessage] # if applicable
|
||||
# Plus SubWorkflowRequestMessage if sub-workflow can make requests
|
||||
output_types = workflow.output_types + [SubWorkflowRequestMessage] # if applicable
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
@@ -183,19 +249,19 @@ class WorkflowExecutor(Executor):
|
||||
```python
|
||||
class ParentExecutor(Executor):
|
||||
@handler
|
||||
async def handle_request(
|
||||
async def handle_subworkflow_request(
|
||||
self,
|
||||
request: MyRequestType, # Subclass of RequestInfoMessage
|
||||
ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any] | RequestInfoMessage],
|
||||
request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
# Handle request locally or forward to external source
|
||||
if self.can_handle_locally(request):
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data="local result", original_request=request, request_id=request.request_id)
|
||||
response = request.create_response(data="local response data")
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# Forward to external handler
|
||||
await ctx.send_message(request)
|
||||
await ctx.request_info(request.source_event)
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
@@ -206,24 +272,30 @@ class WorkflowExecutor(Executor):
|
||||
- Concurrent executions are fully isolated and do not interfere with each other
|
||||
"""
|
||||
|
||||
def __init__(self, workflow: "Workflow", id: str, **kwargs: Any):
|
||||
def __init__(self, workflow: "Workflow", id: str, allow_direct_output: bool = False, **kwargs: Any):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to execute as a sub-workflow.
|
||||
id: Unique identifier for this executor.
|
||||
allow_direct_output: Whether to allow direct output from the sub-workflow.
|
||||
By default, outputs from the sub-workflow are sent to
|
||||
other executors in the parent workflow as messages.
|
||||
When this is set to true, the outputs are yielded
|
||||
directly from the WorkflowExecutor to the parent
|
||||
workflow's event stream.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
"""
|
||||
super().__init__(id, **kwargs)
|
||||
self.workflow = workflow
|
||||
self.allow_direct_output = allow_direct_output
|
||||
|
||||
# Track execution contexts for concurrent sub-workflow executions
|
||||
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
|
||||
# Map request_id to execution_id for response routing
|
||||
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
|
||||
self._active_executions: int = 0 # Count of active sub-workflow executions
|
||||
self._state_loaded: bool = False
|
||||
|
||||
@property
|
||||
@@ -235,9 +307,9 @@ class WorkflowExecutor(Executor):
|
||||
"""
|
||||
input_types = list(self.workflow.input_types)
|
||||
|
||||
# WorkflowExecutor can also handle RequestResponse for sub-workflow responses
|
||||
if RequestResponse not in input_types:
|
||||
input_types.append(RequestResponse)
|
||||
# WorkflowExecutor can also handle SubWorkflowResponseMessage for sub-workflow responses
|
||||
if SubWorkflowResponseMessage not in input_types:
|
||||
input_types.append(SubWorkflowResponseMessage)
|
||||
|
||||
return input_types
|
||||
|
||||
@@ -247,27 +319,17 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
Returns:
|
||||
A list of output types that the underlying workflow can produce.
|
||||
Includes specific RequestInfoMessage subtypes if the sub-workflow contains RequestInfoExecutor.
|
||||
Includes the SubWorkflowRequestMessage type if any executor in the
|
||||
sub-workflow is request-response capable.
|
||||
"""
|
||||
output_types = list(self.workflow.output_types)
|
||||
|
||||
# Check if the sub-workflow contains a RequestInfoExecutor
|
||||
# If so, collect the specific RequestInfoMessage subtypes from all executors
|
||||
has_request_info_executor = any(
|
||||
isinstance(executor, RequestInfoExecutor) for executor in self.workflow.executors.values()
|
||||
is_request_response_capable = any(
|
||||
executor.is_request_response_capable for executor in self.workflow.executors.values()
|
||||
)
|
||||
|
||||
if has_request_info_executor:
|
||||
# Collect all RequestInfoMessage subtypes from executor output types
|
||||
for executor in self.workflow.executors.values():
|
||||
for output_type in executor.output_types:
|
||||
# Check if this is a RequestInfoMessage subclass
|
||||
if (
|
||||
inspect.isclass(output_type)
|
||||
and issubclass(output_type, RequestInfoMessage)
|
||||
and output_type not in output_types
|
||||
):
|
||||
output_types.append(output_type)
|
||||
if is_request_response_capable:
|
||||
output_types.append(SubWorkflowRequestMessage)
|
||||
|
||||
return output_types
|
||||
|
||||
@@ -276,20 +338,20 @@ class WorkflowExecutor(Executor):
|
||||
data["workflow"] = self.workflow.to_dict()
|
||||
return data
|
||||
|
||||
def can_handle(self, message: Any) -> bool:
|
||||
def can_handle(self, message: Message) -> bool:
|
||||
"""Override can_handle to only accept messages that the wrapped workflow can handle.
|
||||
|
||||
This prevents the WorkflowExecutor from accepting messages that should go to other
|
||||
executors (like RequestInfoExecutor).
|
||||
executors because the handler `process_workflow` has no type restrictions.
|
||||
"""
|
||||
# Always handle RequestResponse (for the handle_response handler)
|
||||
if isinstance(message, RequestResponse):
|
||||
# Always handle SubWorkflowResponseMessage
|
||||
if isinstance(message.data, SubWorkflowResponseMessage):
|
||||
return True
|
||||
|
||||
# For other messages, only handle if the wrapped workflow can accept them as input
|
||||
return any(is_instance_of(message, input_type) for input_type in self.workflow.input_types)
|
||||
return any(is_instance_of(message.data, input_type) for input_type in self.workflow.input_types)
|
||||
|
||||
@handler # No output_types - can send any completion data type
|
||||
@handler
|
||||
async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) -> None:
|
||||
"""Execute the sub-workflow with raw input data.
|
||||
|
||||
@@ -300,11 +362,6 @@ class WorkflowExecutor(Executor):
|
||||
input_data: The input data to send to the sub-workflow.
|
||||
ctx: The workflow context from the parent.
|
||||
"""
|
||||
# Skip RequestResponse - it has a specific handler
|
||||
if isinstance(input_data, RequestResponse):
|
||||
logger.debug(f"WorkflowExecutor {self.id} ignoring input of type {type(input_data)}")
|
||||
return
|
||||
|
||||
await self._ensure_state_loaded(ctx)
|
||||
|
||||
# Create execution context for this sub-workflow run
|
||||
@@ -317,9 +374,6 @@ class WorkflowExecutor(Executor):
|
||||
)
|
||||
self._execution_contexts[execution_id] = execution_context
|
||||
|
||||
# Track this execution
|
||||
self._active_executions += 1
|
||||
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
|
||||
|
||||
try:
|
||||
@@ -339,100 +393,9 @@ class WorkflowExecutor(Executor):
|
||||
exec_ctx = self._execution_contexts[execution_id]
|
||||
if not exec_ctx.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
self._active_executions -= 1
|
||||
|
||||
async def _process_workflow_result(
|
||||
self, result: Any, execution_context: ExecutionContext, ctx: WorkflowContext[Any]
|
||||
) -> None:
|
||||
"""Process the result from a workflow execution.
|
||||
|
||||
This method handles the common logic for processing outputs, request info events,
|
||||
and final states that is shared between process_workflow and handle_response.
|
||||
|
||||
Args:
|
||||
result: The workflow execution result.
|
||||
execution_context: The execution context for this sub-workflow run.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Collect all events from the workflow
|
||||
request_info_events = result.get_request_info_events()
|
||||
outputs = result.get_outputs()
|
||||
final_state = result.get_final_state()
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} processing workflow result with "
|
||||
f"{len(outputs)} outputs and {len(request_info_events)} request info events, "
|
||||
f"final state: {final_state}"
|
||||
)
|
||||
|
||||
# Process outputs
|
||||
for output in outputs:
|
||||
await ctx.send_message(output)
|
||||
|
||||
# Process request info events
|
||||
for event in request_info_events:
|
||||
# Track the pending request in execution context
|
||||
execution_context.pending_requests[event.request_id] = event.data
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[event.request_id] = execution_context.execution_id
|
||||
# Set source_executor_id for response routing and send to parent
|
||||
if not isinstance(event.data, RequestInfoMessage):
|
||||
raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}")
|
||||
# Set the source_executor_id to this WorkflowExecutor's ID for response routing
|
||||
event.data.source_executor_id = self.id
|
||||
await ctx.send_message(event.data)
|
||||
|
||||
# Update expected response count for this execution
|
||||
execution_context.expected_response_count = len(request_info_events)
|
||||
|
||||
# Handle final state
|
||||
if final_state == WorkflowRunState.FAILED:
|
||||
# Find the WorkflowFailedEvent.
|
||||
failed_events = [e for e in result if isinstance(e, WorkflowFailedEvent)]
|
||||
if failed_events:
|
||||
failed_event = failed_events[0]
|
||||
error_type = failed_event.details.error_type
|
||||
error_message = failed_event.details.message
|
||||
exception = Exception(
|
||||
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
|
||||
)
|
||||
error_event = WorkflowErrorEvent(
|
||||
data=exception,
|
||||
)
|
||||
await ctx.add_event(error_event)
|
||||
self._active_executions -= 1
|
||||
elif final_state == WorkflowRunState.IDLE:
|
||||
# Sub-workflow is idle - nothing more to do now
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} is idle with {self._active_executions} active executions")
|
||||
self._active_executions -= 1 # Treat idle as completion for now
|
||||
elif final_state == WorkflowRunState.CANCELLED:
|
||||
# Sub-workflow was cancelled - treat as completion
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} was cancelled with {self._active_executions} active executions"
|
||||
)
|
||||
self._active_executions -= 1
|
||||
elif final_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
|
||||
# Sub-workflow is still running with pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
|
||||
f"pending requests with {self._active_executions} active executions"
|
||||
)
|
||||
elif final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Sub-workflow is idle but has pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
|
||||
f"{len(request_info_events)} with {self._active_executions} active executions"
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected final state: {final_state}")
|
||||
|
||||
await self._persist_execution_state(ctx)
|
||||
|
||||
@handler
|
||||
async def handle_response(
|
||||
self,
|
||||
response: RequestResponse[RequestInfoMessage, Any],
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
async def handle_response(self, response: SubWorkflowResponseMessage, ctx: WorkflowContext[Any]) -> None:
|
||||
"""Handle response from parent for a forwarded request.
|
||||
|
||||
This handler accumulates responses and only resumes the sub-workflow
|
||||
@@ -445,29 +408,31 @@ class WorkflowExecutor(Executor):
|
||||
await self._ensure_state_loaded(ctx)
|
||||
|
||||
# Find the execution context for this request
|
||||
execution_id = self._request_to_execution.get(response.request_id)
|
||||
original_request = response.source_event
|
||||
execution_id = self._request_to_execution.get(original_request.request_id)
|
||||
if not execution_id or execution_id not in self._execution_contexts:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {response.request_id}, ignoring"
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {original_request.request_id}. "
|
||||
"This response will be ignored."
|
||||
)
|
||||
return
|
||||
|
||||
execution_context = self._execution_contexts[execution_id]
|
||||
|
||||
# Check if we have this pending request in the execution context
|
||||
if response.request_id not in execution_context.pending_requests:
|
||||
if original_request.request_id not in execution_context.pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{response.request_id} in execution {execution_id}, ignoring"
|
||||
f"{original_request.request_id} in execution {execution_id}, ignoring"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove the request from pending list and request mapping
|
||||
execution_context.pending_requests.pop(response.request_id, None)
|
||||
self._request_to_execution.pop(response.request_id, None)
|
||||
execution_context.pending_requests.pop(original_request.request_id, None)
|
||||
self._request_to_execution.pop(original_request.request_id, None)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[response.request_id] = response.data
|
||||
execution_context.collected_responses[original_request.request_id] = response.data
|
||||
|
||||
await self._persist_execution_state(ctx)
|
||||
|
||||
@@ -493,7 +458,6 @@ class WorkflowExecutor(Executor):
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if not execution_context.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
self._active_executions -= 1
|
||||
|
||||
async def _ensure_state_loaded(self, ctx: WorkflowContext[Any]) -> None:
|
||||
if self._state_loaded:
|
||||
@@ -507,164 +471,168 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
if isinstance(state, dict) and state:
|
||||
with contextlib.suppress(Exception):
|
||||
self.restore_state(state)
|
||||
await self.restore_state(state)
|
||||
self._state_loaded = True
|
||||
else:
|
||||
self._state_loaded = True
|
||||
|
||||
def restore_state(self, state: dict[str, Any]) -> None:
|
||||
async def restore_state(self, state: dict[str, Any]) -> None:
|
||||
"""Restore pending request bookkeeping from a checkpoint snapshot."""
|
||||
self._execution_contexts = {}
|
||||
self._request_to_execution = {}
|
||||
|
||||
executions_payload = state.get("executions")
|
||||
if isinstance(executions_payload, Mapping) and executions_payload:
|
||||
for execution_id, payload in executions_payload.items():
|
||||
if not isinstance(execution_id, str) or not isinstance(payload, Mapping):
|
||||
continue
|
||||
|
||||
pending_ids_raw = payload.get("pending_request_ids", [])
|
||||
if not isinstance(pending_ids_raw, list):
|
||||
continue
|
||||
pending_ids = [rid for rid in pending_ids_raw if isinstance(rid, str)]
|
||||
|
||||
expected = payload.get("expected_response_count", len(pending_ids))
|
||||
try:
|
||||
expected_count = int(expected)
|
||||
except (TypeError, ValueError):
|
||||
expected_count = len(pending_ids)
|
||||
|
||||
collected_ids_raw = payload.get("collected_response_ids", [])
|
||||
collected: dict[str, Any] = {}
|
||||
if isinstance(collected_ids_raw, list):
|
||||
for rid in collected_ids_raw:
|
||||
if isinstance(rid, str):
|
||||
collected[rid] = None
|
||||
|
||||
exec_ctx = ExecutionContext(
|
||||
execution_id=execution_id,
|
||||
collected_responses=collected,
|
||||
expected_response_count=expected_count,
|
||||
pending_requests={rid: None for rid in pending_ids},
|
||||
)
|
||||
|
||||
if exec_ctx.pending_requests or exec_ctx.collected_responses:
|
||||
self._execution_contexts[execution_id] = exec_ctx
|
||||
for rid in exec_ctx.pending_requests:
|
||||
self._request_to_execution[rid] = execution_id
|
||||
else:
|
||||
pending_ids = state.get("pending_request_ids", [])
|
||||
if isinstance(pending_ids, list):
|
||||
pending = [rid for rid in pending_ids if isinstance(rid, str)]
|
||||
if pending:
|
||||
try:
|
||||
expected = int(state.get("expected_response_count", len(pending)))
|
||||
except (TypeError, ValueError):
|
||||
expected = len(pending)
|
||||
|
||||
execution_id = str(uuid.uuid4())
|
||||
exec_ctx = ExecutionContext(
|
||||
execution_id=execution_id,
|
||||
collected_responses={},
|
||||
expected_response_count=expected,
|
||||
pending_requests={rid: None for rid in pending},
|
||||
)
|
||||
self._execution_contexts[execution_id] = exec_ctx
|
||||
for rid in pending:
|
||||
self._request_to_execution[rid] = execution_id
|
||||
# Validate the state contains the right keys
|
||||
if "execution_contexts" not in state:
|
||||
raise KeyError("Missing 'execution_contexts' in WorkflowExecutor state.")
|
||||
if "request_to_execution" not in state:
|
||||
raise KeyError("Missing 'request_to_execution' in WorkflowExecutor state.")
|
||||
|
||||
# Validate the execution contexts stored in the state have the right keys and values
|
||||
execution_contexts: dict[str, ExecutionContext] | None = None
|
||||
try:
|
||||
self._active_executions = int(state.get("active_executions", len(self._execution_contexts)))
|
||||
except (TypeError, ValueError):
|
||||
self._active_executions = len(self._execution_contexts)
|
||||
execution_contexts = {
|
||||
key: decode_checkpoint_value(value) for key, value in state["execution_contexts"].items()
|
||||
}
|
||||
except Exception as ex:
|
||||
raise RuntimeError("Failed to deserialize execution context.") from ex
|
||||
|
||||
helper_states = state.get("request_info_executor_states", {})
|
||||
restored_request_data: dict[str, RequestInfoMessage] = {}
|
||||
if isinstance(helper_states, Mapping):
|
||||
for exec_id, helper_state in helper_states.items():
|
||||
helper_executor = self.workflow.executors.get(exec_id)
|
||||
if not isinstance(helper_executor, RequestInfoExecutor) or not isinstance(helper_state, Mapping):
|
||||
continue
|
||||
with contextlib.suppress(Exception):
|
||||
helper_executor.restore_state(dict(helper_state))
|
||||
for req_id, event in getattr(helper_executor, "_request_events", {}).items(): # type: ignore[attr-defined]
|
||||
if (
|
||||
isinstance(req_id, str)
|
||||
and isinstance(event, RequestInfoEvent)
|
||||
and isinstance(event.data, RequestInfoMessage)
|
||||
):
|
||||
restored_request_data[req_id] = event.data
|
||||
if not all(
|
||||
isinstance(key, str) and isinstance(value, ExecutionContext) for key, value in execution_contexts.items()
|
||||
):
|
||||
raise ValueError("Execution contexts must have 'str' as key and 'ExecutionContext' as value.")
|
||||
if not all(key == value.execution_id for key, value in execution_contexts.items()):
|
||||
raise ValueError("Execution contexts must have matching keys and IDs.")
|
||||
|
||||
if restored_request_data:
|
||||
for req_id, data in restored_request_data.items():
|
||||
execution_id = self._request_to_execution.get(req_id)
|
||||
if execution_id and execution_id in self._execution_contexts:
|
||||
self._execution_contexts[execution_id].pending_requests[req_id] = data
|
||||
# Validate the request_to_execution map contain the right data
|
||||
request_to_execution = state["request_to_execution"]
|
||||
if not all(isinstance(key, str) and isinstance(value, str) for key, value in request_to_execution.items()):
|
||||
raise ValueError("Request to execution map must have 'str' as key and 'str' as value.")
|
||||
if not all(value in execution_contexts for value in request_to_execution.values()):
|
||||
raise ValueError(
|
||||
"'request_to_execution` contains unknown execution ID that is not part of the execution contexts."
|
||||
)
|
||||
|
||||
for execution_id, exec_ctx in self._execution_contexts.items():
|
||||
for req_id in exec_ctx.pending_requests:
|
||||
self._request_to_execution.setdefault(req_id, execution_id)
|
||||
self._execution_contexts = execution_contexts
|
||||
self._request_to_execution = request_to_execution
|
||||
|
||||
request_map = state.get("request_to_execution")
|
||||
if isinstance(request_map, Mapping):
|
||||
for req_id, execution_id in request_map.items():
|
||||
if (
|
||||
isinstance(req_id, str)
|
||||
and isinstance(execution_id, str)
|
||||
and execution_id in self._execution_contexts
|
||||
):
|
||||
self._request_to_execution.setdefault(req_id, execution_id)
|
||||
# Add the `request_info_event`s back to the sub workflow.
|
||||
# This is only a temporary solution to rehydrate the sub workflow with the requests.
|
||||
# The proper way would be to rehydrate the workflow from a checkpoint on a Workflow
|
||||
# API instead of the '_runner_context' object that should be hidden. And the sub workflow
|
||||
# should be rehydrated from a checkpoint object instead of from a subset of the state.
|
||||
# TODO(@taochen): Issue #1614 - how to handle the case when the parent workflow has checkpointing
|
||||
# set up but not the sub workflow?
|
||||
request_info_events = [
|
||||
request_info_event
|
||||
for execution_context in self._execution_contexts.values()
|
||||
for request_info_event in execution_context.pending_requests.values()
|
||||
]
|
||||
await asyncio.gather(*[
|
||||
self.workflow._runner_context.add_request_info_event(event) for event in request_info_events
|
||||
])
|
||||
|
||||
self._state_loaded = True
|
||||
|
||||
def _build_state_snapshot(self) -> dict[str, Any]:
|
||||
executions: dict[str, Any] = {}
|
||||
pending_request_ids: list[str] = []
|
||||
|
||||
for execution_id, exec_ctx in self._execution_contexts.items():
|
||||
if not exec_ctx.pending_requests and not exec_ctx.collected_responses:
|
||||
continue
|
||||
|
||||
request_ids = list(exec_ctx.pending_requests.keys())
|
||||
pending_request_ids.extend(request_ids)
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"pending_request_ids": request_ids,
|
||||
"expected_response_count": exec_ctx.expected_response_count,
|
||||
}
|
||||
|
||||
if exec_ctx.collected_responses:
|
||||
summary["collected_response_ids"] = list(exec_ctx.collected_responses.keys())
|
||||
|
||||
executions[execution_id] = summary
|
||||
|
||||
helper_states: dict[str, Any] = {}
|
||||
for exec_id, executor in self.workflow.executors.items():
|
||||
if isinstance(executor, RequestInfoExecutor):
|
||||
with contextlib.suppress(Exception):
|
||||
snapshot = executor.snapshot_state()
|
||||
if snapshot:
|
||||
helper_states[exec_id] = snapshot
|
||||
|
||||
has_state = bool(executions or helper_states or self._request_to_execution)
|
||||
if not has_state:
|
||||
return {}
|
||||
|
||||
state: dict[str, Any] = {
|
||||
"executions": executions,
|
||||
async def _persist_execution_state(self, ctx: WorkflowContext) -> None:
|
||||
"""Persist the state of the WorkflowExecutor for checkpointing purposes."""
|
||||
state = {
|
||||
"execution_contexts": {
|
||||
execution_id: encode_checkpoint_value(execution_context)
|
||||
for execution_id, execution_context in self._execution_contexts.items()
|
||||
},
|
||||
"request_to_execution": dict(self._request_to_execution),
|
||||
"pending_request_ids": pending_request_ids,
|
||||
"active_executions": self._active_executions,
|
||||
}
|
||||
|
||||
if helper_states:
|
||||
state["request_info_executor_states"] = helper_states
|
||||
|
||||
return state
|
||||
|
||||
async def _persist_execution_state(self, ctx: WorkflowContext[Any]) -> None:
|
||||
snapshot = self._build_state_snapshot()
|
||||
try:
|
||||
await ctx.set_executor_state(snapshot)
|
||||
await ctx.set_executor_state(state)
|
||||
except Exception as exc: # pragma: no cover - transport specific
|
||||
logger.warning(f"WorkflowExecutor {self.id} failed to persist state: {exc}")
|
||||
|
||||
async def _process_workflow_result(
|
||||
self,
|
||||
result: WorkflowRunResult,
|
||||
execution_context: ExecutionContext,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Process the result from a workflow execution.
|
||||
|
||||
This method handles the common logic for processing outputs, request info events,
|
||||
and final states that is shared between process_workflow and handle_response.
|
||||
|
||||
Args:
|
||||
result: The workflow execution result.
|
||||
execution_context: The execution context for this sub-workflow run.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Collect all events from the workflow
|
||||
request_info_events = result.get_request_info_events()
|
||||
outputs = result.get_outputs()
|
||||
workflow_run_state = result.get_final_state()
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} processing workflow result with "
|
||||
f"{len(outputs)} outputs and {len(request_info_events)} request info events. "
|
||||
f"Workflow run state: {workflow_run_state}"
|
||||
)
|
||||
|
||||
# Process outputs
|
||||
if self.allow_direct_output:
|
||||
# Note that the executor is allowed to continue its own execution after yielding outputs.
|
||||
await asyncio.gather(*[ctx.yield_output(output) for output in outputs])
|
||||
else:
|
||||
await asyncio.gather(*[ctx.send_message(output) for output in outputs])
|
||||
|
||||
# Process request info events
|
||||
for event in request_info_events:
|
||||
# Track the pending request in execution context
|
||||
execution_context.pending_requests[event.request_id] = event
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[event.request_id] = execution_context.execution_id
|
||||
# TODO(@taochen): There should be two ways a sub-workflow can make a request:
|
||||
# 1. In a workflow where the parent workflow has an executor that may intercept the
|
||||
# request and handle it directly, a message should be sent.
|
||||
# 2. In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
# a @response_handler would be required in the WorkflowExecutor to handle the response.
|
||||
await ctx.send_message(SubWorkflowRequestMessage(source_event=event, executor_id=self.id))
|
||||
|
||||
# Update expected response count for this execution
|
||||
execution_context.expected_response_count = len(request_info_events)
|
||||
|
||||
# Handle final state
|
||||
if workflow_run_state == WorkflowRunState.FAILED:
|
||||
# Find the WorkflowFailedEvent.
|
||||
failed_events = [e for e in result if isinstance(e, WorkflowFailedEvent)]
|
||||
if failed_events:
|
||||
failed_event = failed_events[0]
|
||||
error_type = failed_event.details.error_type
|
||||
error_message = failed_event.details.message
|
||||
exception = Exception(
|
||||
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
|
||||
)
|
||||
error_event = WorkflowErrorEvent(
|
||||
data=exception,
|
||||
)
|
||||
await ctx.add_event(error_event)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE:
|
||||
# Sub-workflow is idle - nothing more to do now
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.CANCELLED:
|
||||
# Sub-workflow was cancelled - treat as completion
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} was cancelled with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
|
||||
# Sub-workflow is still running with pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
|
||||
f"pending requests with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Sub-workflow is idle but has pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
|
||||
f"{len(request_info_events)} with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected workflow run state: {workflow_run_state}")
|
||||
|
||||
await self._persist_execution_state(ctx)
|
||||
|
||||
@@ -210,6 +210,7 @@ class OtelAttr(str, Enum):
|
||||
MESSAGE_SOURCE_ID = "message.source_id"
|
||||
MESSAGE_TARGET_ID = "message.target_id"
|
||||
MESSAGE_TYPE = "message.type"
|
||||
MESSAGE_PAYLOAD_TYPE = "message.payload_type"
|
||||
MESSAGE_DESTINATION_EXECUTOR_ID = "message.destination_executor_id"
|
||||
|
||||
# Activity events
|
||||
@@ -1567,6 +1568,7 @@ def create_processing_span(
|
||||
executor_id: str,
|
||||
executor_type: str,
|
||||
message_type: str,
|
||||
payload_type: str,
|
||||
source_trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
) -> "_AgnosticContextManager[trace.Span]":
|
||||
@@ -1575,6 +1577,14 @@ def create_processing_span(
|
||||
Processing spans are created as children of the current workflow span and
|
||||
linked (not nested) to the source publishing spans for causality tracking.
|
||||
This supports multiple links for fan-in scenarios.
|
||||
|
||||
Args:
|
||||
executor_id: The unique ID of the executor processing the message.
|
||||
executor_type: The type of the executor (class name).
|
||||
message_type: The type of the message being processed ("standard" or "response").
|
||||
payload_type: The data type of the message being processed.
|
||||
source_trace_contexts: Optional trace contexts from source spans for linking.
|
||||
source_span_ids: Optional source span IDs for linking.
|
||||
"""
|
||||
# Create links to source spans for causality without nesting
|
||||
links: list[trace.Link] = []
|
||||
@@ -1608,6 +1618,7 @@ def create_processing_span(
|
||||
OtelAttr.EXECUTOR_ID: executor_id,
|
||||
OtelAttr.EXECUTOR_TYPE: executor_type,
|
||||
OtelAttr.MESSAGE_TYPE: message_type,
|
||||
OtelAttr.MESSAGE_PAYLOAD_TYPE: payload_type,
|
||||
},
|
||||
links=links,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ def test_workflow_checkpoint_default_values():
|
||||
assert checkpoint.timestamp != ""
|
||||
assert checkpoint.messages == {}
|
||||
assert checkpoint.shared_state == {}
|
||||
assert checkpoint.pending_request_info_events == {}
|
||||
assert checkpoint.iteration_count == 0
|
||||
assert checkpoint.metadata == {}
|
||||
assert checkpoint.version == "1.0"
|
||||
@@ -32,6 +33,7 @@ def test_workflow_checkpoint_custom_values():
|
||||
workflow_id="test-workflow-456",
|
||||
timestamp=custom_timestamp,
|
||||
messages={"executor1": [{"data": "test"}]},
|
||||
pending_request_info_events={"req123": {"data": "test"}},
|
||||
shared_state={"key": "value"},
|
||||
iteration_count=5,
|
||||
metadata={"test": True},
|
||||
@@ -43,6 +45,7 @@ def test_workflow_checkpoint_custom_values():
|
||||
assert checkpoint.timestamp == custom_timestamp
|
||||
assert checkpoint.messages == {"executor1": [{"data": "test"}]}
|
||||
assert checkpoint.shared_state == {"key": "value"}
|
||||
assert checkpoint.pending_request_info_events == {"req123": {"data": "test"}}
|
||||
assert checkpoint.iteration_count == 5
|
||||
assert checkpoint.metadata == {"test": True}
|
||||
assert checkpoint.version == "2.0"
|
||||
@@ -50,7 +53,11 @@ def test_workflow_checkpoint_custom_values():
|
||||
|
||||
async def test_memory_checkpoint_storage_save_and_load():
|
||||
storage = InMemoryCheckpointStorage()
|
||||
checkpoint = WorkflowCheckpoint(workflow_id="test-workflow", messages={"executor1": [{"data": "hello"}]})
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_id="test-workflow",
|
||||
messages={"executor1": [{"data": "hello"}]},
|
||||
pending_request_info_events={"req123": {"data": "test"}},
|
||||
)
|
||||
|
||||
# Save checkpoint
|
||||
saved_id = await storage.save_checkpoint(checkpoint)
|
||||
@@ -62,6 +69,7 @@ async def test_memory_checkpoint_storage_save_and_load():
|
||||
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
|
||||
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
|
||||
assert loaded_checkpoint.messages == checkpoint.messages
|
||||
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
|
||||
|
||||
|
||||
async def test_memory_checkpoint_storage_load_nonexistent():
|
||||
@@ -152,6 +160,7 @@ async def test_file_checkpoint_storage_save_and_load():
|
||||
workflow_id="test-workflow",
|
||||
messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]},
|
||||
shared_state={"key": "value"},
|
||||
pending_request_info_events={"req123": {"data": "test"}},
|
||||
)
|
||||
|
||||
# Save checkpoint
|
||||
@@ -169,6 +178,7 @@ async def test_file_checkpoint_storage_save_and_load():
|
||||
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
|
||||
assert loaded_checkpoint.messages == checkpoint.messages
|
||||
assert loaded_checkpoint.shared_state == checkpoint.shared_state
|
||||
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
|
||||
|
||||
|
||||
async def test_file_checkpoint_storage_load_nonexistent():
|
||||
@@ -284,6 +294,7 @@ async def test_file_checkpoint_storage_json_serialization():
|
||||
workflow_id="complex-workflow",
|
||||
messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]},
|
||||
shared_state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
|
||||
pending_request_info_events={"req123": {"data": "test"}},
|
||||
)
|
||||
|
||||
# Save and load
|
||||
@@ -303,6 +314,7 @@ async def test_file_checkpoint_storage_json_serialization():
|
||||
assert data["shared_state"]["list"] == [1, 2, 3]
|
||||
assert data["shared_state"]["bool"] is True
|
||||
assert data["shared_state"]["null"] is None
|
||||
assert data["pending_request_info_events"]["req123"]["data"] == "test"
|
||||
|
||||
|
||||
def test_checkpoint_storage_protocol_compliance():
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from dataclasses import dataclass # noqa: I001
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework._workflows._request_info_executor import RequestInfoMessage, RequestResponse
|
||||
from agent_framework._workflows._checkpoint_encoding import (
|
||||
decode_checkpoint_value,
|
||||
encode_checkpoint_value,
|
||||
@@ -11,30 +10,45 @@ from agent_framework._workflows._checkpoint_encoding import (
|
||||
from agent_framework._workflows._typing_utils import is_instance_of
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SampleRequest(RequestInfoMessage):
|
||||
@dataclass
|
||||
class SampleRequest:
|
||||
"""Sample request message for testing checkpoint encoding/decoding."""
|
||||
|
||||
request_id: str
|
||||
prompt: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampleResponse:
|
||||
"""Sample response message for testing checkpoint encoding/decoding."""
|
||||
|
||||
data: str
|
||||
original_request: SampleRequest
|
||||
request_id: str
|
||||
|
||||
|
||||
def test_decode_dataclass_with_nested_request() -> None:
|
||||
original = RequestResponse[SampleRequest, str](
|
||||
"""Test that dataclass with nested dataclass fields can be encoded and decoded correctly."""
|
||||
original = SampleResponse(
|
||||
data="approve",
|
||||
original_request=SampleRequest(request_id="abc", prompt="prompt"),
|
||||
request_id="abc",
|
||||
)
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = cast(RequestResponse[SampleRequest, str], decode_checkpoint_value(encoded))
|
||||
decoded = cast(SampleResponse, decode_checkpoint_value(encoded))
|
||||
|
||||
assert isinstance(decoded, RequestResponse)
|
||||
assert isinstance(decoded, SampleResponse)
|
||||
assert decoded.data == "approve"
|
||||
assert decoded.request_id == "abc"
|
||||
assert isinstance(decoded.original_request, SampleRequest)
|
||||
assert decoded.original_request.prompt == "prompt"
|
||||
assert decoded.original_request.request_id == "abc"
|
||||
|
||||
|
||||
def test_is_instance_of_coerces_request_response_original_request_dict() -> None:
|
||||
response = RequestResponse[SampleRequest, str](
|
||||
def test_is_instance_of_coerces_nested_dataclass_dict() -> None:
|
||||
"""Test that is_instance_of can handle nested structures with dict conversion."""
|
||||
response = SampleResponse(
|
||||
data="approve",
|
||||
original_request=SampleRequest(request_id="req-1", prompt="prompt"),
|
||||
request_id="req-1",
|
||||
@@ -49,5 +63,66 @@ def test_is_instance_of_coerces_request_response_original_request_dict() -> None
|
||||
},
|
||||
)
|
||||
|
||||
assert is_instance_of(response, RequestResponse[SampleRequest, str])
|
||||
assert is_instance_of(response, SampleResponse)
|
||||
assert isinstance(response.original_request, dict)
|
||||
|
||||
# Verify the dict contains expected values
|
||||
dict_request = cast(dict[str, Any], response.original_request)
|
||||
assert dict_request["request_id"] == "req-1"
|
||||
assert dict_request["prompt"] == "prompt"
|
||||
|
||||
|
||||
def test_encode_decode_simple_dataclass() -> None:
|
||||
"""Test encoding and decoding of a simple dataclass."""
|
||||
original = SampleRequest(request_id="test-123", prompt="test prompt")
|
||||
|
||||
encoded = encode_checkpoint_value(original)
|
||||
decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
|
||||
|
||||
assert isinstance(decoded, SampleRequest)
|
||||
assert decoded.request_id == "test-123"
|
||||
assert decoded.prompt == "test prompt"
|
||||
|
||||
|
||||
def test_encode_decode_nested_structures() -> None:
|
||||
"""Test encoding and decoding of complex nested structures."""
|
||||
nested_data = {
|
||||
"requests": [
|
||||
SampleRequest(request_id="req-1", prompt="first prompt"),
|
||||
SampleRequest(request_id="req-2", prompt="second prompt"),
|
||||
],
|
||||
"responses": {
|
||||
"req-1": SampleResponse(
|
||||
data="first response",
|
||||
original_request=SampleRequest(request_id="req-1", prompt="first prompt"),
|
||||
request_id="req-1",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
encoded = encode_checkpoint_value(nested_data)
|
||||
decoded = decode_checkpoint_value(encoded)
|
||||
|
||||
assert isinstance(decoded, dict)
|
||||
assert "requests" in decoded
|
||||
assert "responses" in decoded
|
||||
|
||||
# Check the requests list
|
||||
requests = cast(list[Any], decoded["requests"])
|
||||
assert isinstance(requests, list)
|
||||
assert len(requests) == 2
|
||||
assert all(isinstance(req, SampleRequest) for req in requests)
|
||||
first_request = cast(SampleRequest, requests[0])
|
||||
second_request = cast(SampleRequest, requests[1])
|
||||
assert first_request.request_id == "req-1"
|
||||
assert second_request.request_id == "req-2"
|
||||
|
||||
# Check the responses dict
|
||||
responses = cast(dict[str, Any], decoded["responses"])
|
||||
assert isinstance(responses, dict)
|
||||
assert "req-1" in responses
|
||||
response = cast(SampleResponse, responses["req-1"])
|
||||
assert isinstance(response, SampleResponse)
|
||||
assert response.data == "first response"
|
||||
assert isinstance(response.original_request, SampleRequest)
|
||||
assert response.original_request.request_id == "req-1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
from agent_framework import Executor, Message, WorkflowContext, handler
|
||||
|
||||
|
||||
def test_executor_without_id():
|
||||
@@ -64,9 +64,9 @@ def test_executor_with_valid_handlers():
|
||||
executor = MockExecutorWithValidHandlers(id="test")
|
||||
assert executor.id is not None
|
||||
assert len(executor._handlers) == 2 # type: ignore
|
||||
assert executor.can_handle("text") is True
|
||||
assert executor.can_handle(42) is True
|
||||
assert executor.can_handle(3.14) is False
|
||||
assert executor.can_handle(Message(data="text", source_id="mock")) is True
|
||||
assert executor.can_handle(Message(data=42, source_id="mock")) is True
|
||||
assert executor.can_handle(Message(data=3.14, source_id="mock")) is False
|
||||
|
||||
|
||||
def test_executor_handlers_with_output_types():
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
FunctionExecutor,
|
||||
Message,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
@@ -230,9 +231,9 @@ class TestFunctionExecutor:
|
||||
async def string_processor(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(text)
|
||||
|
||||
assert string_processor.can_handle("hello")
|
||||
assert not string_processor.can_handle(123)
|
||||
assert not string_processor.can_handle([])
|
||||
assert string_processor.can_handle(Message(data="hello", source_id="Mock"))
|
||||
assert not string_processor.can_handle(Message(data=123, source_id="Mock"))
|
||||
assert not string_processor.can_handle(Message(data=[], source_id="Mock"))
|
||||
|
||||
def test_duplicate_handler_registration(self):
|
||||
"""Test that registering duplicate handlers raises an error."""
|
||||
@@ -309,9 +310,9 @@ class TestFunctionExecutor:
|
||||
async def int_processor(value: int):
|
||||
return value * 2
|
||||
|
||||
assert int_processor.can_handle(42)
|
||||
assert not int_processor.can_handle("hello")
|
||||
assert not int_processor.can_handle([])
|
||||
assert int_processor.can_handle(Message(data=42, source_id="mock"))
|
||||
assert not int_processor.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert not int_processor.can_handle(Message(data=[], source_id="mock"))
|
||||
|
||||
async def test_single_parameter_execution(self):
|
||||
"""Test that single-parameter functions can be executed properly."""
|
||||
@@ -325,7 +326,7 @@ class TestFunctionExecutor:
|
||||
WorkflowBuilder().set_start_executor(double_value).build()
|
||||
|
||||
# For testing purposes, we can check that the handler is registered correctly
|
||||
assert double_value.can_handle(5)
|
||||
assert double_value.can_handle(Message(data=5, source_id="mock"))
|
||||
assert int in double_value._handlers
|
||||
|
||||
def test_sync_function_basic(self):
|
||||
@@ -369,9 +370,9 @@ class TestFunctionExecutor:
|
||||
def string_handler(text: str):
|
||||
return text.strip()
|
||||
|
||||
assert string_handler.can_handle("hello")
|
||||
assert not string_handler.can_handle(123)
|
||||
assert not string_handler.can_handle([])
|
||||
assert string_handler.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert not string_handler.can_handle(Message(data=123, source_id="mock"))
|
||||
assert not string_handler.can_handle(Message(data=[], source_id="mock"))
|
||||
|
||||
def test_sync_function_validation(self):
|
||||
"""Test validation for synchronous functions."""
|
||||
@@ -413,8 +414,8 @@ class TestFunctionExecutor:
|
||||
assert isinstance(async_func, FunctionExecutor)
|
||||
|
||||
# Both should handle strings
|
||||
assert sync_func.can_handle("test")
|
||||
assert async_func.can_handle("test")
|
||||
assert sync_func.can_handle(Message(data="test", source_id="mock"))
|
||||
assert async_func.can_handle(Message(data="test", source_id="mock"))
|
||||
|
||||
# Both should be different instances
|
||||
assert sync_func is not async_func
|
||||
@@ -443,8 +444,8 @@ class TestFunctionExecutor:
|
||||
assert async_spec["workflow_output_types"] == [str] # Second parameter is str
|
||||
|
||||
# Verify the executors can handle their input types
|
||||
assert to_upper_sync.can_handle("hello")
|
||||
assert reverse_async.can_handle("HELLO")
|
||||
assert to_upper_sync.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert reverse_async.can_handle(Message(data="HELLO", source_id="mock"))
|
||||
|
||||
# For integration testing, we mainly verify that the handlers are properly registered
|
||||
# and the functions are wrapped correctly
|
||||
|
||||
@@ -312,7 +312,6 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
async for ev in wf.run_stream(task_text):
|
||||
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
|
||||
req_event = ev
|
||||
break
|
||||
assert req_event is not None
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
@@ -334,10 +333,16 @@ async def test_magentic_checkpoint_resume_round_trip():
|
||||
|
||||
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
|
||||
completed: WorkflowOutputEvent | None = None
|
||||
req_event = None
|
||||
async for event in wf_resume.run_stream_from_checkpoint(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
responses={req_event.request_id: reply},
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
req_event = event
|
||||
assert req_event is not None
|
||||
|
||||
responses = {req_event.request_id: reply}
|
||||
async for event in wf_resume.send_responses_streaming(responses=responses):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
completed = event
|
||||
assert completed is not None
|
||||
@@ -666,7 +671,6 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
|
||||
async for event in workflow.run_stream("task"):
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
req_event = event
|
||||
break
|
||||
|
||||
assert req_event is not None
|
||||
|
||||
@@ -685,7 +689,6 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
|
||||
with pytest.raises(ValueError, match="Workflow graph has changed"):
|
||||
async for _ in renamed_workflow.run_stream_from_checkpoint(
|
||||
target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
|
||||
responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)},
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework._workflows._executor import Executor
|
||||
from agent_framework._workflows._request_info_mixin import RequestInfoMixin
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserApprovalRequest:
|
||||
"""A request for user approval with context."""
|
||||
|
||||
prompt: str
|
||||
context: str
|
||||
request_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.request_id:
|
||||
import uuid
|
||||
|
||||
self.request_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalculationRequest:
|
||||
"""A request for a complex calculation."""
|
||||
|
||||
operation: str
|
||||
operands: list[float]
|
||||
request_id: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.request_id:
|
||||
import uuid
|
||||
|
||||
self.request_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
class ApprovalRequiredExecutor(Executor, RequestInfoMixin):
|
||||
"""Executor that requires approval before proceeding."""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
self.approval_received = False
|
||||
self.final_result = None
|
||||
|
||||
@handler
|
||||
async def start_process(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Start a process that requires approval."""
|
||||
# Request approval from external system
|
||||
approval_request = UserApprovalRequest(
|
||||
prompt=f"Please approve the operation: {message}",
|
||||
context="This is a critical operation that requires human approval.",
|
||||
)
|
||||
await ctx.request_info(approval_request, UserApprovalRequest, bool)
|
||||
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self, original_request: UserApprovalRequest, approved: bool, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
"""Handle the approval response."""
|
||||
self.approval_received = True
|
||||
|
||||
if approved:
|
||||
self.final_result = f"Operation approved: {original_request.prompt}"
|
||||
await ctx.send_message(f"APPROVED: {original_request.context}")
|
||||
else:
|
||||
self.final_result = "Operation denied by user"
|
||||
await ctx.send_message("DENIED: Operation was not approved")
|
||||
|
||||
|
||||
class CalculationExecutor(Executor, RequestInfoMixin):
|
||||
"""Executor that delegates complex calculations to external services."""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
self.calculations_performed: list[tuple[str, list[float], float]] = []
|
||||
|
||||
@handler
|
||||
async def process_calculation(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Process a calculation request."""
|
||||
# Parse the message to extract operation
|
||||
parts = message.split()
|
||||
if len(parts) >= 3:
|
||||
operation = parts[0]
|
||||
try:
|
||||
operands = [float(x) for x in parts[1:]]
|
||||
calc_request = CalculationRequest(operation=operation, operands=operands)
|
||||
await ctx.request_info(calc_request, CalculationRequest, float)
|
||||
except ValueError:
|
||||
await ctx.send_message("Invalid calculation format")
|
||||
else:
|
||||
await ctx.send_message("Insufficient parameters for calculation")
|
||||
|
||||
@response_handler
|
||||
async def handle_calculation_response(
|
||||
self, original_request: CalculationRequest, result: float, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
"""Handle the calculation response."""
|
||||
self.calculations_performed.append((original_request.operation, original_request.operands, result))
|
||||
operands_str = ", ".join(map(str, original_request.operands))
|
||||
await ctx.send_message(f"Calculation complete: {original_request.operation}({operands_str}) = {result}")
|
||||
|
||||
|
||||
class MultiRequestExecutor(Executor, RequestInfoMixin):
|
||||
"""Executor that makes multiple requests and waits for all responses."""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
self.responses_received: list[tuple[str, bool | float]] = []
|
||||
|
||||
@handler
|
||||
async def start_multi_request(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Start multiple requests simultaneously."""
|
||||
# Request approval
|
||||
approval_request = UserApprovalRequest(
|
||||
prompt="Approve batch operation", context="Multiple operations will be performed"
|
||||
)
|
||||
await ctx.request_info(approval_request, UserApprovalRequest, bool)
|
||||
|
||||
# Request calculation
|
||||
calc_request = CalculationRequest(operation="multiply", operands=[10.0, 5.0])
|
||||
await ctx.request_info(calc_request, CalculationRequest, float)
|
||||
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self, original_request: UserApprovalRequest, approved: bool, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
"""Handle approval response."""
|
||||
self.responses_received.append(("approval", approved))
|
||||
await self._check_completion(ctx)
|
||||
|
||||
@response_handler
|
||||
async def handle_calculation_response(
|
||||
self, original_request: CalculationRequest, result: float, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
"""Handle calculation response."""
|
||||
self.responses_received.append(("calculation", result))
|
||||
await self._check_completion(ctx)
|
||||
|
||||
async def _check_completion(self, ctx: WorkflowContext[str]) -> None:
|
||||
"""Check if all responses are received and send final result."""
|
||||
if len(self.responses_received) == 2:
|
||||
approval_result = next((r[1] for r in self.responses_received if r[0] == "approval"), None)
|
||||
calc_result = next((r[1] for r in self.responses_received if r[0] == "calculation"), None)
|
||||
|
||||
if approval_result and calc_result is not None:
|
||||
await ctx.send_message(f"All operations complete. Calculation result: {calc_result}")
|
||||
else:
|
||||
await ctx.send_message("Operations completed with mixed results")
|
||||
|
||||
|
||||
class OutputCollector(Executor):
|
||||
"""Simple executor that collects outputs for testing."""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
self.collected_outputs: list[str] = []
|
||||
|
||||
@handler
|
||||
async def collect_output(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Collect output messages."""
|
||||
self.collected_outputs.append(message)
|
||||
|
||||
|
||||
class TestRequestInfoAndResponse:
|
||||
"""Test cases for end-to-end request info and response handling at the workflow level."""
|
||||
|
||||
async def test_approval_workflow(self):
|
||||
"""Test end-to-end workflow with approval request."""
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# First run the workflow until it emits a request
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in workflow.run_stream("test operation"):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
assert isinstance(request_info_event.data, UserApprovalRequest)
|
||||
assert request_info_event.data.prompt == "Please approve the operation: test operation"
|
||||
|
||||
# Send response and continue workflow
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
assert executor.approval_received is True
|
||||
assert executor.final_result == "Operation approved: Please approve the operation: test operation"
|
||||
|
||||
async def test_calculation_workflow(self):
|
||||
"""Test end-to-end workflow with calculation request."""
|
||||
executor = CalculationExecutor(id="calc_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# First run the workflow until it emits a calculation request
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in workflow.run_stream("multiply 15.5 2.0"):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
assert isinstance(request_info_event.data, CalculationRequest)
|
||||
assert request_info_event.data.operation == "multiply"
|
||||
assert request_info_event.data.operands == [15.5, 2.0]
|
||||
|
||||
# Send response with calculated result
|
||||
calculated_result = 31.0
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: calculated_result}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
assert len(executor.calculations_performed) == 1
|
||||
assert executor.calculations_performed[0] == ("multiply", [15.5, 2.0], calculated_result)
|
||||
|
||||
async def test_multiple_requests_workflow(self):
|
||||
"""Test workflow with multiple concurrent requests."""
|
||||
executor = MultiRequestExecutor(id="multi_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# Collect all request events by running the full stream
|
||||
request_events: list[RequestInfoEvent] = []
|
||||
async for event in workflow.run_stream("start batch"):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_events.append(event)
|
||||
|
||||
assert len(request_events) == 2
|
||||
|
||||
# Find the approval and calculation requests
|
||||
approval_event: RequestInfoEvent | None = next(
|
||||
(e for e in request_events if isinstance(e.data, UserApprovalRequest)), None
|
||||
)
|
||||
calc_event: RequestInfoEvent | None = next(
|
||||
(e for e in request_events if isinstance(e.data, CalculationRequest)), None
|
||||
)
|
||||
|
||||
assert approval_event is not None
|
||||
assert calc_event is not None
|
||||
|
||||
# Send responses for both requests
|
||||
responses = {approval_event.request_id: True, calc_event.request_id: 50.0}
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
assert len(executor.responses_received) == 2
|
||||
|
||||
async def test_denied_approval_workflow(self):
|
||||
"""Test workflow when approval is denied."""
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# First run the workflow until it emits a request
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in workflow.run_stream("sensitive operation"):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_event = event
|
||||
|
||||
assert request_info_event is not None
|
||||
|
||||
# Deny the request
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: False}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
assert executor.approval_received is True
|
||||
assert executor.final_result == "Operation denied by user"
|
||||
|
||||
async def test_workflow_state_with_pending_requests(self):
|
||||
"""Test workflow state when waiting for responses."""
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# Run workflow until idle with pending requests
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
idle_with_pending = False
|
||||
async for event in workflow.run_stream("test operation"):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_event = event
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
idle_with_pending = True
|
||||
|
||||
assert request_info_event is not None
|
||||
assert idle_with_pending
|
||||
|
||||
# Continue with response
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
|
||||
async def test_invalid_calculation_input(self):
|
||||
"""Test workflow handling of invalid calculation input."""
|
||||
executor = CalculationExecutor(id="calc_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).build()
|
||||
|
||||
# Send invalid input (no numbers)
|
||||
completed = False
|
||||
async for event in workflow.run_stream("invalid input"):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert completed
|
||||
# Should not have any calculations performed due to invalid input
|
||||
assert len(executor.calculations_performed) == 0
|
||||
|
||||
async def test_checkpoint_with_pending_request_info_events(self):
|
||||
"""Test that request info events are properly serialized in checkpoints and can be restored."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Use file-based storage to test full serialization
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
|
||||
# Create workflow with checkpointing enabled
|
||||
executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
workflow = WorkflowBuilder().set_start_executor(executor).with_checkpointing(storage).build()
|
||||
|
||||
# Step 1: Run workflow to completion to ensure checkpoints are created
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in workflow.run_stream("checkpoint test operation"):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_event = event
|
||||
|
||||
# Verify request was emitted
|
||||
assert request_info_event is not None
|
||||
assert isinstance(request_info_event.data, UserApprovalRequest)
|
||||
assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
|
||||
assert request_info_event.source_executor_id == "approval_executor"
|
||||
|
||||
# Step 2: List checkpoints to find the one with our pending request
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
|
||||
|
||||
# Find the checkpoint with our pending request
|
||||
checkpoint_with_request = None
|
||||
for checkpoint in checkpoints:
|
||||
if request_info_event.request_id in checkpoint.pending_request_info_events:
|
||||
checkpoint_with_request = checkpoint
|
||||
break
|
||||
|
||||
assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
|
||||
|
||||
# Step 3: Verify the pending request info event was properly serialized
|
||||
serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
|
||||
assert "data" in serialized_event
|
||||
assert "request_id" in serialized_event
|
||||
assert "source_executor_id" in serialized_event
|
||||
assert "request_type" in serialized_event
|
||||
assert serialized_event["request_id"] == request_info_event.request_id
|
||||
assert serialized_event["source_executor_id"] == "approval_executor"
|
||||
|
||||
# Step 4: Create a fresh workflow and restore from checkpoint
|
||||
new_executor = ApprovalRequiredExecutor(id="approval_executor")
|
||||
restored_workflow = WorkflowBuilder().set_start_executor(new_executor).with_checkpointing(storage).build()
|
||||
|
||||
# Step 5: Resume from checkpoint and verify the request can be continued
|
||||
completed = False
|
||||
restored_request_event: RequestInfoEvent | None = None
|
||||
async for event in restored_workflow.run_stream_from_checkpoint(checkpoint_with_request.checkpoint_id):
|
||||
# Should re-emit the pending request info event
|
||||
if isinstance(event, RequestInfoEvent) and event.request_id == request_info_event.request_id:
|
||||
restored_request_event = event
|
||||
elif (
|
||||
isinstance(event, WorkflowStatusEvent)
|
||||
and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
):
|
||||
completed = True
|
||||
|
||||
assert completed, "Workflow should reach idle with pending requests state after restoration"
|
||||
assert restored_request_event is not None, "Restored request info event should be emitted"
|
||||
|
||||
# Verify the restored event matches the original
|
||||
assert restored_request_event.source_executor_id == request_info_event.source_executor_id
|
||||
assert isinstance(restored_request_event.data, UserApprovalRequest)
|
||||
assert restored_request_event.data.prompt == request_info_event.data.prompt
|
||||
assert restored_request_event.data.context == request_info_event.data.context
|
||||
|
||||
# Step 6: Provide response to the restored request and complete the workflow
|
||||
final_completed = False
|
||||
async for event in restored_workflow.send_responses_streaming({
|
||||
request_info_event.request_id: True # Approve the request
|
||||
}):
|
||||
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
final_completed = True
|
||||
|
||||
assert final_completed, "Workflow should complete after providing response to restored request"
|
||||
|
||||
# Step 7: Verify the executor state was properly restored and response was processed
|
||||
assert new_executor.approval_received is True
|
||||
expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
|
||||
assert new_executor.final_result == expected_result
|
||||
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
|
||||
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
|
||||
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
|
||||
from agent_framework._workflows._events import RequestInfoEvent
|
||||
from agent_framework._workflows._shared_state import SharedState
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockRequest: ...
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SimpleApproval:
|
||||
prompt: str = ""
|
||||
draft: str = ""
|
||||
iteration: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SlottedApproval:
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimedApproval:
|
||||
issued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
async def test_rehydrate_request_info_event() -> None:
|
||||
"""Rehydration should succeed for valid request info events."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_type=MockRequest,
|
||||
request_data=MockRequest(),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
assert checkpoint.pending_request_info_events
|
||||
assert "request-123" in checkpoint.pending_request_info_events
|
||||
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
|
||||
|
||||
# Rehydrate the context
|
||||
await runner_context.apply_checkpoint(checkpoint)
|
||||
|
||||
pending_requests = await runner_context.get_pending_request_info_events()
|
||||
assert "request-123" in pending_requests
|
||||
rehydrated_event = pending_requests["request-123"]
|
||||
assert rehydrated_event.request_id == "request-123"
|
||||
assert rehydrated_event.source_executor_id == "review_gateway"
|
||||
assert rehydrated_event.request_type is MockRequest
|
||||
assert rehydrated_event.response_type is bool
|
||||
assert isinstance(rehydrated_event.data, MockRequest)
|
||||
|
||||
|
||||
async def test_rehydrate_fails_when_request_type_missing() -> None:
|
||||
"""Rehydration should fail is the request type is missing or fails to import."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_type=MockRequest,
|
||||
request_data=MockRequest(),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
assert checkpoint.pending_request_info_events
|
||||
assert "request-123" in checkpoint.pending_request_info_events
|
||||
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
|
||||
|
||||
# Modify the checkpoint to simulate missing request type
|
||||
checkpoint.pending_request_info_events["request-123"]["request_type"] = "nonexistent.module:MissingRequest"
|
||||
|
||||
# Rehydrate the context
|
||||
with pytest.raises(ImportError):
|
||||
await runner_context.apply_checkpoint(checkpoint)
|
||||
|
||||
|
||||
async def test_pending_requests_in_summary() -> None:
|
||||
"""Test that pending requests are correctly summarized in the checkpoint summary."""
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_id="request-123",
|
||||
source_executor_id="review_gateway",
|
||||
request_type=MockRequest,
|
||||
request_data=MockRequest(),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(request_info_event)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
assert checkpoint is not None
|
||||
summary = get_checkpoint_summary(checkpoint)
|
||||
|
||||
assert summary.checkpoint_id == checkpoint_id
|
||||
assert summary.status == "awaiting request response"
|
||||
|
||||
assert len(summary.pending_request_info_events) == 1
|
||||
pending_event = summary.pending_request_info_events[0]
|
||||
assert isinstance(pending_event, RequestInfoEvent)
|
||||
assert pending_event.request_id == "request-123"
|
||||
|
||||
assert pending_event.source_executor_id == "review_gateway"
|
||||
assert pending_event.request_type is MockRequest
|
||||
assert pending_event.response_type is bool
|
||||
assert isinstance(pending_event.data, MockRequest)
|
||||
|
||||
|
||||
async def test_request_info_event_serializes_non_json_payloads() -> None:
|
||||
req_1 = RequestInfoEvent(
|
||||
request_id="req-1",
|
||||
source_executor_id="source",
|
||||
request_type=TimedApproval,
|
||||
request_data=TimedApproval(issued_at=datetime(2024, 5, 4, 12, 30, 45)),
|
||||
response_type=bool,
|
||||
)
|
||||
req_2 = RequestInfoEvent(
|
||||
request_id="req-2",
|
||||
source_executor_id="source",
|
||||
request_type=SlottedApproval,
|
||||
request_data=SlottedApproval(note="slot-based"),
|
||||
response_type=bool,
|
||||
)
|
||||
|
||||
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
await runner_context.add_request_info_event(req_1)
|
||||
await runner_context.add_request_info_event(req_2)
|
||||
|
||||
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
|
||||
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
|
||||
|
||||
# Should be JSON serializable despite datetime/slots
|
||||
serialized = json.dumps(encode_checkpoint_value(checkpoint))
|
||||
deserialized = json.loads(serialized)
|
||||
|
||||
assert "value" in deserialized
|
||||
deserialized = deserialized["value"]
|
||||
|
||||
assert "pending_request_info_events" in deserialized
|
||||
pending_request_info_events = deserialized["pending_request_info_events"]
|
||||
assert "req-1" in pending_request_info_events
|
||||
assert isinstance(pending_request_info_events["req-1"]["data"]["value"]["issued_at"], str)
|
||||
|
||||
assert "req-2" in pending_request_info_events
|
||||
assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based"
|
||||
@@ -1,285 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
|
||||
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
|
||||
from agent_framework._workflows._const import EXECUTOR_STATE_KEY
|
||||
from agent_framework._workflows._events import RequestInfoEvent, WorkflowEvent
|
||||
from agent_framework._workflows._request_info_executor import (
|
||||
PendingRequestDetails,
|
||||
PendingRequestSnapshot,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
)
|
||||
from agent_framework._workflows._runner_context import Message
|
||||
from agent_framework._workflows._shared_state import SharedState
|
||||
from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
|
||||
PENDING_STATE_KEY = RequestInfoExecutor._PENDING_SHARED_STATE_KEY # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
class _StubRunnerContext:
|
||||
"""Minimal runner context stub for exercising WorkflowContext helpers."""
|
||||
|
||||
async def send_message(self, message: Message) -> None: # pragma: no cover - unused in tests
|
||||
return None
|
||||
|
||||
async def drain_messages(self) -> dict[str, list[Message]]: # pragma: no cover - unused
|
||||
return {}
|
||||
|
||||
async def has_messages(self) -> bool: # pragma: no cover - unused
|
||||
return False
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None: # pragma: no cover - unused
|
||||
return None
|
||||
|
||||
async def drain_events(self) -> list[WorkflowEvent]: # pragma: no cover - unused
|
||||
return []
|
||||
|
||||
async def has_events(self) -> bool: # pragma: no cover - unused
|
||||
return False
|
||||
|
||||
async def next_event(self) -> WorkflowEvent: # pragma: no cover - unused
|
||||
raise RuntimeError("Not implemented in stub context")
|
||||
|
||||
def has_checkpointing(self) -> bool: # pragma: no cover - unused
|
||||
return False
|
||||
|
||||
def set_workflow_id(self, workflow_id: str) -> None: # pragma: no cover - unused
|
||||
pass
|
||||
|
||||
def reset_for_new_run(self) -> None: # pragma: no cover - unused
|
||||
pass
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
shared_state: SharedState,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str: # pragma: no cover - unused
|
||||
raise RuntimeError("Checkpointing not supported in stub context")
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: # pragma: no cover - unused
|
||||
return None
|
||||
|
||||
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: # pragma: no cover - unused
|
||||
pass
|
||||
|
||||
def set_streaming(self, streaming: bool) -> None: # pragma: no cover - unused
|
||||
pass
|
||||
|
||||
def is_streaming(self) -> bool: # pragma: no cover - unused
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SimpleApproval(RequestInfoMessage):
|
||||
prompt: str = ""
|
||||
draft: str = ""
|
||||
iteration: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SlottedApproval(RequestInfoMessage):
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimedApproval(RequestInfoMessage):
|
||||
issued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
async def test_rehydrate_falls_back_when_request_type_missing() -> None:
|
||||
"""Rehydration should succeed even if the original request type cannot be imported.
|
||||
|
||||
This simulates resuming a workflow where the HumanApprovalRequest class is unavailable
|
||||
in the current process (e.g., defined in __main__ during the original run).
|
||||
"""
|
||||
request_id = "request-123"
|
||||
snapshot = PendingRequestSnapshot(
|
||||
request_id=request_id,
|
||||
source_executor_id="review_gateway",
|
||||
request_type="nonexistent.module:MissingRequest",
|
||||
request_as_json_safe_dict={
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
|
||||
await ctx.set_executor_state({PENDING_STATE_KEY: {request_id: snapshot}})
|
||||
|
||||
executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
event = await executor._rehydrate_request_event(request_id, ctx) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert event is not None
|
||||
assert event.request_id == request_id
|
||||
assert isinstance(event.data, RequestInfoMessage)
|
||||
|
||||
|
||||
async def test_has_pending_request_detects_snapshot() -> None:
|
||||
request_id = "request-123"
|
||||
snapshot = PendingRequestSnapshot(
|
||||
request_id=request_id,
|
||||
source_executor_id="review_gateway",
|
||||
request_type="nonexistent.module:MissingRequest",
|
||||
request_as_json_safe_dict={
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
|
||||
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
|
||||
await ctx.set_executor_state({PENDING_STATE_KEY: {request_id: snapshot}})
|
||||
|
||||
executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
assert await executor.has_pending_request(request_id, ctx)
|
||||
|
||||
|
||||
async def test_has_pending_request_false_when_snapshot_absent() -> None:
|
||||
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
|
||||
await ctx.set_executor_state({PENDING_STATE_KEY: {}})
|
||||
|
||||
executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
assert not await executor.has_pending_request("missing", ctx)
|
||||
|
||||
|
||||
def test_pending_requests_from_checkpoint_and_summary() -> None:
|
||||
request = SimpleApproval(prompt="Review draft", draft="Draft text", iteration=3)
|
||||
request.request_id = "req-42"
|
||||
|
||||
response = RequestResponse[SimpleApproval, str](
|
||||
data="approve",
|
||||
original_request=request,
|
||||
request_id=request.request_id,
|
||||
)
|
||||
|
||||
encoded_response = encode_checkpoint_value(response)
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="cp-1",
|
||||
workflow_id="wf",
|
||||
messages={
|
||||
"request_info": [
|
||||
{
|
||||
"data": encoded_response,
|
||||
"source_id": "request_info",
|
||||
"target_id": "review_gateway",
|
||||
}
|
||||
]
|
||||
},
|
||||
shared_state={
|
||||
PENDING_STATE_KEY: {
|
||||
request.request_id: {
|
||||
"request_id": request.request_id,
|
||||
"prompt": request.prompt,
|
||||
"draft": request.draft,
|
||||
"iteration": request.iteration,
|
||||
"source_executor_id": "review_gateway",
|
||||
}
|
||||
}
|
||||
},
|
||||
iteration_count=1,
|
||||
)
|
||||
|
||||
summary = get_checkpoint_summary(checkpoint)
|
||||
assert summary.checkpoint_id == "cp-1"
|
||||
assert summary.status == "awaiting request response"
|
||||
assert summary.pending_requests[0].request_id == "req-42"
|
||||
|
||||
pending = summary.pending_requests
|
||||
assert len(pending) == 1
|
||||
entry = pending[0]
|
||||
assert isinstance(entry, PendingRequestDetails)
|
||||
assert entry.request_id == "req-42"
|
||||
assert entry.prompt == "Review draft"
|
||||
assert entry.draft == "Draft text"
|
||||
assert entry.iteration == 3
|
||||
assert entry.original_request is not None
|
||||
|
||||
|
||||
def test_snapshot_state_serializes_non_json_payloads() -> None:
|
||||
executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
timed = TimedApproval(issued_at=datetime(2024, 5, 4, 12, 30, 45))
|
||||
timed.request_id = "timed"
|
||||
slotted = SlottedApproval(note="slot-based")
|
||||
slotted.request_id = "slotted"
|
||||
|
||||
executor._request_events = { # pyright: ignore[reportPrivateUsage]
|
||||
timed.request_id: RequestInfoEvent(
|
||||
request_id=timed.request_id,
|
||||
source_executor_id="source",
|
||||
request_type=TimedApproval,
|
||||
request_data=timed,
|
||||
),
|
||||
slotted.request_id: RequestInfoEvent(
|
||||
request_id=slotted.request_id,
|
||||
source_executor_id="source",
|
||||
request_type=SlottedApproval,
|
||||
request_data=slotted,
|
||||
),
|
||||
}
|
||||
|
||||
state = executor.snapshot_state()
|
||||
|
||||
# Should be JSON serializable despite datetime/slots
|
||||
serialized = json.dumps(state)
|
||||
assert "timed" in serialized
|
||||
timed_payload = state["request_events"][timed.request_id]["request_data"]["value"]
|
||||
assert isinstance(timed_payload["issued_at"], str)
|
||||
|
||||
|
||||
def test_restore_state_falls_back_to_base_request_type() -> None:
|
||||
executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
approval = SimpleApproval(prompt="Review", draft="Draft", iteration=1)
|
||||
approval.request_id = "req"
|
||||
executor._request_events = { # pyright: ignore[reportPrivateUsage]
|
||||
approval.request_id: RequestInfoEvent(
|
||||
request_id=approval.request_id,
|
||||
source_executor_id="source",
|
||||
request_type=SimpleApproval,
|
||||
request_data=approval,
|
||||
)
|
||||
}
|
||||
|
||||
state = executor.snapshot_state()
|
||||
state["request_events"][approval.request_id]["request_type"] = "missing.module:GhostRequest"
|
||||
|
||||
executor.restore_state(state)
|
||||
|
||||
restored = executor._request_events[approval.request_id] # pyright: ignore[reportPrivateUsage]
|
||||
assert restored.request_type is RequestInfoMessage
|
||||
assert isinstance(restored.data, RequestInfoMessage)
|
||||
|
||||
|
||||
async def test_run_persists_pending_requests_in_runner_state() -> None:
|
||||
shared_state = SharedState()
|
||||
runner_ctx = _StubRunnerContext()
|
||||
ctx: WorkflowContext[None] = WorkflowContext("request_info", ["source"], shared_state, runner_ctx)
|
||||
|
||||
executor = RequestInfoExecutor(id="request_info")
|
||||
approval = SimpleApproval(prompt="Review", draft="Draft", iteration=1)
|
||||
approval.request_id = "req-123"
|
||||
|
||||
await executor.execute(approval, ctx.source_executor_ids, shared_state, runner_ctx)
|
||||
|
||||
# Runner state should include both pending snapshot and serialized request events
|
||||
assert await shared_state.has(EXECUTOR_STATE_KEY)
|
||||
executor_state = await shared_state.get(EXECUTOR_STATE_KEY)
|
||||
assert executor.id in executor_state
|
||||
assert PENDING_STATE_KEY in executor_state[executor.id]
|
||||
assert approval.request_id in executor_state[executor.id][PENDING_STATE_KEY]
|
||||
|
||||
response_ctx: WorkflowContext[None] = WorkflowContext("request_info", ["source"], shared_state, runner_ctx)
|
||||
await executor.handle_response("approved", approval.request_id, response_ctx) # type: ignore
|
||||
|
||||
assert executor_state[executor.id][PENDING_STATE_KEY] == {}
|
||||
@@ -0,0 +1,788 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework._workflows._executor import Executor, handler
|
||||
from agent_framework._workflows._request_info_mixin import response_handler
|
||||
from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
class TestRequestInfoMixin:
|
||||
"""Test cases for RequestInfoMixin functionality."""
|
||||
|
||||
def test_request_info_mixin_initialization(self):
|
||||
"""Test that RequestInfoMixin can be initialized."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
# After calling _discover_response_handlers, it should have the attributes
|
||||
assert hasattr(executor, "_response_handlers")
|
||||
assert hasattr(executor, "_response_handler_specs")
|
||||
assert hasattr(executor, "is_request_response_capable")
|
||||
assert executor.is_request_response_capable is False
|
||||
|
||||
def test_response_handler_decorator_creates_metadata(self):
|
||||
"""Test that the response_handler decorator creates proper metadata."""
|
||||
|
||||
@response_handler
|
||||
async def test_handler(self: Any, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
"""Test handler docstring."""
|
||||
pass
|
||||
|
||||
# Check that the decorator preserves function attributes
|
||||
assert test_handler.__name__ == "test_handler"
|
||||
assert test_handler.__doc__ == "Test handler docstring."
|
||||
assert hasattr(test_handler, "_response_handler_spec")
|
||||
|
||||
# Check the spec attributes
|
||||
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
|
||||
assert spec["name"] == "test_handler"
|
||||
assert spec["response_type"] is int
|
||||
assert spec["request_type"] is str
|
||||
|
||||
def test_response_handler_with_workflow_context_types(self):
|
||||
"""Test response handler with different WorkflowContext type parameters."""
|
||||
|
||||
@response_handler
|
||||
async def handler_with_output_types(
|
||||
self: Any, original_request: str, response: int, ctx: WorkflowContext[str, bool]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
spec = handler_with_output_types._response_handler_spec # type: ignore[reportAttributeAccessIssue]
|
||||
assert "output_types" in spec
|
||||
assert "workflow_output_types" in spec
|
||||
|
||||
def test_response_handler_preserves_signature(self):
|
||||
"""Test that response_handler preserves the original function signature."""
|
||||
|
||||
async def original_handler(self: Any, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
decorated = response_handler(original_handler)
|
||||
|
||||
# Check that signature is preserved
|
||||
original_sig = inspect.signature(original_handler)
|
||||
decorated_sig = inspect.signature(decorated)
|
||||
|
||||
# Both should have the same parameter names and types
|
||||
assert list(original_sig.parameters.keys()) == list(decorated_sig.parameters.keys())
|
||||
|
||||
def test_executor_with_response_handlers(self):
|
||||
"""Test an executor with valid response handlers."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_string_response(
|
||||
self, original_request: str, response: int, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_dict_response(
|
||||
self, original_request: dict[str, Any], response: bool, ctx: WorkflowContext[bool]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should be request-response capable
|
||||
assert executor.is_request_response_capable is True
|
||||
|
||||
# Should have registered handlers
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 2
|
||||
assert (str, int) in response_handlers
|
||||
assert (dict[str, Any], bool) in response_handlers
|
||||
|
||||
def test_executor_without_response_handlers(self):
|
||||
"""Test an executor without response handlers."""
|
||||
|
||||
class PlainExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="plain_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
executor = PlainExecutor()
|
||||
|
||||
# Should not be request-response capable
|
||||
assert executor.is_request_response_capable is False
|
||||
|
||||
# Should have empty handlers
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 0
|
||||
|
||||
def test_duplicate_response_handlers_raise_error(self):
|
||||
"""Test that duplicate response handlers for the same message type raise an error."""
|
||||
|
||||
class DuplicateExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="duplicate_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_first(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_second(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Duplicate response handler for request type <class 'str'> and response type <class 'int'>",
|
||||
):
|
||||
DuplicateExecutor()
|
||||
|
||||
def test_response_handler_function_callable(self):
|
||||
"""Test that response handlers can actually be called."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
self.handled_request = None
|
||||
self.handled_response = None
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_response(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
self.handled_request = original_request
|
||||
self.handled_response = response
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Get the handler
|
||||
response_handler_func = executor._response_handlers[(str, int)] # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Create a mock context - we'll just use None since the handler doesn't use it
|
||||
asyncio.run(response_handler_func("test_request", 42, None)) # type: ignore[reportArgumentType]
|
||||
|
||||
assert executor.handled_request == "test_request"
|
||||
assert executor.handled_response == 42
|
||||
|
||||
def test_inheritance_with_response_handlers(self):
|
||||
"""Test that response handlers work correctly with inheritance."""
|
||||
|
||||
class BaseExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="base_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def base_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
class ChildExecutor(BaseExecutor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "child_executor"
|
||||
|
||||
@response_handler
|
||||
async def child_handler(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
child = ChildExecutor()
|
||||
|
||||
# Should have both handlers
|
||||
response_handlers = child._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 2
|
||||
assert (str, int) in response_handlers
|
||||
assert (str, bool) in response_handlers
|
||||
assert child.is_request_response_capable is True
|
||||
|
||||
def test_response_handler_spec_attributes(self):
|
||||
"""Test that response handler specs contain expected attributes."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def test_handler(self, original_request: str, response: int, ctx: WorkflowContext[str, bool]) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
specs = executor._response_handler_specs # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(specs) == 1
|
||||
|
||||
spec = specs[0]
|
||||
assert spec["name"] == "test_handler"
|
||||
assert spec["request_type"] is str
|
||||
assert spec["response_type"] is int
|
||||
assert "output_types" in spec
|
||||
assert "workflow_output_types" in spec
|
||||
assert "ctx_annotation" in spec
|
||||
assert spec["source"] == "class_method"
|
||||
|
||||
def test_multiple_discovery_calls_raise_error(self):
|
||||
"""Test that multiple calls to _discover_response_handlers raise an error for duplicates."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def test_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# First call should work fine
|
||||
first_handlers = len(executor._response_handlers) # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Second call should raise an error due to duplicate registration
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Duplicate response handler for request type <class 'str'> and response type <class 'int'>",
|
||||
):
|
||||
executor._discover_response_handlers() # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Handlers count should remain the same
|
||||
assert first_handlers == 1
|
||||
|
||||
def test_non_callable_attributes_ignored(self):
|
||||
"""Test that non-callable attributes are ignored during discovery."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
some_variable = "not_a_function"
|
||||
another_attr = 42
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def valid_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should only have one handler despite other attributes
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 1
|
||||
assert (str, int) in response_handlers
|
||||
|
||||
def test_same_request_type_different_response_types(self):
|
||||
"""Test that handlers with same request type but different response types are distinct."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
self.str_int_handler_called = False
|
||||
self.str_bool_handler_called = False
|
||||
self.str_dict_handler_called = False
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
self.str_int_handler_called = True
|
||||
|
||||
@response_handler
|
||||
async def handle_str_bool(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
|
||||
self.str_bool_handler_called = True
|
||||
|
||||
@response_handler
|
||||
async def handle_str_dict(
|
||||
self, original_request: str, response: dict[str, Any], ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
self.str_dict_handler_called = True
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should have three distinct handlers
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 3
|
||||
assert (str, int) in response_handlers
|
||||
assert (str, bool) in response_handlers
|
||||
assert (str, dict[str, Any]) in response_handlers
|
||||
|
||||
# Test that each handler can be found correctly
|
||||
str_int_handler = executor._find_response_handler("test", 42) # pyright: ignore[reportPrivateUsage]
|
||||
str_bool_handler = executor._find_response_handler("test", True) # pyright: ignore[reportPrivateUsage]
|
||||
str_dict_handler = executor._find_response_handler("test", {"key": "value"}) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert str_int_handler is not None
|
||||
assert str_bool_handler is not None
|
||||
assert str_dict_handler is not None
|
||||
|
||||
# Test that handlers are called correctly
|
||||
asyncio.run(str_int_handler(42, None)) # type: ignore[reportArgumentType]
|
||||
asyncio.run(str_bool_handler(True, None)) # type: ignore[reportArgumentType]
|
||||
asyncio.run(str_dict_handler({"key": "value"}, None)) # type: ignore[reportArgumentType]
|
||||
|
||||
assert executor.str_int_handler_called
|
||||
assert executor.str_bool_handler_called
|
||||
assert executor.str_dict_handler_called
|
||||
|
||||
def test_different_request_types_same_response_type(self):
|
||||
"""Test that handlers with different request types but same response type are distinct."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
self.str_int_handler_called = False
|
||||
self.dict_int_handler_called = False
|
||||
self.list_int_handler_called = False
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
self.str_int_handler_called = True
|
||||
|
||||
@response_handler
|
||||
async def handle_dict_int(
|
||||
self, original_request: dict[str, Any], response: int, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
self.dict_int_handler_called = True
|
||||
|
||||
@response_handler
|
||||
async def handle_list_int(
|
||||
self, original_request: list[str], response: int, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
self.list_int_handler_called = True
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should have three distinct handlers
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 3
|
||||
assert (str, int) in response_handlers
|
||||
assert (dict[str, Any], int) in response_handlers
|
||||
assert (list[str], int) in response_handlers
|
||||
|
||||
# Test that each handler can be found correctly
|
||||
str_int_handler = executor._find_response_handler("test", 42) # pyright: ignore[reportPrivateUsage]
|
||||
dict_int_handler = executor._find_response_handler({"key": "value"}, 42) # pyright: ignore[reportPrivateUsage]
|
||||
list_int_handler = executor._find_response_handler(["test"], 42) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert str_int_handler is not None
|
||||
assert dict_int_handler is not None
|
||||
assert list_int_handler is not None
|
||||
|
||||
# Test that handlers are called correctly
|
||||
asyncio.run(str_int_handler(42, None)) # type: ignore[reportArgumentType]
|
||||
asyncio.run(dict_int_handler(42, None)) # type: ignore[reportArgumentType]
|
||||
asyncio.run(list_int_handler(42, None)) # type: ignore[reportArgumentType]
|
||||
|
||||
assert executor.str_int_handler_called
|
||||
assert executor.dict_int_handler_called
|
||||
assert executor.list_int_handler_called
|
||||
|
||||
def test_complex_type_combinations(self):
|
||||
"""Test response handlers with complex type combinations."""
|
||||
|
||||
class CustomRequest:
|
||||
pass
|
||||
|
||||
class CustomResponse:
|
||||
pass
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
self.custom_custom_called = False
|
||||
self.custom_str_called = False
|
||||
self.str_custom_called = False
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_custom_custom(
|
||||
self, original_request: CustomRequest, response: CustomResponse, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
self.custom_custom_called = True
|
||||
|
||||
@response_handler
|
||||
async def handle_custom_str(
|
||||
self, original_request: CustomRequest, response: str, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
self.custom_str_called = True
|
||||
|
||||
@response_handler
|
||||
async def handle_str_custom(
|
||||
self, original_request: str, response: CustomResponse, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
self.str_custom_called = True
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should have three distinct handlers
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 3
|
||||
assert (CustomRequest, CustomResponse) in response_handlers
|
||||
assert (CustomRequest, str) in response_handlers
|
||||
assert (str, CustomResponse) in response_handlers
|
||||
|
||||
# Test that each handler can be found correctly
|
||||
custom_request = CustomRequest()
|
||||
custom_response = CustomResponse()
|
||||
|
||||
custom_custom_handler = executor._find_response_handler(custom_request, custom_response) # pyright: ignore[reportPrivateUsage]
|
||||
custom_str_handler = executor._find_response_handler(custom_request, "test") # pyright: ignore[reportPrivateUsage]
|
||||
str_custom_handler = executor._find_response_handler("test", custom_response) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert custom_custom_handler is not None
|
||||
assert custom_str_handler is not None
|
||||
assert str_custom_handler is not None
|
||||
|
||||
def test_handler_key_uniqueness(self):
|
||||
"""Test that handler keys (request_type, response_type) are truly unique."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle1(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle2(self, original_request: int, response: str, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle3(self, original_request: str, response: str, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle4(self, original_request: int, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should have four distinct handlers based on different combinations
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 4
|
||||
|
||||
# Verify all expected combinations exist
|
||||
expected_keys = {
|
||||
(str, int), # handle1
|
||||
(int, str), # handle2
|
||||
(str, str), # handle3
|
||||
(int, int), # handle4
|
||||
}
|
||||
|
||||
actual_keys = set(response_handlers.keys())
|
||||
assert actual_keys == expected_keys
|
||||
|
||||
def test_no_false_matches_with_similar_types(self):
|
||||
"""Test that handlers don't match with similar but different types."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_list_str_float(
|
||||
self, original_request: list[str], response: float, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Test that wrong combinations don't match
|
||||
assert executor._find_response_handler("test", 3.14) is None # pyright: ignore[reportPrivateUsage] # str request, float response - no handler
|
||||
assert executor._find_response_handler(["test"], 42) is None # pyright: ignore[reportPrivateUsage] # list request, int response - no handler
|
||||
assert executor._find_response_handler(42, "test") is None # pyright: ignore[reportPrivateUsage] # int request, str response - no handler
|
||||
|
||||
# Test that correct combinations do match
|
||||
assert executor._find_response_handler("test", 42) is not None # pyright: ignore[reportPrivateUsage] # str request, int response - has handler
|
||||
assert executor._find_response_handler(["test"], 3.14) is not None # pyright: ignore[reportPrivateUsage] # list request, float response - has handler
|
||||
|
||||
def test_is_request_supported_with_exact_matches(self):
|
||||
"""Test is_request_supported with exact type matches."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_dict_bool(
|
||||
self, original_request: dict[str, Any], response: bool, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Test exact matches
|
||||
assert executor.is_request_supported(str, int) is True
|
||||
assert executor.is_request_supported(str, bool) is True # bool and int are compatible
|
||||
assert executor.is_request_supported(dict[str, Any], bool) is True
|
||||
|
||||
# Test non-matches
|
||||
assert executor.is_request_supported(int, str) is False
|
||||
assert executor.is_request_supported(list[str], int) is False
|
||||
|
||||
def test_is_request_supported_without_handlers(self):
|
||||
"""Test is_request_supported when no handlers are registered."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should return False for any type combination
|
||||
assert executor.is_request_supported(str, int) is False
|
||||
assert executor.is_request_supported(dict[str, Any], bool) is False
|
||||
assert executor.is_request_supported(int, str) is False
|
||||
|
||||
def test_is_request_supported_before_discovery(self):
|
||||
"""Test is_request_supported before response handlers are discovered."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor", defer_discovery=True)
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
# Don't call _discover_response_handlers()
|
||||
|
||||
# Should return False when _response_handlers attribute doesn't exist
|
||||
assert executor.is_request_supported(str, int) is False
|
||||
assert executor.is_request_supported(dict[str, Any], bool) is False
|
||||
|
||||
def test_is_request_supported_with_compatible_types(self):
|
||||
"""Test is_request_supported with type-compatible scenarios."""
|
||||
|
||||
class BaseRequest:
|
||||
pass
|
||||
|
||||
class DerivedRequest(BaseRequest):
|
||||
pass
|
||||
|
||||
class BaseResponse:
|
||||
pass
|
||||
|
||||
class DerivedResponse(BaseResponse):
|
||||
pass
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_base_base(
|
||||
self, original_request: BaseRequest, response: BaseResponse, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Test exact matches
|
||||
assert executor.is_request_supported(BaseRequest, BaseResponse) is True
|
||||
assert executor.is_request_supported(str, int) is True
|
||||
|
||||
# Test compatible derived types (depends on is_type_compatible implementation)
|
||||
# These should return True if the type compatibility function supports inheritance
|
||||
result_derived_request = executor.is_request_supported(DerivedRequest, BaseResponse)
|
||||
result_derived_response = executor.is_request_supported(BaseRequest, DerivedResponse)
|
||||
result_both_derived = executor.is_request_supported(DerivedRequest, DerivedResponse)
|
||||
|
||||
# The actual result depends on the is_type_compatible implementation
|
||||
# We'll just assert that the method doesn't raise an exception
|
||||
assert isinstance(result_derived_request, bool)
|
||||
assert isinstance(result_derived_response, bool)
|
||||
assert isinstance(result_both_derived, bool)
|
||||
|
||||
def test_is_request_supported_with_multiple_handlers(self):
|
||||
"""Test is_request_supported when multiple handlers are registered."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_str_bool(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_dict_str(
|
||||
self, original_request: dict[str, Any], response: str, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_list_float(
|
||||
self, original_request: list[str], response: float, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Test all registered combinations
|
||||
assert executor.is_request_supported(str, int) is True
|
||||
assert executor.is_request_supported(str, bool) is True
|
||||
assert executor.is_request_supported(dict[str, Any], str) is True
|
||||
assert executor.is_request_supported(list[str], float) is True
|
||||
|
||||
# Test combinations that don't exist
|
||||
assert executor.is_request_supported(str, float) is False
|
||||
assert executor.is_request_supported(int, str) is False
|
||||
assert executor.is_request_supported(dict[str, Any], int) is False
|
||||
assert executor.is_request_supported(list[str], bool) is False
|
||||
|
||||
def test_is_request_supported_with_complex_types(self):
|
||||
"""Test is_request_supported with complex generic types."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_dict_list(
|
||||
self, original_request: dict[str, Any], response: list[int], ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_list_dict(
|
||||
self, original_request: list[str], response: dict[str, bool], ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Test complex type matches
|
||||
assert executor.is_request_supported(dict[str, Any], list[int]) is True
|
||||
assert executor.is_request_supported(list[str], dict[str, bool]) is True
|
||||
|
||||
# Test non-matches with similar but different complex types
|
||||
assert executor.is_request_supported(dict[str, Any], list[str]) is False
|
||||
assert executor.is_request_supported(list[int], dict[str, bool]) is False
|
||||
assert executor.is_request_supported(dict[int, Any], list[int]) is False
|
||||
|
||||
def test_is_request_supported_with_inheritance(self):
|
||||
"""Test is_request_supported with inherited response handlers."""
|
||||
|
||||
class BaseExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="base_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def base_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
class ChildExecutor(BaseExecutor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.id = "child_executor"
|
||||
|
||||
@response_handler
|
||||
async def child_handler(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
child = ChildExecutor()
|
||||
|
||||
# Should support both inherited and child-defined handlers
|
||||
assert child.is_request_supported(str, int) is True # From base class
|
||||
assert child.is_request_supported(str, bool) is True # From child class
|
||||
|
||||
# Should not support unregistered combinations
|
||||
assert child.is_request_supported(str, str) is False
|
||||
assert child.is_request_supported(int, str) is False
|
||||
@@ -6,12 +6,14 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
|
||||
from agent_framework._workflows._const import INTERNAL_SOURCE_ID
|
||||
from agent_framework._workflows._edge import (
|
||||
Case,
|
||||
Default,
|
||||
Edge,
|
||||
FanInEdgeGroup,
|
||||
FanOutEdgeGroup,
|
||||
InternalEdgeGroup,
|
||||
SingleEdgeGroup,
|
||||
SwitchCaseEdgeGroup,
|
||||
SwitchCaseEdgeGroupCase,
|
||||
@@ -557,16 +559,32 @@ class TestSerializationWorkflowClasses:
|
||||
|
||||
# Verify edge groups contain edges
|
||||
edge_groups = data["edge_groups"]
|
||||
assert len(edge_groups) == 1, "Should have exactly one edge group"
|
||||
edge_group = edge_groups[0]
|
||||
assert "edges" in edge_group, "Edge group should contain 'edges' field"
|
||||
assert len(edge_group["edges"]) == 1, "Should have exactly one edge"
|
||||
|
||||
edge = edge_group["edges"][0]
|
||||
assert "source_id" in edge, "Edge should have source_id"
|
||||
assert "target_id" in edge, "Edge should have target_id"
|
||||
assert edge["source_id"] == "executor1", f"Expected source_id 'executor1', got {edge['source_id']}"
|
||||
assert edge["target_id"] == "executor2", f"Expected target_id 'executor2', got {edge['target_id']}"
|
||||
single_edge_groups = [SingleEdgeGroup.from_dict(eg) for eg in edge_groups if eg["type"] == "SingleEdgeGroup"]
|
||||
internal_edge_groups = [
|
||||
InternalEdgeGroup.from_dict(eg) for eg in edge_groups if eg["type"] == "InternalEdgeGroup"
|
||||
]
|
||||
|
||||
assert len(single_edge_groups) == 1, "Should have exactly one SingleEdgeGroup for the added edge"
|
||||
assert len(internal_edge_groups) == 2, (
|
||||
"Should have exactly two (one per executor) InternalEdgeGroups for request/response handling"
|
||||
)
|
||||
|
||||
for edge_group in single_edge_groups:
|
||||
assert len(edge_group.edges) == 1, "Should have exactly one edge"
|
||||
|
||||
edge = edge_group.edges[0]
|
||||
|
||||
assert edge.source_id == "executor1", f"Expected source_id 'executor1', got {edge.source_id}"
|
||||
assert edge.target_id == "executor2", f"Expected target_id 'executor2', got {edge.target_id}"
|
||||
|
||||
for edge_group in internal_edge_groups:
|
||||
assert len(edge_group.edges) == 1, "Each InternalEdgeGroup should have exactly one edge"
|
||||
|
||||
edge = edge_group.edges[0]
|
||||
|
||||
assert edge.source_id == INTERNAL_SOURCE_ID(edge.target_id)
|
||||
assert edge.target_id in [executor1.id, executor2.id]
|
||||
|
||||
# Test model_dump_json
|
||||
json_str = workflow.to_json()
|
||||
@@ -577,12 +595,21 @@ class TestSerializationWorkflowClasses:
|
||||
|
||||
# Verify edges are preserved in JSON serialization
|
||||
json_edge_groups = parsed["edge_groups"]
|
||||
assert len(json_edge_groups) == 1, "JSON should have exactly one edge group"
|
||||
json_edge_group = json_edge_groups[0]
|
||||
assert "edges" in json_edge_group, "JSON edge group should contain 'edges' field"
|
||||
json_edge = json_edge_group["edges"][0]
|
||||
assert json_edge["source_id"] == "executor1", "JSON should preserve edge source_id"
|
||||
assert json_edge["target_id"] == "executor2", "JSON should preserve edge target_id"
|
||||
assert len(json_edge_groups) == 1 + 2, "JSON should have exactly one SingleEdgeGroup and two InternalEdgeGroups"
|
||||
|
||||
for json_edge_group in json_edge_groups:
|
||||
assert "edges" in json_edge_group, "JSON edge group should contain 'edges' field"
|
||||
assert len(json_edge_group["edges"]) == 1, "Each JSON edge group should have exactly one edge"
|
||||
if json_edge_group["type"] == "SingleEdgeGroup":
|
||||
json_edge = json_edge_group["edges"][0]
|
||||
assert json_edge["source_id"] == "executor1", "JSON should preserve edge source_id"
|
||||
assert json_edge["target_id"] == "executor2", "JSON should preserve edge target_id"
|
||||
elif json_edge_group["type"] == "InternalEdgeGroup":
|
||||
json_edge = json_edge_group["edges"][0]
|
||||
assert json_edge["source_id"] == INTERNAL_SOURCE_ID(json_edge["target_id"])
|
||||
assert json_edge["target_id"] in [executor1.id, executor2.id]
|
||||
else:
|
||||
pytest.fail(f"Unexpected edge group type: {json_edge_group['type']}")
|
||||
|
||||
def test_workflow_serialization_excludes_non_serializable_fields(self) -> None:
|
||||
"""Test that non-serializable fields are excluded from serialization."""
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,9 +27,10 @@ class EmailValidationRequest:
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomainCheckRequest(RequestInfoMessage):
|
||||
class DomainCheckRequest:
|
||||
"""Request to check if a domain is approved."""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
domain: str = ""
|
||||
email: str = "" # Include original email for correlation
|
||||
|
||||
@@ -43,72 +44,93 @@ class ValidationResult:
|
||||
reason: str
|
||||
|
||||
|
||||
# Test helper functions
|
||||
def create_email_validation_workflow() -> Workflow:
|
||||
"""Create a standard email validation workflow."""
|
||||
email_validator = EmailValidator()
|
||||
email_request_info = RequestInfoExecutor(id="email_request_info")
|
||||
|
||||
return (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(email_validator)
|
||||
.add_edge(email_validator, email_request_info)
|
||||
.add_edge(email_request_info, email_validator)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
class BasicParent(Executor):
|
||||
"""Basic parent executor for simple sub-workflow tests."""
|
||||
class Coordinator(Executor):
|
||||
"""Coordinator executor in the parent workflow for simple sub-workflow tests."""
|
||||
|
||||
def __init__(self, cache: dict[str, bool] | None = None) -> None:
|
||||
super().__init__(id="basic_parent")
|
||||
self.result: ValidationResult | None = None
|
||||
self.cache: dict[str, bool] = dict(cache) if cache is not None else {}
|
||||
self._pending_sub_workflow_requests: dict[str, SubWorkflowRequestMessage] = {}
|
||||
|
||||
@handler
|
||||
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
request = EmailValidationRequest(email=email)
|
||||
await ctx.send_message(request, target_id="email_workflow")
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
request: DomainCheckRequest,
|
||||
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
|
||||
sub_workflow_request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handle requests from sub-workflows with optional caching."""
|
||||
domain_request = request
|
||||
if not isinstance(sub_workflow_request.source_event.data, DomainCheckRequest):
|
||||
raise ValueError("Unexpected request type")
|
||||
|
||||
domain_request = sub_workflow_request.source_event.data
|
||||
|
||||
if domain_request.domain in self.cache:
|
||||
# Return cached result
|
||||
response = RequestResponse(
|
||||
data=self.cache[domain_request.domain], original_request=request, request_id=request.request_id
|
||||
)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
await ctx.send_message(sub_workflow_request.create_response(self.cache[domain_request.domain]))
|
||||
else:
|
||||
# Not in cache, forward to external
|
||||
await ctx.send_message(request)
|
||||
self._pending_sub_workflow_requests[domain_request.id] = sub_workflow_request
|
||||
await ctx.request_info(domain_request, DomainCheckRequest, bool)
|
||||
|
||||
@response_handler
|
||||
async def handle_domain_response(
|
||||
self,
|
||||
original_request: DomainCheckRequest,
|
||||
is_approved: bool,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handle domain check response with correlation and send the response back to the sub-workflow."""
|
||||
if original_request.id not in self._pending_sub_workflow_requests:
|
||||
raise ValueError("No pending sub-workflow request for the given domain check response")
|
||||
|
||||
sub_workflow_request = self._pending_sub_workflow_requests.pop(original_request.id)
|
||||
await ctx.send_message(sub_workflow_request.create_response(is_approved))
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
self.result = result
|
||||
|
||||
|
||||
# Test executors
|
||||
class EmailValidator(Executor):
|
||||
class EmailFormatValidator(Executor):
|
||||
"""Validates the format of an email address."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="email_format_validator")
|
||||
|
||||
@handler
|
||||
async def validate(
|
||||
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
|
||||
) -> None:
|
||||
"""Validate email format and extract domain."""
|
||||
email = request.email
|
||||
if "@" not in email:
|
||||
result = ValidationResult(email=email, is_valid=False, reason="Invalid email format")
|
||||
await ctx.yield_output(result)
|
||||
return
|
||||
|
||||
domain = email.split("@")[1]
|
||||
domain_check = DomainCheckRequest(domain=domain, email=email)
|
||||
await ctx.send_message(domain_check)
|
||||
|
||||
|
||||
class EmailDomainValidator(Executor):
|
||||
"""Validates email addresses in a sub-workflow."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="email_validator")
|
||||
super().__init__(id="email_domain_validator")
|
||||
|
||||
@handler
|
||||
async def validate_request(
|
||||
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
|
||||
self, request: DomainCheckRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
|
||||
) -> None:
|
||||
"""Validate an email address."""
|
||||
# Extract domain and check if it's approved
|
||||
domain = request.email.split("@")[1] if "@" in request.email else ""
|
||||
domain = request.domain
|
||||
|
||||
if not domain:
|
||||
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
|
||||
@@ -116,62 +138,37 @@ class EmailValidator(Executor):
|
||||
return
|
||||
|
||||
# Request domain check from external source
|
||||
domain_check = DomainCheckRequest(domain=domain, email=request.email)
|
||||
await ctx.send_message(domain_check)
|
||||
await ctx.request_info(request, DomainCheckRequest, bool)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def handle_domain_response(
|
||||
self, response: RequestResponse[DomainCheckRequest, bool], ctx: WorkflowContext[Never, ValidationResult]
|
||||
self,
|
||||
original_request: DomainCheckRequest,
|
||||
is_approved: bool,
|
||||
ctx: WorkflowContext[Never, ValidationResult],
|
||||
) -> None:
|
||||
"""Handle domain check response with correlation."""
|
||||
# Use the original email from the correlated response
|
||||
result = ValidationResult(
|
||||
email=response.original_request.email,
|
||||
is_valid=response.data or False,
|
||||
reason="Domain approved" if response.data else "Domain not approved",
|
||||
email=original_request.email,
|
||||
is_valid=is_approved,
|
||||
reason="Domain approved" if is_approved else "Domain not approved",
|
||||
)
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
class ParentOrchestrator(Executor):
|
||||
"""Parent workflow orchestrator with domain knowledge."""
|
||||
# Test helper functions
|
||||
def create_email_validation_workflow() -> Workflow:
|
||||
"""Create a standard email validation workflow."""
|
||||
email_format_validator = EmailFormatValidator()
|
||||
email_domain_validator = EmailDomainValidator()
|
||||
|
||||
def __init__(self, approved_domains: set[str] | None = None) -> None:
|
||||
super().__init__(id="parent_orchestrator")
|
||||
self.approved_domains: set[str] = (
|
||||
set(approved_domains) if approved_domains is not None else {"example.com", "test.org"}
|
||||
)
|
||||
self.results: list[ValidationResult] = []
|
||||
|
||||
@handler
|
||||
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
"""Start processing emails."""
|
||||
for email in emails:
|
||||
request = EmailValidationRequest(email=email)
|
||||
await ctx.send_message(request, target_id="email_workflow")
|
||||
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
request: DomainCheckRequest,
|
||||
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
|
||||
) -> None:
|
||||
"""Handle requests from sub-workflows."""
|
||||
domain_request = request
|
||||
|
||||
# Check if we know this domain
|
||||
if domain_request.domain in self.approved_domains:
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# We don't know this domain, forward to external
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect validation results."""
|
||||
self.results.append(result)
|
||||
return (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(email_format_validator)
|
||||
.add_edge(email_format_validator, email_domain_validator)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
async def test_basic_sub_workflow() -> None:
|
||||
@@ -180,17 +177,14 @@ async def test_basic_sub_workflow() -> None:
|
||||
validation_workflow = create_email_validation_workflow()
|
||||
|
||||
# Create parent workflow without interception
|
||||
parent = BasicParent()
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
|
||||
main_request_info = RequestInfoExecutor(id="main_request_info")
|
||||
parent = Coordinator()
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_validation_workflow")
|
||||
|
||||
main_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(parent)
|
||||
.add_edge(parent, workflow_executor)
|
||||
.add_edge(workflow_executor, parent)
|
||||
.add_edge(workflow_executor, main_request_info)
|
||||
.add_edge(main_request_info, workflow_executor) # CRITICAL: For RequestResponse routing
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -220,17 +214,14 @@ async def test_sub_workflow_with_interception():
|
||||
validation_workflow = create_email_validation_workflow()
|
||||
|
||||
# Create parent workflow with interception cache
|
||||
parent = BasicParent(cache={"example.com": True, "internal.org": True})
|
||||
parent = Coordinator(cache={"example.com": True, "internal.org": True})
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
|
||||
parent_request_info = RequestInfoExecutor(id="request_info")
|
||||
|
||||
main_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(parent)
|
||||
.add_edge(parent, workflow_executor)
|
||||
.add_edge(workflow_executor, parent)
|
||||
.add_edge(parent, parent_request_info) # For forwarded requests
|
||||
.add_edge(parent_request_info, workflow_executor) # For RequestResponse routing
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -276,6 +267,7 @@ async def test_workflow_scoped_interception() -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="multi_parent")
|
||||
self.results: dict[str, ValidationResult] = {}
|
||||
self._pending_sub_workflow_requests: dict[str, SubWorkflowRequestMessage] = {}
|
||||
|
||||
@handler
|
||||
async def start(self, data: dict[str, str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
@@ -286,30 +278,47 @@ async def test_workflow_scoped_interception() -> None:
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
request: DomainCheckRequest,
|
||||
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
|
||||
sub_workflow_request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
domain_request = request
|
||||
"""Handle requests from sub-workflows with optional caching."""
|
||||
if not isinstance(sub_workflow_request.source_event.data, DomainCheckRequest):
|
||||
raise ValueError("Unexpected request type")
|
||||
|
||||
if request.source_executor_id == "workflow_a":
|
||||
domain_request = sub_workflow_request.source_event.data
|
||||
|
||||
if sub_workflow_request.executor_id == "workflow_a" and domain_request.domain == "strict.com":
|
||||
# Strict rules for workflow A
|
||||
if domain_request.domain == "strict.com":
|
||||
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# Forward to external
|
||||
await ctx.send_message(request)
|
||||
elif request.source_executor_id == "workflow_b":
|
||||
await ctx.send_message(
|
||||
sub_workflow_request.create_response(True), target_id=sub_workflow_request.executor_id
|
||||
)
|
||||
return
|
||||
if sub_workflow_request.executor_id == "workflow_b" and domain_request.domain.endswith(".com"):
|
||||
# Lenient rules for workflow B
|
||||
if domain_request.domain.endswith(".com"):
|
||||
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# Forward to external
|
||||
await ctx.send_message(request)
|
||||
else:
|
||||
# Unknown source, forward to external
|
||||
await ctx.send_message(request)
|
||||
await ctx.send_message(
|
||||
sub_workflow_request.create_response(True), target_id=sub_workflow_request.executor_id
|
||||
)
|
||||
return
|
||||
|
||||
# Unknown source, forward to external
|
||||
self._pending_sub_workflow_requests[domain_request.id] = sub_workflow_request
|
||||
await ctx.request_info(domain_request, DomainCheckRequest, bool)
|
||||
|
||||
@response_handler
|
||||
async def handle_domain_response(
|
||||
self,
|
||||
original_request: DomainCheckRequest,
|
||||
is_approved: bool,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handle domain check response with correlation and send the response back to the sub-workflow."""
|
||||
if original_request.id not in self._pending_sub_workflow_requests:
|
||||
raise ValueError("No pending sub-workflow request for the given domain check response")
|
||||
|
||||
sub_workflow_request = self._pending_sub_workflow_requests.pop(original_request.id)
|
||||
await ctx.send_message(
|
||||
sub_workflow_request.create_response(is_approved), target_id=sub_workflow_request.executor_id
|
||||
)
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
@@ -322,7 +331,6 @@ async def test_workflow_scoped_interception() -> None:
|
||||
parent = MultiWorkflowParent()
|
||||
executor_a = WorkflowExecutor(workflow_a, "workflow_a")
|
||||
executor_b = WorkflowExecutor(workflow_b, "workflow_b")
|
||||
parent_request_info = RequestInfoExecutor(id="request_info")
|
||||
|
||||
main_workflow = (
|
||||
WorkflowBuilder()
|
||||
@@ -331,9 +339,6 @@ async def test_workflow_scoped_interception() -> None:
|
||||
.add_edge(parent, executor_b)
|
||||
.add_edge(executor_a, parent)
|
||||
.add_edge(executor_b, parent)
|
||||
.add_edge(parent, parent_request_info)
|
||||
.add_edge(parent_request_info, executor_a) # For RequestResponse routing
|
||||
.add_edge(parent_request_info, executor_b) # For RequestResponse routing
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -359,6 +364,7 @@ async def test_concurrent_sub_workflow_execution() -> None:
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="concurrent_processor")
|
||||
self.results: list[ValidationResult] = []
|
||||
self._pending_sub_workflow_requests: dict[str, SubWorkflowRequestMessage] = {}
|
||||
|
||||
@handler
|
||||
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
@@ -366,7 +372,35 @@ async def test_concurrent_sub_workflow_execution() -> None:
|
||||
# Send all requests concurrently to the same workflow executor
|
||||
for email in emails:
|
||||
request = EmailValidationRequest(email=email)
|
||||
await ctx.send_message(request, target_id="email_workflow")
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
sub_workflow_request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handle requests from sub-workflows with optional caching."""
|
||||
if not isinstance(sub_workflow_request.source_event.data, DomainCheckRequest):
|
||||
raise ValueError("Unexpected request type")
|
||||
|
||||
domain_request = sub_workflow_request.source_event.data
|
||||
self._pending_sub_workflow_requests[domain_request.id] = sub_workflow_request
|
||||
await ctx.request_info(domain_request, DomainCheckRequest, bool)
|
||||
|
||||
@response_handler
|
||||
async def handle_domain_response(
|
||||
self,
|
||||
original_request: DomainCheckRequest,
|
||||
is_approved: bool,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handle domain check response with correlation and send the response back to the sub-workflow."""
|
||||
if original_request.id not in self._pending_sub_workflow_requests:
|
||||
raise ValueError("No pending sub-workflow request for the given domain check response")
|
||||
|
||||
sub_workflow_request = self._pending_sub_workflow_requests.pop(original_request.id)
|
||||
await ctx.send_message(sub_workflow_request.create_response(is_approved))
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
@@ -379,15 +413,12 @@ async def test_concurrent_sub_workflow_execution() -> None:
|
||||
# Create parent workflow
|
||||
processor = ConcurrentProcessor()
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
|
||||
parent_request_info = RequestInfoExecutor(id="request_info")
|
||||
|
||||
main_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(processor)
|
||||
.add_edge(processor, workflow_executor)
|
||||
.add_edge(workflow_executor, processor)
|
||||
.add_edge(workflow_executor, parent_request_info) # For external requests
|
||||
.add_edge(parent_request_info, workflow_executor) # For RequestResponse routing
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar, Union
|
||||
|
||||
from agent_framework._workflows import RequestInfoMessage, RequestResponse
|
||||
from agent_framework._workflows._typing_utils import is_instance_of, is_type_compatible
|
||||
from agent_framework import RequestInfoEvent
|
||||
from agent_framework._workflows._typing_utils import (
|
||||
deserialize_type,
|
||||
is_instance_of,
|
||||
is_type_compatible,
|
||||
serialize_type,
|
||||
)
|
||||
|
||||
|
||||
def test_basic_types() -> None:
|
||||
@@ -91,22 +96,6 @@ def test_custom_type() -> None:
|
||||
assert not is_instance_of(instance, dict)
|
||||
|
||||
|
||||
def test_request_response_type() -> None:
|
||||
"""Test RequestResponse generic type checking."""
|
||||
|
||||
request_instance = RequestResponse[RequestInfoMessage, str](
|
||||
data="approve",
|
||||
request_id="req-1",
|
||||
original_request=RequestInfoMessage(),
|
||||
)
|
||||
|
||||
class CustomRequestInfoMessage(RequestInfoMessage):
|
||||
info: str
|
||||
|
||||
assert is_instance_of(request_instance, RequestResponse[RequestInfoMessage, str])
|
||||
assert not is_instance_of(request_instance, RequestResponse[CustomRequestInfoMessage, str])
|
||||
|
||||
|
||||
def test_custom_generic_type() -> None:
|
||||
"""Test custom generic type checking."""
|
||||
|
||||
@@ -135,12 +124,90 @@ def test_edge_cases() -> None:
|
||||
assert not is_instance_of(5, str | None) # Optional type without matching type
|
||||
|
||||
|
||||
def test_serialize_type() -> None:
|
||||
"""Test serialization of types to strings."""
|
||||
# Test built-in types
|
||||
assert serialize_type(int) == "builtins.int"
|
||||
assert serialize_type(str) == "builtins.str"
|
||||
assert serialize_type(float) == "builtins.float"
|
||||
assert serialize_type(bool) == "builtins.bool"
|
||||
assert serialize_type(list) == "builtins.list"
|
||||
assert serialize_type(dict) == "builtins.dict"
|
||||
assert serialize_type(tuple) == "builtins.tuple"
|
||||
assert serialize_type(set) == "builtins.set"
|
||||
|
||||
# Test custom class
|
||||
@dataclass
|
||||
class TestClass:
|
||||
value: int
|
||||
|
||||
# The custom class will be in the test module
|
||||
expected = f"{TestClass.__module__}.{TestClass.__qualname__}"
|
||||
assert serialize_type(TestClass) == expected
|
||||
|
||||
|
||||
def test_deserialize_type() -> None:
|
||||
"""Test deserialization of type strings back to types."""
|
||||
# Test built-in types
|
||||
assert deserialize_type("builtins.int") is int
|
||||
assert deserialize_type("builtins.str") is str
|
||||
assert deserialize_type("builtins.float") is float
|
||||
assert deserialize_type("builtins.bool") is bool
|
||||
assert deserialize_type("builtins.list") is list
|
||||
assert deserialize_type("builtins.dict") is dict
|
||||
assert deserialize_type("builtins.tuple") is tuple
|
||||
assert deserialize_type("builtins.set") is set
|
||||
|
||||
|
||||
def test_serialize_deserialize_roundtrip() -> None:
|
||||
"""Test that serialization and deserialization are inverse operations."""
|
||||
# Test built-in types
|
||||
types_to_test = [int, str, float, bool, list, dict, tuple, set]
|
||||
|
||||
for type_to_test in types_to_test:
|
||||
serialized = serialize_type(type_to_test)
|
||||
deserialized = deserialize_type(serialized)
|
||||
assert deserialized is type_to_test
|
||||
|
||||
# Test agent framework type roundtrip
|
||||
|
||||
serialized = serialize_type(RequestInfoEvent)
|
||||
deserialized = deserialize_type(serialized)
|
||||
assert deserialized is RequestInfoEvent
|
||||
|
||||
# Verify we can instantiate the deserialized type
|
||||
instance = deserialized(
|
||||
request_id="request-123",
|
||||
source_executor_id="executor_1",
|
||||
request_type=str,
|
||||
request_data="test",
|
||||
response_type=str,
|
||||
)
|
||||
assert isinstance(instance, RequestInfoEvent)
|
||||
|
||||
|
||||
def test_deserialize_type_error_handling() -> None:
|
||||
"""Test error handling in deserialize_type function."""
|
||||
import pytest
|
||||
|
||||
# Test with non-existent module
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
deserialize_type("nonexistent.module.Type")
|
||||
|
||||
# Test with non-existent type in existing module
|
||||
with pytest.raises(AttributeError):
|
||||
deserialize_type("builtins.NonExistentType")
|
||||
|
||||
|
||||
def test_type_compatibility_basic() -> None:
|
||||
"""Test basic type compatibility scenarios."""
|
||||
# Exact type match
|
||||
assert is_type_compatible(str, str)
|
||||
assert is_type_compatible(int, int)
|
||||
|
||||
# bool is a subtype of int
|
||||
assert is_type_compatible(bool, int)
|
||||
|
||||
# Any compatibility
|
||||
assert is_type_compatible(str, Any)
|
||||
assert is_type_compatible(list[int], Any)
|
||||
|
||||
@@ -8,7 +8,6 @@ import pytest
|
||||
from agent_framework import (
|
||||
EdgeDuplicationError,
|
||||
Executor,
|
||||
ExecutorDuplicationError,
|
||||
GraphConnectivityError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
@@ -83,11 +82,10 @@ def test_duplicate_executor_ids_fail_validation():
|
||||
executor1 = StringExecutor(id="dup")
|
||||
executor2 = IntExecutor(id="dup")
|
||||
|
||||
with pytest.raises(ExecutorDuplicationError) as exc_info:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
(WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor1).build())
|
||||
|
||||
assert exc_info.value.executor_id == "dup"
|
||||
assert exc_info.value.validation_type == ValidationTypeEnum.EXECUTOR_DUPLICATION
|
||||
assert str(exc_info.value) == "Duplicate executor ID 'dup' detected in workflow."
|
||||
|
||||
|
||||
def test_edge_duplication_validation_fails():
|
||||
@@ -185,7 +183,7 @@ def test_graph_connectivity_isolated_executors():
|
||||
assert "executor3" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_start_executor_not_in_graph():
|
||||
def test_disconnected_start_executor_not_in_graph():
|
||||
executor1 = StringExecutor(id="executor1")
|
||||
executor2 = StringExecutor(id="executor2")
|
||||
executor3 = StringExecutor(id="executor3") # Not in graph
|
||||
@@ -193,7 +191,7 @@ def test_start_executor_not_in_graph():
|
||||
with pytest.raises(GraphConnectivityError) as exc_info:
|
||||
WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor3).build()
|
||||
|
||||
assert "not present in the workflow graph" in str(exc_info.value)
|
||||
assert "The following executors are unreachable from the start executor 'executor3'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_missing_start_executor():
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -21,9 +22,6 @@ from agent_framework import (
|
||||
FileCheckpointStorage,
|
||||
Message,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Role,
|
||||
TextContent,
|
||||
WorkflowBuilder,
|
||||
@@ -33,6 +31,7 @@ from agent_framework import (
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +67,14 @@ class AggregatorExecutor(Executor):
|
||||
await ctx.yield_output(sum(msg.data for msg in messages))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockRequest:
|
||||
"""A mock request message for testing purposes."""
|
||||
|
||||
request_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
prompt: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApprovalMessage:
|
||||
"""A mock message for approval requests."""
|
||||
@@ -79,22 +86,22 @@ class MockExecutorRequestApproval(Executor):
|
||||
"""A mock executor that simulates a request for approval."""
|
||||
|
||||
@handler
|
||||
async def mock_handler_a(self, message: NumberMessage, ctx: WorkflowContext[RequestInfoMessage]) -> None:
|
||||
async def mock_handler_a(self, message: NumberMessage, ctx: WorkflowContext) -> None:
|
||||
"""A mock handler that requests approval."""
|
||||
await ctx.set_shared_state(self.id, message.data)
|
||||
await ctx.send_message(RequestInfoMessage())
|
||||
await ctx.request_info(MockRequest(prompt="Mock approval request"), MockRequest, ApprovalMessage)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def mock_handler_b(
|
||||
self,
|
||||
message: RequestResponse[RequestInfoMessage, ApprovalMessage],
|
||||
original_request: MockRequest,
|
||||
response: ApprovalMessage,
|
||||
ctx: WorkflowContext[NumberMessage, int],
|
||||
) -> None:
|
||||
"""A mock handler that processes the approval response."""
|
||||
data = await ctx.get_shared_state(self.id)
|
||||
assert isinstance(data, int)
|
||||
assert isinstance(message.data, ApprovalMessage)
|
||||
if message.data.approved:
|
||||
if response.approved:
|
||||
await ctx.yield_output(data)
|
||||
else:
|
||||
await ctx.send_message(NumberMessage(data=data))
|
||||
@@ -182,15 +189,12 @@ async def test_workflow_send_responses_streaming():
|
||||
"""Test the workflow run with approval."""
|
||||
executor_a = IncrementExecutor(id="executor_a")
|
||||
executor_b = MockExecutorRequestApproval(id="executor_b")
|
||||
request_info_executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(executor_a)
|
||||
.add_edge(executor_a, executor_b)
|
||||
.add_edge(executor_b, executor_a)
|
||||
.add_edge(executor_b, request_info_executor)
|
||||
.add_edge(request_info_executor, executor_b)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -219,15 +223,12 @@ async def test_workflow_send_responses():
|
||||
"""Test the workflow run with approval."""
|
||||
executor_a = IncrementExecutor(id="executor_a")
|
||||
executor_b = MockExecutorRequestApproval(id="executor_b")
|
||||
request_info_executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(executor_a)
|
||||
.add_edge(executor_a, executor_b)
|
||||
.add_edge(executor_b, executor_a)
|
||||
.add_edge(executor_b, request_info_executor)
|
||||
.add_edge(request_info_executor, executor_b)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -480,6 +481,15 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo
|
||||
workflow_id="test-workflow",
|
||||
messages={},
|
||||
shared_state={},
|
||||
pending_request_info_events={
|
||||
"request_123": RequestInfoEvent(
|
||||
request_id="request_123",
|
||||
source_executor_id=simple_executor.id,
|
||||
request_type=str,
|
||||
request_data="Mock",
|
||||
response_type=str,
|
||||
).to_dict(),
|
||||
},
|
||||
iteration_count=0,
|
||||
)
|
||||
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
|
||||
@@ -494,17 +504,20 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo
|
||||
)
|
||||
|
||||
# Test that run_stream_from_checkpoint accepts responses parameter
|
||||
responses = {"request_123": {"data": "test_response"}}
|
||||
responses = {"request_123": "test_response"}
|
||||
|
||||
try:
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run_stream_from_checkpoint(checkpoint_id, responses=responses):
|
||||
events.append(event)
|
||||
if len(events) >= 2: # Limit to avoid infinite loops
|
||||
break
|
||||
except Exception:
|
||||
# Expected since we have minimal setup, but method should accept the parameters
|
||||
pass
|
||||
events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run_stream_from_checkpoint(checkpoint_id):
|
||||
events.append(event)
|
||||
|
||||
assert next(
|
||||
event for event in events if isinstance(event, RequestInfoEvent) and event.request_id == "request_123"
|
||||
)
|
||||
|
||||
async for event in workflow.send_responses_streaming(responses):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) > 0 # Just ensure we processed some events
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -737,7 +750,7 @@ async def test_workflow_concurrent_execution_prevention_streaming():
|
||||
|
||||
# Create an async generator that will consume the stream slowly
|
||||
async def consume_stream_slowly():
|
||||
result = []
|
||||
result: list[WorkflowEvent] = []
|
||||
async for event in workflow.run_stream(NumberMessage(data=0)):
|
||||
result.append(event)
|
||||
await asyncio.sleep(0.01) # Slow consumption
|
||||
@@ -770,7 +783,7 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
|
||||
|
||||
# Start a streaming execution
|
||||
async def consume_stream():
|
||||
result = []
|
||||
result: list[WorkflowEvent] = []
|
||||
async for event in workflow.run_stream(NumberMessage(data=0)):
|
||||
result.append(event)
|
||||
await asyncio.sleep(0.01)
|
||||
@@ -846,6 +859,7 @@ async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
assert len(agent_run_events) == 1, "Expected exactly one AgentRunEvent in non-streaming mode"
|
||||
assert len(agent_update_events) == 0, "Expected no AgentRunUpdateEvent in non-streaming mode"
|
||||
assert agent_run_events[0].executor_id == "agent_exec"
|
||||
assert agent_run_events[0].data is not None
|
||||
assert agent_run_events[0].data.messages[0].text == "Hello World"
|
||||
|
||||
# Test streaming mode with run_stream()
|
||||
@@ -866,6 +880,8 @@ async def test_agent_streaming_vs_non_streaming() -> None:
|
||||
|
||||
# Verify the updates build up to the full message
|
||||
accumulated_text = "".join(
|
||||
e.data.contents[0].text for e in stream_agent_update_events if e.data.contents and e.data.contents[0].text
|
||||
e.data.contents[0].text
|
||||
for e in stream_agent_update_events
|
||||
if e.data and e.data.contents and e.data.contents[0].text
|
||||
)
|
||||
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
|
||||
|
||||
@@ -14,8 +14,6 @@ from agent_framework import (
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
Role,
|
||||
TextContent,
|
||||
UsageContent,
|
||||
@@ -24,6 +22,7 @@ from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -56,15 +55,17 @@ class SimpleExecutor(Executor):
|
||||
|
||||
|
||||
class RequestingExecutor(Executor):
|
||||
"""Executor that sends RequestInfoMessage to trigger RequestInfoEvent."""
|
||||
"""Executor that requests info."""
|
||||
|
||||
@handler
|
||||
async def handle_message(self, _: list[ChatMessage], ctx: WorkflowContext[RequestInfoMessage]) -> None:
|
||||
async def handle_message(self, _: list[ChatMessage], ctx: WorkflowContext) -> None:
|
||||
# Send a RequestInfoMessage to trigger the request info process
|
||||
await ctx.send_message(RequestInfoMessage())
|
||||
await ctx.request_info("Mock request data", str, str)
|
||||
|
||||
@handler
|
||||
async def handle_request_response(self, _: Any, ctx: WorkflowContext[ChatMessage]) -> None:
|
||||
@response_handler
|
||||
async def handle_request_response(
|
||||
self, original_request: str, response: str, ctx: WorkflowContext[ChatMessage]
|
||||
) -> None:
|
||||
# Handle the response and emit completion response
|
||||
update = AgentRunResponseUpdate(
|
||||
contents=[TextContent(text="Request completed successfully")],
|
||||
@@ -148,14 +149,11 @@ class TestWorkflowAgent:
|
||||
async def test_end_to_end_request_info_handling(self):
|
||||
"""Test end-to-end workflow with RequestInfoEvent handling."""
|
||||
# Create workflow with requesting executor -> request info executor (no cycle)
|
||||
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", emit_streaming=False)
|
||||
requesting_executor = RequestingExecutor(id="requester")
|
||||
request_info_executor = RequestInfoExecutor(id="request_info")
|
||||
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(requesting_executor)
|
||||
.add_edge(requesting_executor, request_info_executor)
|
||||
.build()
|
||||
WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requesting_executor).build()
|
||||
)
|
||||
|
||||
agent = WorkflowAgent(workflow=workflow, name="Request Test Agent")
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_workflow_builder_fluent_api():
|
||||
.build()
|
||||
)
|
||||
|
||||
assert len(workflow.edge_groups) == 4
|
||||
assert len(workflow.edge_groups) == 4 + 6 # 4 defined edges + 6 internal edges for request-response handling
|
||||
assert workflow.start_executor_id == executor_a.id
|
||||
assert len(workflow.executors) == 6
|
||||
|
||||
|
||||
@@ -24,6 +24,18 @@ if TYPE_CHECKING:
|
||||
from agent_framework._workflows._runner_context import InProcRunnerContext
|
||||
|
||||
|
||||
class MockExecutor(Executor):
|
||||
"""Mock executor for testing."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Handle string messages."""
|
||||
...
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def make_context(
|
||||
executor_id: str = "exec",
|
||||
@@ -31,10 +43,11 @@ async def make_context(
|
||||
from agent_framework._workflows._runner_context import InProcRunnerContext
|
||||
from agent_framework._workflows._shared_state import SharedState
|
||||
|
||||
mock_executor = MockExecutor(executor_id)
|
||||
runner_ctx = InProcRunnerContext()
|
||||
shared_state = SharedState()
|
||||
workflow_ctx: WorkflowContext[object] = WorkflowContext(
|
||||
executor_id,
|
||||
mock_executor,
|
||||
["source"],
|
||||
shared_state,
|
||||
runner_ctx,
|
||||
|
||||
@@ -8,7 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
|
||||
|
||||
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
|
||||
from agent_framework._workflows._executor import Executor, handler
|
||||
from agent_framework._workflows._runner_context import InProcRunnerContext, Message
|
||||
from agent_framework._workflows._runner_context import InProcRunnerContext, Message, MessageType
|
||||
from agent_framework._workflows._shared_state import SharedState
|
||||
from agent_framework._workflows._workflow import Workflow
|
||||
from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
@@ -127,7 +127,9 @@ async def test_span_creation_and_attributes(span_exporter: InMemorySpanExporter)
|
||||
OtelAttr.MESSAGE_DESTINATION_EXECUTOR_ID: "target-789",
|
||||
}
|
||||
with (
|
||||
create_processing_span("executor-456", "TestExecutor", "TestMessage") as processing_span,
|
||||
create_processing_span(
|
||||
"executor-456", "TestExecutor", str(MessageType.STANDARD), "TestMessage"
|
||||
) as processing_span,
|
||||
create_workflow_span(
|
||||
OtelAttr.MESSAGE_SEND_SPAN, sending_attributes, kind=trace.SpanKind.PRODUCER
|
||||
) as sending_span,
|
||||
@@ -155,7 +157,8 @@ async def test_span_creation_and_attributes(span_exporter: InMemorySpanExporter)
|
||||
assert processing_span.attributes is not None
|
||||
assert processing_span.attributes.get("executor.id") == "executor-456"
|
||||
assert processing_span.attributes.get("executor.type") == "TestExecutor"
|
||||
assert processing_span.attributes.get("message.type") == "TestMessage"
|
||||
assert processing_span.attributes.get("message.type") == str(MessageType.STANDARD)
|
||||
assert processing_span.attributes.get("message.payload_type") == "TestMessage"
|
||||
|
||||
# Check sending span
|
||||
sending_span = next(s for s in spans if s.name == "message.send")
|
||||
@@ -175,7 +178,7 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
|
||||
|
||||
# Test trace context propagation in messages
|
||||
workflow_ctx: WorkflowContext[str] = WorkflowContext(
|
||||
"test-executor",
|
||||
executor,
|
||||
["source"],
|
||||
shared_state,
|
||||
ctx,
|
||||
@@ -218,18 +221,20 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
|
||||
assert processing_span.attributes is not None
|
||||
assert processing_span.attributes.get("executor.id") == "test-executor"
|
||||
assert processing_span.attributes.get("executor.type") == "MockExecutor"
|
||||
assert processing_span.attributes.get("message.type") == "str"
|
||||
assert processing_span.attributes.get("message.type") == str(MessageType.STANDARD)
|
||||
assert processing_span.attributes.get("message.payload_type") == "str"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
|
||||
async def test_trace_context_disabled_when_tracing_disabled(enable_otel, span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test that no trace context is added when tracing is disabled."""
|
||||
# Tracing should be disabled by default
|
||||
executor = MockExecutor("test-executor")
|
||||
shared_state = SharedState()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
workflow_ctx: WorkflowContext[str] = WorkflowContext(
|
||||
"test-executor",
|
||||
executor,
|
||||
["source"],
|
||||
shared_state,
|
||||
ctx,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
@@ -10,8 +8,6 @@ from agent_framework import (
|
||||
ExecutorFailedEvent,
|
||||
InProcRunnerContext,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
SharedState,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
@@ -69,18 +65,26 @@ async def test_executor_failed_event_emitted_on_direct_execute():
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed)
|
||||
|
||||
|
||||
class SimpleExecutor(Executor):
|
||||
"""Executor that does nothing, for testing."""
|
||||
|
||||
@handler
|
||||
async def run(self, msg: str, ctx: WorkflowContext[str]) -> None: # pragma: no cover
|
||||
await ctx.send_message(msg)
|
||||
|
||||
|
||||
class Requester(Executor):
|
||||
"""Executor that always requests external info to test idle-with-requests state."""
|
||||
|
||||
@handler
|
||||
async def ask(self, _: str, ctx: WorkflowContext[RequestInfoMessage]) -> None: # pragma: no cover
|
||||
await ctx.send_message(RequestInfoMessage())
|
||||
async def ask(self, _: str, ctx: WorkflowContext) -> None: # pragma: no cover
|
||||
await ctx.request_info("Mock request data", str, str)
|
||||
|
||||
|
||||
async def test_idle_with_pending_requests_status_streaming():
|
||||
req = Requester(id="req")
|
||||
rie = RequestInfoExecutor(id="rie")
|
||||
wf = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
|
||||
simple_executor = SimpleExecutor(id="simple")
|
||||
requester = Requester(id="req")
|
||||
wf = WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requester).build()
|
||||
|
||||
events = [ev async for ev in wf.run_stream("start")] # Consume stream fully
|
||||
|
||||
@@ -134,9 +138,9 @@ async def test_non_streaming_final_state_helpers():
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Idle-with-pending-request case
|
||||
req = Requester(id="req")
|
||||
rie = RequestInfoExecutor(id="rie")
|
||||
wf2 = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
|
||||
simple_executor = SimpleExecutor(id="simple")
|
||||
requester = Requester(id="req")
|
||||
wf2 = WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requester).build()
|
||||
result2: WorkflowRunResult = await wf2.run("start")
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
@@ -151,32 +155,12 @@ async def test_run_includes_status_events_completed():
|
||||
|
||||
|
||||
async def test_run_includes_status_events_idle_with_requests():
|
||||
req = Requester(id="req2")
|
||||
rie = RequestInfoExecutor(id="rie2")
|
||||
wf = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
|
||||
simple_executor = SimpleExecutor(id="simple")
|
||||
requester = Requester(id="req2")
|
||||
wf = WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requester).build()
|
||||
result: WorkflowRunResult = await wf.run("start")
|
||||
timeline = result.status_timeline()
|
||||
assert timeline, "Expected status timeline in non-streaming run() results"
|
||||
assert len(timeline) >= 3
|
||||
assert timeline[-2].state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
|
||||
assert timeline[-1].state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapshotRequest(RequestInfoMessage):
|
||||
prompt: str = ""
|
||||
draft: str = ""
|
||||
iteration: int = 0
|
||||
|
||||
|
||||
class SnapshotRequester(Executor):
|
||||
"""Executor that emits a rich RequestInfoMessage for persistence tests."""
|
||||
|
||||
def __init__(self, id: str, prompt: str, draft: str) -> None:
|
||||
super().__init__(id=id)
|
||||
self._prompt = prompt
|
||||
self._draft = draft
|
||||
|
||||
@handler
|
||||
async def ask(self, _: str, ctx: WorkflowContext[SnapshotRequest]) -> None: # pragma: no cover - simple helper
|
||||
await ctx.send_message(SnapshotRequest(prompt=self._prompt, draft=self._draft, iteration=1))
|
||||
|
||||
@@ -56,9 +56,9 @@ def run_sample(
|
||||
sample_path: Path,
|
||||
use_uv: bool = True,
|
||||
python_root: Path | None = None,
|
||||
) -> tuple[bool, str, str]:
|
||||
) -> tuple[bool, str, str, str]:
|
||||
"""
|
||||
Run a single sample file using subprocess and return (success, output, error_info).
|
||||
Run a single sample file using subprocess and return (success, output, error_info, error_type).
|
||||
|
||||
Args:
|
||||
sample_path: Path to the sample file
|
||||
@@ -66,7 +66,8 @@ def run_sample(
|
||||
python_root: Root directory for uv run
|
||||
|
||||
Returns:
|
||||
Tuple of (success, output, error_info)
|
||||
Tuple of (success, output, error_info, error_type)
|
||||
error_type can be: "timeout", "input_hang", "execution_error", "exception"
|
||||
"""
|
||||
if use_uv and python_root:
|
||||
cmd = ["uv", "run", "python", str(sample_path)]
|
||||
@@ -75,29 +76,69 @@ def run_sample(
|
||||
cmd = [sys.executable, sample_path.name]
|
||||
cwd = sample_path.parent
|
||||
|
||||
# Set environment variables to handle Unicode properly
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8" # Force Python to use UTF-8 for I/O
|
||||
env["PYTHONUTF8"] = "1" # Enable UTF-8 mode in Python 3.7+
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60, # 60 second timeout
|
||||
# Use Popen for better timeout handling with stdin for samples that may wait for input
|
||||
# Popen gives us more control over process lifecycle compared to subprocess.run()
|
||||
process = subprocess.Popen(
|
||||
cmd, # Command to execute as a list [program, arg1, arg2, ...]
|
||||
cwd=cwd, # Working directory for the subprocess
|
||||
stdout=subprocess.PIPE, # Capture stdout so we can read the output
|
||||
stderr=subprocess.PIPE, # Capture stderr so we can read error messages
|
||||
stdin=subprocess.PIPE, # Create a pipe for stdin so we can send input
|
||||
text=True, # Handle input/output as text strings (not bytes)
|
||||
encoding="utf-8", # Use UTF-8 encoding to handle Unicode characters like emojis
|
||||
errors="replace", # Replace problematic characters instead of failing
|
||||
env=env, # Pass environment variables for proper Unicode handling
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.strip() if result.stdout.strip() else "No output"
|
||||
return True, output, ""
|
||||
try:
|
||||
# communicate() sends input to stdin and waits for process to complete
|
||||
# input="" sends an empty string to stdin, which causes input() calls to
|
||||
# immediately receive EOFError (End Of File) since there's no data to read.
|
||||
# This prevents the process from hanging indefinitely waiting for user input.
|
||||
stdout, stderr = process.communicate(input="", timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
# If the process doesn't complete within the timeout period, we need to
|
||||
# forcibly terminate it. This is especially important for processes that
|
||||
# ignore EOFError and continue to hang on input() calls.
|
||||
|
||||
error_info = f"Exit code: {result.returncode}"
|
||||
if result.stderr.strip():
|
||||
error_info += f"\nSTDERR: {result.stderr}"
|
||||
# First attempt: Send SIGKILL (immediate termination) on Unix or TerminateProcess on Windows
|
||||
process.kill()
|
||||
try:
|
||||
# Give the process a few seconds to clean up after being killed
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# If the process is still alive after kill(), use terminate() as a last resort
|
||||
# terminate() sends SIGTERM (graceful termination request) which may work
|
||||
# when kill() doesn't on some systems
|
||||
process.terminate()
|
||||
stdout, stderr = "", "Process forcibly terminated"
|
||||
return False, "", f"TIMEOUT: {sample_path.name} (exceeded 60 seconds)", "timeout"
|
||||
|
||||
return False, result.stdout.strip() if result.stdout.strip() else "", error_info
|
||||
if process.returncode == 0:
|
||||
output = stdout.strip() if stdout.strip() else "No output"
|
||||
return True, output, "", "success"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "", f"TIMEOUT: {sample_path.name} (exceeded 60 seconds)"
|
||||
error_info = f"Exit code: {process.returncode}"
|
||||
if stderr.strip():
|
||||
error_info += f"\nSTDERR: {stderr}"
|
||||
|
||||
# Check if this looks like an input/interaction related error
|
||||
error_type = "execution_error"
|
||||
stderr_safe = stderr.encode("utf-8", errors="replace").decode("utf-8") if stderr else ""
|
||||
if "EOFError" in stderr_safe or "input" in stderr_safe.lower() or "stdin" in stderr_safe.lower():
|
||||
error_type = "input_hang"
|
||||
elif "UnicodeEncodeError" in stderr_safe and ("charmap" in stderr_safe or "codec can't encode" in stderr_safe):
|
||||
error_type = "input_hang" # Unicode errors often indicate interactive samples with emojis
|
||||
|
||||
return False, stdout.strip() if stdout.strip() else "", error_info, error_type
|
||||
except Exception as e:
|
||||
return False, "", f"ERROR: {sample_path.name} - Exception: {str(e)}"
|
||||
return False, "", f"ERROR: {sample_path.name} - Exception: {str(e)}", "exception"
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
@@ -161,7 +202,7 @@ def main() -> None:
|
||||
print(f"Found {len(sample_files)} Python sample files")
|
||||
|
||||
# Run samples concurrently
|
||||
results: list[tuple[Path, bool, str, str]] = []
|
||||
results: list[tuple[Path, bool, str, str, str]] = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.max_workers) as executor:
|
||||
# Submit all tasks
|
||||
@@ -174,53 +215,81 @@ def main() -> None:
|
||||
for future in as_completed(future_to_sample):
|
||||
sample_path = future_to_sample[future]
|
||||
try:
|
||||
success, output, error_info = future.result()
|
||||
results.append((sample_path, success, output, error_info))
|
||||
success, output, error_info, error_type = future.result()
|
||||
results.append((sample_path, success, output, error_info, error_type))
|
||||
|
||||
# Print progress - show relative path from samples directory
|
||||
relative_path = sample_path.relative_to(samples_dir)
|
||||
if success:
|
||||
print(f"✅ {relative_path}")
|
||||
else:
|
||||
print(f"❌ {relative_path} - {error_info.split(':', 1)[0]}")
|
||||
# Show error type in progress display
|
||||
error_display = f"{error_type.upper()}" if error_type != "execution_error" else "ERROR"
|
||||
print(f"❌ {relative_path} - {error_display}")
|
||||
|
||||
except Exception as e:
|
||||
error_info = f"Future exception: {str(e)}"
|
||||
results.append((sample_path, False, "", error_info))
|
||||
results.append((sample_path, False, "", error_info, "exception"))
|
||||
relative_path = sample_path.relative_to(samples_dir)
|
||||
print(f"❌ {relative_path} - {error_info}")
|
||||
print(f"❌ {relative_path} - EXCEPTION")
|
||||
|
||||
# Sort results by original file order for consistent reporting
|
||||
sample_to_index = {path: i for i, path in enumerate(sample_files)}
|
||||
results.sort(key=lambda x: sample_to_index[x[0]])
|
||||
|
||||
successful_runs = sum(1 for _, success, _, _ in results if success)
|
||||
successful_runs = sum(1 for _, success, _, _, _ in results if success)
|
||||
failed_runs = len(results) - successful_runs
|
||||
|
||||
# Categorize failures by type
|
||||
timeout_failures = [r for r in results if not r[1] and r[4] == "timeout"]
|
||||
input_hang_failures = [r for r in results if not r[1] and r[4] == "input_hang"]
|
||||
execution_errors = [r for r in results if not r[1] and r[4] == "execution_error"]
|
||||
exceptions = [r for r in results if not r[1] and r[4] == "exception"]
|
||||
|
||||
# Print detailed results
|
||||
print(f"\n{'=' * 80}")
|
||||
print("DETAILED RESULTS:")
|
||||
print(f"{'=' * 80}")
|
||||
|
||||
for sample_path, success, output, error_info in results:
|
||||
for sample_path, success, output, error_info, error_type in results:
|
||||
relative_path = sample_path.relative_to(samples_dir)
|
||||
if success:
|
||||
print(f"✅ {relative_path}")
|
||||
if output and output != "No output":
|
||||
print(f" Output preview: {output[:100]}{'...' if len(output) > 100 else ''}")
|
||||
else:
|
||||
print(f"❌ {relative_path}")
|
||||
# Display error with type indicator
|
||||
if error_type == "timeout":
|
||||
print(f"⏱️ {relative_path} - TIMEOUT (likely waiting for input)")
|
||||
elif error_type == "input_hang":
|
||||
print(f"⌨️ {relative_path} - INPUT ERROR (interactive sample)")
|
||||
elif error_type == "exception":
|
||||
print(f"💥 {relative_path} - EXCEPTION")
|
||||
else:
|
||||
print(f"❌ {relative_path} - EXECUTION ERROR")
|
||||
print(f" Error: {error_info}")
|
||||
|
||||
# Print summary
|
||||
# Print categorized summary
|
||||
print(f"\n{'=' * 80}")
|
||||
if failed_runs == 0:
|
||||
print("🎉 ALL SAMPLES COMPLETED SUCCESSFULLY!")
|
||||
else:
|
||||
print(f"❌ {failed_runs} SAMPLE(S) FAILED!")
|
||||
|
||||
print(f"Successful runs: {successful_runs}")
|
||||
print(f"Failed runs: {failed_runs}")
|
||||
|
||||
if failed_runs > 0:
|
||||
print("\nFailure breakdown:")
|
||||
if len(timeout_failures) > 0:
|
||||
print(f" ⏱️ Timeouts (likely interactive): {len(timeout_failures)}")
|
||||
if len(input_hang_failures) > 0:
|
||||
print(f" ⌨️ Input errors (interactive): {len(input_hang_failures)}")
|
||||
if len(execution_errors) > 0:
|
||||
print(f" ❌ Execution errors: {len(execution_errors)}")
|
||||
if len(exceptions) > 0:
|
||||
print(f" 💥 Exceptions: {len(exceptions)}")
|
||||
|
||||
if args.subdir:
|
||||
print(f"Subdirectory filter: {args.subdir}")
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
| Azure Chat Agents (Streaming) | [agents/azure_chat_agents_streaming.py](./agents/azure_chat_agents_streaming.py) | Add Azure Chat agents as edges and handle streaming events |
|
||||
| Azure AI Chat Agents (Streaming) | [agents/azure_ai_agents_streaming.py](./agents/azure_ai_agents_streaming.py) | Add Azure AI agents as edges and handle streaming events |
|
||||
| Azure Chat Agents (Function Bridge) | [agents/azure_chat_agents_function_bridge.py](./agents/azure_chat_agents_function_bridge.py) | Chain two agents with a function executor that injects external context |
|
||||
| Azure Chat Agents (Tools + HITL) | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Tool-enabled writer/editor pipeline with human feedback gating via RequestInfoExecutor |
|
||||
| Azure Chat Agents (Tools + HITL) | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Tool-enabled writer/editor pipeline with human feedback gating |
|
||||
| Custom Agent Executors | [agents/custom_agent_executors.py](./agents/custom_agent_executors.py) | Create executors to handle agent run methods |
|
||||
| Sequential Workflow as Agent | [agents/sequential_workflow_as_agent.py](./agents/sequential_workflow_as_agent.py) | Build a sequential workflow orchestrating agents, then expose it as a reusable agent |
|
||||
| Concurrent Workflow as Agent | [agents/concurrent_workflow_as_agent.py](./agents/concurrent_workflow_as_agent.py) | Build a concurrent fan-out/fan-in workflow, then expose it as a reusable agent |
|
||||
@@ -58,7 +58,7 @@ Once comfortable with these, explore the rest of the samples below.
|
||||
| Sample | File | Concepts |
|
||||
|---|---|---|
|
||||
| Sub-Workflow (Basics) | [composition/sub_workflow_basics.py](./composition/sub_workflow_basics.py) | Wrap a workflow as an executor and orchestrate sub-workflows |
|
||||
| Sub-Workflow: Request Interception | [composition/sub_workflow_request_interception.py](./composition/sub_workflow_request_interception.py) | Intercept and forward sub-workflow requests using @handler for RequestInfoMessage subclasses |
|
||||
| Sub-Workflow: Request Interception | [composition/sub_workflow_request_interception.py](./composition/sub_workflow_request_interception.py) | Intercept and forward sub-workflow requests using @handler for SubWorkflowRequestMessage |
|
||||
| Sub-Workflow: Parallel Requests | [composition/sub_workflow_parallel_requests.py](./composition/sub_workflow_parallel_requests.py) | Multiple specialized interceptors handling different request types from same sub-workflow |
|
||||
|
||||
### control-flow
|
||||
|
||||
+112
-73
@@ -8,32 +8,32 @@ from typing import Annotated
|
||||
from agent_framework import (
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
AgentRunResponse,
|
||||
AgentRunUpdateEvent,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Role,
|
||||
ToolMode,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from pydantic import Field
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
Sample: Tool-enabled agents with human feedback
|
||||
|
||||
Pipeline layout:
|
||||
writer_agent (uses Azure OpenAI tools) -> DraftFeedbackCoordinator -> RequestInfoExecutor
|
||||
-> DraftFeedbackCoordinator -> final_editor_agent
|
||||
writer_agent (uses Azure OpenAI tools) -> Coordinator -> writer_agent
|
||||
-> Coordinator -> final_editor_agent -> Coordinator -> output
|
||||
|
||||
The writer agent calls tools to gather product facts before drafting copy. A custom executor
|
||||
packages the draft and emits a RequestInfoEvent so a human can comment, then replays the human
|
||||
@@ -41,7 +41,7 @@ guidance back into the conversation before the final editor agent produces the p
|
||||
|
||||
Demonstrates:
|
||||
- Attaching Python function tools to an agent inside a workflow.
|
||||
- Capturing the writer's output and routing it through RequestInfoExecutor for human review.
|
||||
- Capturing the writer's output for human review.
|
||||
- Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses.
|
||||
|
||||
Prerequisites:
|
||||
@@ -82,27 +82,37 @@ def get_brand_voice_profile(
|
||||
|
||||
|
||||
@dataclass
|
||||
class DraftFeedbackRequest(RequestInfoMessage):
|
||||
"""Payload sent to RequestInfoExecutor for human review."""
|
||||
class DraftFeedbackRequest:
|
||||
"""Payload sent for human review."""
|
||||
|
||||
prompt: str = ""
|
||||
draft_text: str = ""
|
||||
conversation: list[ChatMessage] = field(default_factory=list) # type: ignore[reportUnknownVariableType]
|
||||
|
||||
|
||||
class DraftFeedbackCoordinator(Executor):
|
||||
class Coordinator(Executor):
|
||||
"""Bridge between the writer agent, human feedback, and final editor."""
|
||||
|
||||
def __init__(self, *, id: str = "draft_feedback_coordinator") -> None:
|
||||
def __init__(self, id: str, writer_id: str, final_editor_id: str) -> None:
|
||||
super().__init__(id)
|
||||
self.writer_id = writer_id
|
||||
self.final_editor_id = final_editor_id
|
||||
|
||||
@handler
|
||||
async def on_writer_response(
|
||||
self,
|
||||
draft: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[DraftFeedbackRequest],
|
||||
ctx: WorkflowContext[Never, AgentRunResponse],
|
||||
) -> None:
|
||||
# Preserve the full conversation so the final editor can see tool traces and the initial prompt.
|
||||
"""Handle responses from the other two agents in the workflow."""
|
||||
if draft.executor_id == self.final_editor_id:
|
||||
# Final editor response; yield output directly.
|
||||
await ctx.yield_output(draft.agent_run_response)
|
||||
return
|
||||
|
||||
# Writer agent response; request human feedback.
|
||||
# Preserve the full conversation so the final editor
|
||||
# can see tool traces and the initial prompt.
|
||||
conversation: list[ChatMessage]
|
||||
if draft.full_conversation is not None:
|
||||
conversation = list(draft.full_conversation)
|
||||
@@ -117,18 +127,34 @@ class DraftFeedbackCoordinator(Executor):
|
||||
"(tone tweaks, must-have detail, target audience, etc.). "
|
||||
"Keep it under 30 words."
|
||||
)
|
||||
await ctx.send_message(DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation))
|
||||
await ctx.request_info(
|
||||
DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation),
|
||||
DraftFeedbackRequest,
|
||||
str,
|
||||
)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[DraftFeedbackRequest, str],
|
||||
original_request: DraftFeedbackRequest,
|
||||
feedback: str,
|
||||
ctx: WorkflowContext[AgentExecutorRequest],
|
||||
) -> None:
|
||||
note = (feedback.data or "").strip()
|
||||
request = feedback.original_request
|
||||
note = feedback.strip()
|
||||
if note.lower() == "approve":
|
||||
# Human approved the draft as-is; forward it unchanged.
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(
|
||||
messages=original_request.conversation
|
||||
+ [ChatMessage(Role.USER, text="The draft is approved as-is.")],
|
||||
should_respond=True,
|
||||
),
|
||||
target_id=self.final_editor_id,
|
||||
)
|
||||
return
|
||||
|
||||
conversation: list[ChatMessage] = list(request.conversation)
|
||||
# Human provided feedback; prompt the writer to revise.
|
||||
conversation: list[ChatMessage] = list(original_request.conversation)
|
||||
instruction = (
|
||||
"A human reviewer shared the following guidance:\n"
|
||||
f"{note or 'No specific guidance provided.'}\n\n"
|
||||
@@ -136,11 +162,57 @@ class DraftFeedbackCoordinator(Executor):
|
||||
"Keep the response under 120 words and reflect any requested tone adjustments."
|
||||
)
|
||||
conversation.append(ChatMessage(Role.USER, text=instruction))
|
||||
await ctx.send_message(AgentExecutorRequest(messages=conversation, should_respond=True))
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=conversation, should_respond=True), target_id=self.writer_id
|
||||
)
|
||||
|
||||
|
||||
def display_agent_run_update(event: AgentRunUpdateEvent, last_executor: str | None) -> None:
|
||||
"""Display an AgentRunUpdateEvent in a readable format."""
|
||||
printed_tool_calls: set[str] = set()
|
||||
printed_tool_results: set[str] = set()
|
||||
executor_id = event.executor_id
|
||||
update = event.data
|
||||
# Extract and print any new tool calls or results from the update.
|
||||
function_calls = [c for c in update.contents if isinstance(c, FunctionCallContent)] # type: ignore[union-attr]
|
||||
function_results = [c for c in update.contents if isinstance(c, FunctionResultContent)] # type: ignore[union-attr]
|
||||
if executor_id != last_executor:
|
||||
if last_executor is not None:
|
||||
print()
|
||||
print(f"{executor_id}:", end=" ", flush=True)
|
||||
last_executor = executor_id
|
||||
# Print any new tool calls before the text update.
|
||||
for call in function_calls:
|
||||
if call.call_id in printed_tool_calls:
|
||||
continue
|
||||
printed_tool_calls.add(call.call_id)
|
||||
args = call.arguments
|
||||
args_preview = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else (args or "").strip()
|
||||
print(
|
||||
f"\n{executor_id} [tool-call] {call.name}({args_preview})",
|
||||
flush=True,
|
||||
)
|
||||
print(f"{executor_id}:", end=" ", flush=True)
|
||||
# Print any new tool results before the text update.
|
||||
for result in function_results:
|
||||
if result.call_id in printed_tool_results:
|
||||
continue
|
||||
printed_tool_results.add(result.call_id)
|
||||
result_text = result.result
|
||||
if not isinstance(result_text, str):
|
||||
result_text = json.dumps(result_text, ensure_ascii=False)
|
||||
print(
|
||||
f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
|
||||
flush=True,
|
||||
)
|
||||
print(f"{executor_id}:", end=" ", flush=True)
|
||||
# Finally, print the text update.
|
||||
print(update, end="", flush=True)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the workflow and bridge human feedback between two agents."""
|
||||
# Create agents with tools and instructions.
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
|
||||
writer_agent = chat_client.create_agent(
|
||||
@@ -157,33 +229,39 @@ async def main() -> None:
|
||||
final_editor_agent = chat_client.create_agent(
|
||||
name="final_editor_agent",
|
||||
instructions=(
|
||||
"You are an editor who polishes marketing copy using human guidance. "
|
||||
"Respect factual details from the prior messages while applying the feedback."
|
||||
"You are an editor who polishes marketing copy after human approval. "
|
||||
"Correct any legal or factual issues. Return the final version even if no changes are made. "
|
||||
),
|
||||
)
|
||||
|
||||
feedback_coordinator = DraftFeedbackCoordinator()
|
||||
request_info_executor = RequestInfoExecutor(id="human_feedback")
|
||||
coordinator = Coordinator(
|
||||
id="coordinator",
|
||||
writer_id="writer_agent",
|
||||
final_editor_id="final_editor_agent",
|
||||
)
|
||||
|
||||
# Build the workflow.
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(writer_agent)
|
||||
.add_edge(writer_agent, feedback_coordinator)
|
||||
.add_edge(feedback_coordinator, request_info_executor)
|
||||
.add_edge(request_info_executor, feedback_coordinator)
|
||||
.add_edge(feedback_coordinator, final_editor_agent)
|
||||
.add_edge(writer_agent, coordinator)
|
||||
.add_edge(coordinator, writer_agent)
|
||||
.add_edge(final_editor_agent, coordinator)
|
||||
.add_edge(coordinator, final_editor_agent)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Switch to turn on agent run update display.
|
||||
# By default this is off to reduce clutter during human input.
|
||||
display_agent_run_update_switch = False
|
||||
|
||||
print(
|
||||
"Interactive mode. When prompted, provide a short feedback note for the editor (type 'exit' to quit).",
|
||||
"Interactive mode. When prompted, provide a short feedback note for the editor.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed = False
|
||||
printed_tool_calls: set[str] = set()
|
||||
printed_tool_results: set[str] = set()
|
||||
|
||||
while not completed:
|
||||
last_executor: str | None = None
|
||||
@@ -198,48 +276,9 @@ async def main() -> None:
|
||||
requests: list[tuple[str, DraftFeedbackRequest]] = []
|
||||
|
||||
async for event in stream:
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
executor_id = event.executor_id
|
||||
update = event.data
|
||||
# Extract and print any new tool calls or results from the update.
|
||||
function_calls = [c for c in update.contents if isinstance(c, FunctionCallContent)] # type: ignore[union-attr]
|
||||
function_results = [c for c in update.contents if isinstance(c, FunctionResultContent)] # type: ignore[union-attr]
|
||||
if executor_id != last_executor:
|
||||
if last_executor is not None:
|
||||
print()
|
||||
print(f"{executor_id}:", end=" ", flush=True)
|
||||
last_executor = executor_id
|
||||
# Print any new tool calls before the text update.
|
||||
for call in function_calls:
|
||||
if call.call_id in printed_tool_calls:
|
||||
continue
|
||||
printed_tool_calls.add(call.call_id)
|
||||
args = call.arguments
|
||||
if isinstance(args, dict):
|
||||
args_preview = json.dumps(args, ensure_ascii=False)
|
||||
else:
|
||||
args_preview = (args or "").strip()
|
||||
print(
|
||||
f"\n{executor_id} [tool-call] {call.name}({args_preview})",
|
||||
flush=True,
|
||||
)
|
||||
print(f"{executor_id}:", end=" ", flush=True)
|
||||
# Print any new tool results before the text update.
|
||||
for result in function_results:
|
||||
if result.call_id in printed_tool_results:
|
||||
continue
|
||||
printed_tool_results.add(result.call_id)
|
||||
result_text = result.result
|
||||
if not isinstance(result_text, str):
|
||||
result_text = json.dumps(result_text, ensure_ascii=False)
|
||||
print(
|
||||
f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
|
||||
flush=True,
|
||||
)
|
||||
print(f"{executor_id}:", end=" ", flush=True)
|
||||
# Finally, print the text update.
|
||||
print(update, end="", flush=True)
|
||||
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
|
||||
if isinstance(event, AgentRunUpdateEvent) and display_agent_run_update_switch:
|
||||
display_agent_run_update(event, last_executor)
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, DraftFeedbackRequest):
|
||||
# Stash the request so we can prompt the human after the stream completes.
|
||||
requests.append((event.request_id, event.data))
|
||||
last_executor = None
|
||||
@@ -256,7 +295,7 @@ async def main() -> None:
|
||||
for request_id, request in requests:
|
||||
print("\n----- Writer draft -----")
|
||||
print(request.draft_text.strip())
|
||||
print("\nProvide guidance for the editor (or press Enter to accept the draft).")
|
||||
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
|
||||
answer = input("Human feedback: ").strip() # noqa: ASYNC250
|
||||
if answer.lower() == "exit":
|
||||
print("Exiting...")
|
||||
|
||||
+23
-29
@@ -7,6 +7,9 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# Ensure local getting_started package can be imported when running as a script.
|
||||
_SAMPLES_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_SAMPLES_ROOT) not in sys.path:
|
||||
@@ -17,16 +20,13 @@ from agent_framework import ( # noqa: E402
|
||||
Executor,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Role,
|
||||
WorkflowAgent,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient # noqa: E402
|
||||
from getting_started.workflows.agents.workflow_as_agent_reflection_pattern import ( # noqa: E402
|
||||
ReviewRequest,
|
||||
ReviewResponse,
|
||||
@@ -40,20 +40,20 @@ Purpose:
|
||||
This sample demonstrates how to build a workflow agent that escalates uncertain
|
||||
decisions to a human manager. A Worker generates results, while a Reviewer
|
||||
evaluates them. When the Reviewer is not confident, it escalates the decision
|
||||
to a human via RequestInfoExecutor, receives the human response, and then
|
||||
forwards that response back to the Worker. The workflow completes when idle.
|
||||
to a human, receives the human response, and then forwards that response back
|
||||
to the Worker. The workflow completes when idle.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI account configured and accessible for OpenAIChatClient.
|
||||
- Familiarity with WorkflowBuilder, Executor, and WorkflowContext from agent_framework.
|
||||
- Understanding of request-response message handling (RequestInfoMessage, RequestResponse).
|
||||
- Understanding of request-response message handling in executors.
|
||||
- (Optional) Review of reflection and escalation patterns, such as those in
|
||||
workflow_as_agent_reflection.py.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanReviewRequest(RequestInfoMessage):
|
||||
class HumanReviewRequest:
|
||||
"""A request message type for escalation to a human reviewer."""
|
||||
|
||||
agent_request: ReviewRequest | None = None
|
||||
@@ -62,14 +62,13 @@ class HumanReviewRequest(RequestInfoMessage):
|
||||
class ReviewerWithHumanInTheLoop(Executor):
|
||||
"""Executor that always escalates reviews to a human manager."""
|
||||
|
||||
def __init__(self, worker_id: str, request_info_id: str, reviewer_id: str | None = None) -> None:
|
||||
def __init__(self, worker_id: str, reviewer_id: str | None = None) -> None:
|
||||
unique_id = reviewer_id or f"{worker_id}-reviewer"
|
||||
super().__init__(id=unique_id)
|
||||
self._worker_id = worker_id
|
||||
self._request_info_id = request_info_id
|
||||
|
||||
@handler
|
||||
async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse | HumanReviewRequest]) -> None:
|
||||
async def review(self, request: ReviewRequest, ctx: WorkflowContext) -> None:
|
||||
# In this simplified example, we always escalate to a human manager.
|
||||
# See workflow_as_agent_reflection.py for an implementation
|
||||
# using an automated agent to make the review decision.
|
||||
@@ -77,23 +76,21 @@ class ReviewerWithHumanInTheLoop(Executor):
|
||||
print("Reviewer: Escalating to human manager...")
|
||||
|
||||
# Forward the request to a human manager by sending a HumanReviewRequest.
|
||||
await ctx.send_message(
|
||||
HumanReviewRequest(agent_request=request),
|
||||
target_id=self._request_info_id,
|
||||
)
|
||||
await ctx.request_info(HumanReviewRequest(agent_request=request), HumanReviewRequest, ReviewResponse)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def accept_human_review(
|
||||
self, response: RequestResponse[HumanReviewRequest, ReviewResponse], ctx: WorkflowContext[ReviewResponse]
|
||||
self,
|
||||
original_request: ReviewRequest,
|
||||
response: ReviewResponse,
|
||||
ctx: WorkflowContext[ReviewResponse],
|
||||
) -> None:
|
||||
# Accept the human review response and forward it back to the Worker.
|
||||
human_response = response.data
|
||||
assert isinstance(human_response, ReviewResponse)
|
||||
print(f"Reviewer: Accepting human review for request {human_response.request_id[:8]}...")
|
||||
print(f"Reviewer: Human feedback: {human_response.feedback}")
|
||||
print(f"Reviewer: Human approved: {human_response.approved}")
|
||||
print(f"Reviewer: Accepting human review for request {response.request_id[:8]}...")
|
||||
print(f"Reviewer: Human feedback: {response.feedback}")
|
||||
print(f"Reviewer: Human approved: {response.approved}")
|
||||
print("Reviewer: Forwarding human review back to worker...")
|
||||
await ctx.send_message(human_response, target_id=self._worker_id)
|
||||
await ctx.send_message(response, target_id=self._worker_id)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -102,20 +99,17 @@ async def main() -> None:
|
||||
|
||||
# Create executors for the workflow.
|
||||
print("Creating chat client and executors...")
|
||||
mini_chat_client = OpenAIChatClient(model_id="gpt-4.1-nano")
|
||||
mini_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
worker = Worker(id="sub-worker", chat_client=mini_chat_client)
|
||||
request_info_executor = RequestInfoExecutor(id="request_info")
|
||||
reviewer = ReviewerWithHumanInTheLoop(worker_id=worker.id, request_info_id=request_info_executor.id)
|
||||
reviewer = ReviewerWithHumanInTheLoop(worker_id=worker.id)
|
||||
|
||||
print("Building workflow with Worker ↔ Reviewer cycle...")
|
||||
print("Building workflow with Worker-Reviewer cycle...")
|
||||
# Build a workflow with bidirectional communication between Worker and Reviewer,
|
||||
# and escalation paths for human review.
|
||||
agent = (
|
||||
WorkflowBuilder()
|
||||
.add_edge(worker, reviewer) # Worker sends requests to Reviewer
|
||||
.add_edge(reviewer, worker) # Reviewer sends feedback to Worker
|
||||
.add_edge(reviewer, request_info_executor) # Reviewer requests human input
|
||||
.add_edge(request_info_executor, reviewer) # Human input forwarded back to Reviewer
|
||||
.set_start_executor(worker)
|
||||
.build()
|
||||
.as_agent() # Convert workflow into an agent interface
|
||||
|
||||
+95
-234
@@ -1,11 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
# NOTE: the Azure client imports above are real dependencies. When running this
|
||||
# sample outside of Azure-enabled environments you may wish to swap in the
|
||||
# `agent_framework.builtin` chat client or mock the writer executor. We keep the
|
||||
# concrete import here so readers can see an end-to-end configuration.
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
@@ -14,30 +16,20 @@ from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
Role,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
get_checkpoint_summary,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
# NOTE: the Azure client imports above are real dependencies. When running this
|
||||
# sample outside of Azure-enabled environments you may wish to swap in the
|
||||
# `agent_framework.builtin` chat client or mock the writer executor. We keep the
|
||||
# concrete import here so readers can see an end-to-end configuration.
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Workflow
|
||||
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
|
||||
|
||||
"""
|
||||
Sample: Checkpoint + human-in-the-loop quickstart.
|
||||
|
||||
@@ -45,17 +37,14 @@ This getting-started sample keeps the moving pieces to a minimum:
|
||||
|
||||
1. A brief is turned into a consistent prompt for an AI copywriter.
|
||||
2. The copywriter (an `AgentExecutor`) drafts release notes.
|
||||
3. A reviewer gateway routes every draft through `RequestInfoExecutor` so a human
|
||||
can approve or request tweaks.
|
||||
3. A reviewer gateway sends a request for approval for every draft.
|
||||
4. The workflow records checkpoints between each superstep so you can stop the
|
||||
program, restart later, and optionally pre-supply human answers on resume.
|
||||
|
||||
Key concepts demonstrated
|
||||
-------------------------
|
||||
- Minimal executor pipeline with checkpoint persistence.
|
||||
- Human-in-the-loop pause/resume by pairing `RequestInfoExecutor` with
|
||||
checkpoint restoration.
|
||||
- Supplying responses at restore time (`run_stream_from_checkpoint(..., responses=...)`).
|
||||
- Human-in-the-loop pause/resume with checkpoint restoration.
|
||||
|
||||
Typical pause/resume flow
|
||||
-------------------------
|
||||
@@ -110,8 +99,8 @@ class BriefPreparer(Executor):
|
||||
|
||||
|
||||
@dataclass
|
||||
class HumanApprovalRequest(RequestInfoMessage):
|
||||
"""Message sent to the human reviewer via RequestInfoExecutor."""
|
||||
class HumanApprovalRequest:
|
||||
"""Request sent to the human reviewer."""
|
||||
|
||||
# These fields are intentionally simple because they are serialised into
|
||||
# checkpoints. Keeping them primitive types guarantees the new
|
||||
@@ -124,52 +113,42 @@ class HumanApprovalRequest(RequestInfoMessage):
|
||||
class ReviewGateway(Executor):
|
||||
"""Routes agent drafts to humans and optionally back for revisions."""
|
||||
|
||||
def __init__(self, id: str, reviewer_id: str, writer_id: str, finalize_id: str) -> None:
|
||||
def __init__(self, id: str, writer_id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
self._reviewer_id = reviewer_id
|
||||
self._writer_id = writer_id
|
||||
self._finalize_id = finalize_id
|
||||
|
||||
@handler
|
||||
async def on_agent_response(
|
||||
self,
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[HumanApprovalRequest, str],
|
||||
) -> None:
|
||||
# Capture the agent output so we can surface it to the reviewer and
|
||||
# persist iterations. The `RequestInfoExecutor` relies on this state to
|
||||
# rehydrate when checkpoints are restored.
|
||||
async def on_agent_response(self, response: AgentExecutorResponse, ctx: WorkflowContext) -> None:
|
||||
# Capture the agent output so we can surface it to the reviewer and persist iterations.
|
||||
draft = response.agent_run_response.text or ""
|
||||
iteration = int((await ctx.get_executor_state() or {}).get("iteration", 0)) + 1
|
||||
await ctx.set_executor_state({"iteration": iteration, "last_draft": draft})
|
||||
# Emit a human approval request. Because this flows through
|
||||
# RequestInfoExecutor it will pause the workflow until an answer is
|
||||
# supplied either interactively or via pre-supplied responses.
|
||||
await ctx.send_message(
|
||||
# Emit a human approval request.
|
||||
await ctx.request_info(
|
||||
HumanApprovalRequest(
|
||||
prompt="Review the draft. Reply 'approve' or provide edit instructions.",
|
||||
draft=draft,
|
||||
iteration=iteration,
|
||||
),
|
||||
target_id=self._reviewer_id,
|
||||
HumanApprovalRequest,
|
||||
str,
|
||||
)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanApprovalRequest, str],
|
||||
original_request: HumanApprovalRequest,
|
||||
feedback: str,
|
||||
ctx: WorkflowContext[AgentExecutorRequest | str, str],
|
||||
) -> None:
|
||||
# The RequestResponse wrapper gives us both the human data and the
|
||||
# original request message, even when resuming from checkpoints.
|
||||
reply = (feedback.data or "").strip()
|
||||
# The `original_request` is the request we sent earlier that is now being answered.
|
||||
reply = feedback.strip()
|
||||
state = await ctx.get_executor_state() or {}
|
||||
draft = state.get("last_draft") or (feedback.original_request.draft if feedback.original_request else "")
|
||||
draft = state.get("last_draft") or (original_request.draft or "")
|
||||
|
||||
if reply.lower() == "approve":
|
||||
# When the human signs off we can short-circuit the workflow and
|
||||
# send the approved draft to the final executor.
|
||||
await ctx.send_message(draft, target_id=self._finalize_id)
|
||||
# Workflow is completed when the human approves.
|
||||
await ctx.yield_output(draft)
|
||||
return
|
||||
|
||||
# Any other response loops us back to the writer with fresh guidance.
|
||||
@@ -187,63 +166,34 @@ class ReviewGateway(Executor):
|
||||
)
|
||||
|
||||
|
||||
class FinaliseExecutor(Executor):
|
||||
"""Publishes the approved text."""
|
||||
|
||||
@handler
|
||||
async def publish(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
# Store the output so diagnostics or a UI could fetch the final copy.
|
||||
await ctx.set_executor_state({"published_text": text})
|
||||
# Yield the final output so the workflow completes cleanly.
|
||||
await ctx.yield_output(text)
|
||||
|
||||
|
||||
def create_workflow(*, checkpoint_storage: FileCheckpointStorage | None = None) -> "Workflow":
|
||||
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
|
||||
"""Assemble the workflow graph used by both the initial run and resume."""
|
||||
|
||||
# The Azure client is created once so our agent executor can issue calls to
|
||||
# the hosted model. The agent id is stable across runs which keeps
|
||||
# checkpoints deterministic.
|
||||
# The Azure client is created once so our agent executor can issue calls to the hosted
|
||||
# model. The agent id is stable across runs which keeps checkpoints deterministic.
|
||||
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
|
||||
writer = AgentExecutor(
|
||||
chat_client.create_agent(
|
||||
instructions="Write concise, warm release notes that sound human and helpful.",
|
||||
),
|
||||
id="writer",
|
||||
)
|
||||
# RequestInfoExecutor is the lynchpin for human-in-the-loop: every draft is
|
||||
# routed through it so checkpoints can pause while waiting for responses.
|
||||
review = RequestInfoExecutor(id="request_info")
|
||||
finalise = FinaliseExecutor(id="finalise")
|
||||
gateway = ReviewGateway(
|
||||
id="review_gateway",
|
||||
reviewer_id=review.id,
|
||||
writer_id=writer.id,
|
||||
finalize_id=finalise.id,
|
||||
)
|
||||
agent = chat_client.create_agent(instructions="Write concise, warm release notes that sound human and helpful.")
|
||||
|
||||
writer = AgentExecutor(agent, id="writer")
|
||||
gateway = ReviewGateway(id="review_gateway", writer_id=writer.id)
|
||||
prepare = BriefPreparer(id="prepare_brief", agent_id=writer.id)
|
||||
|
||||
# Wire the workflow DAG. Edges mirror the numbered steps described in the
|
||||
# module docstring. Because `WorkflowBuilder` is declarative, reading these
|
||||
# edges is often the quickest way to understand execution order.
|
||||
builder = (
|
||||
workflow_builder = (
|
||||
WorkflowBuilder(max_iterations=6)
|
||||
.set_start_executor(prepare)
|
||||
.add_edge(prepare, writer)
|
||||
.add_edge(writer, gateway)
|
||||
.add_edge(gateway, review)
|
||||
.add_edge(review, gateway) # human resumes loop
|
||||
.add_edge(gateway, writer) # revisions
|
||||
.add_edge(gateway, finalise)
|
||||
.add_edge(gateway, writer) # revisions loop
|
||||
.with_checkpointing(checkpoint_storage=checkpoint_storage)
|
||||
)
|
||||
# Opt-in to persistence when the caller provides storage. The workflow
|
||||
# object itself is identical whether or not checkpointing is enabled.
|
||||
if checkpoint_storage:
|
||||
builder = builder.with_checkpointing(checkpoint_storage=checkpoint_storage)
|
||||
return builder.build()
|
||||
|
||||
return workflow_builder.build()
|
||||
|
||||
|
||||
def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
def render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
"""Pretty-print saved checkpoints with the new framework summaries."""
|
||||
|
||||
print("\nCheckpoint summary:")
|
||||
@@ -251,166 +201,83 @@ def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
# Compose a single line per checkpoint so the user can scan the output
|
||||
# and pick the resume point that still has outstanding human work.
|
||||
line = (
|
||||
f"- {summary.checkpoint_id} | iter={summary.iteration_count} "
|
||||
f"- {summary.checkpoint_id} | timestamp={summary.timestamp} | iter={summary.iteration_count} "
|
||||
f"| targets={summary.targets} | states={summary.executor_ids}"
|
||||
)
|
||||
if summary.status:
|
||||
line += f" | status={summary.status}"
|
||||
if summary.draft_preview:
|
||||
line += f" | draft_preview={summary.draft_preview}"
|
||||
if summary.pending_requests:
|
||||
line += f" | pending_request_id={summary.pending_requests[0].request_id}"
|
||||
if summary.pending_request_info_events:
|
||||
line += f" | pending_request_id={summary.pending_request_info_events[0].request_id}"
|
||||
print(line)
|
||||
|
||||
|
||||
def _print_events(events: list[Any]) -> tuple[str | None, list[tuple[str, HumanApprovalRequest]]]:
|
||||
"""Echo workflow events to the console and collect outstanding requests."""
|
||||
|
||||
completed_output: str | None = None
|
||||
requests: list[tuple[str, HumanApprovalRequest]] = []
|
||||
|
||||
for event in events:
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
completed_output = event.data
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
|
||||
# Capture pending human approvals so the caller can ask the user for
|
||||
# input after the current batch of events is processed.
|
||||
requests.append((event.request_id, event.data))
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state in {
|
||||
WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
}:
|
||||
print(f"Workflow state: {event.state.name}")
|
||||
|
||||
return completed_output, requests
|
||||
|
||||
|
||||
def _prompt_for_responses(requests: list[tuple[str, HumanApprovalRequest]]) -> dict[str, str] | None:
|
||||
def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]:
|
||||
"""Interactive CLI prompt for any live RequestInfo requests."""
|
||||
|
||||
if not requests:
|
||||
return None
|
||||
answers: dict[str, str] = {}
|
||||
for request_id, request in requests:
|
||||
# Keep the prompt conversational so testers can use the script without
|
||||
# memorising the workflow APIs.
|
||||
responses: dict[str, str] = {}
|
||||
for request_id, request in requests.items():
|
||||
print("\n=== Human approval needed ===")
|
||||
print(f"request_id: {request_id}")
|
||||
if request.iteration:
|
||||
print(f"Iteration: {request.iteration}")
|
||||
print(f"Iteration: {request.iteration}")
|
||||
print(request.prompt)
|
||||
print("Draft: \n---\n" + request.draft + "\n---")
|
||||
answer = input("Type 'approve' or enter revision guidance (or 'exit' to quit): ").strip() # noqa: ASYNC250
|
||||
if answer.lower() == "exit":
|
||||
response = input("Type 'approve' or enter revision guidance (or 'exit' to quit): ").strip()
|
||||
if response.lower() == "exit":
|
||||
raise SystemExit("Stopped by user.")
|
||||
answers[request_id] = answer
|
||||
return answers
|
||||
responses[request_id] = response
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
def _maybe_pre_supply_responses(cp: "WorkflowCheckpoint") -> dict[str, str] | None:
|
||||
"""Offer to collect responses before resuming a checkpoint."""
|
||||
|
||||
pending = get_checkpoint_summary(cp).pending_requests
|
||||
if not pending:
|
||||
return None
|
||||
|
||||
print(
|
||||
"This checkpoint still has pending human input. Provide the responses now so the resume step "
|
||||
"applies them immediately and does not re-emit the original RequestInfo event."
|
||||
)
|
||||
choice = input("Pre-supply responses for this checkpoint? [y/N]: ").strip().lower() # noqa: ASYNC250
|
||||
if choice not in {"y", "yes"}:
|
||||
return None
|
||||
|
||||
answers: dict[str, str] = {}
|
||||
for item in pending:
|
||||
iteration = item.iteration or 0
|
||||
print(f"\nPending draft (iteration {iteration} | request_id={item.request_id}):")
|
||||
draft_text = (item.draft or "").strip()
|
||||
if draft_text:
|
||||
# The shortened preview in the summary may truncate text; here we
|
||||
# show the full draft so the reviewer can make an informed choice.
|
||||
print("Draft:\n---\n" + draft_text + "\n---")
|
||||
else:
|
||||
print("Draft: [not captured in checkpoint payload - refer to your notes/log]")
|
||||
prompt_text = (item.prompt or "Review the draft").strip()
|
||||
print(prompt_text)
|
||||
answer = input("Response ('approve' or guidance, 'exit' to abort): ").strip() # noqa: ASYNC250
|
||||
if answer.lower() == "exit":
|
||||
raise SystemExit("Resume aborted by user.")
|
||||
answers[item.request_id] = answer
|
||||
return answers
|
||||
|
||||
|
||||
async def _consume(stream: AsyncIterable[Any]) -> list[Any]:
|
||||
"""Materialise an async event stream into a list."""
|
||||
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> str | None:
|
||||
async def run_interactive_session(
|
||||
workflow: Workflow,
|
||||
initial_message: str | None = None,
|
||||
checkpoint_id: str | None = None,
|
||||
) -> str:
|
||||
"""Run the workflow until it either finishes or pauses for human input."""
|
||||
|
||||
pending_responses: dict[str, str] | None = None
|
||||
requests: dict[str, HumanApprovalRequest] = {}
|
||||
responses: dict[str, str] | None = None
|
||||
completed_output: str | None = None
|
||||
first = True
|
||||
|
||||
while completed_output is None:
|
||||
if first:
|
||||
# Kick off the workflow with the initial brief. The returned events
|
||||
# include RequestInfo events when the agent produces a draft.
|
||||
events = await _consume(workflow.run_stream(initial_message))
|
||||
first = False
|
||||
elif pending_responses:
|
||||
# Feed any answers the user just typed back into the workflow.
|
||||
events = await _consume(workflow.send_responses_streaming(pending_responses))
|
||||
while True:
|
||||
if responses:
|
||||
event_stream = workflow.send_responses_streaming(responses)
|
||||
requests.clear()
|
||||
responses = None
|
||||
else:
|
||||
if initial_message:
|
||||
print(f"\nStarting workflow with brief: {initial_message}\n")
|
||||
event_stream = workflow.run_stream(initial_message)
|
||||
elif checkpoint_id:
|
||||
print("\nStarting workflow from checkpoint...\n")
|
||||
event_stream = workflow.run_stream_from_checkpoint(checkpoint_id)
|
||||
else:
|
||||
raise ValueError("Either initial_message or checkpoint_id must be provided")
|
||||
|
||||
async for event in event_stream:
|
||||
if isinstance(event, WorkflowStatusEvent):
|
||||
print(event)
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
completed_output = event.data
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
if isinstance(event.data, HumanApprovalRequest):
|
||||
requests[event.request_id] = event.data
|
||||
else:
|
||||
raise ValueError("Unexpected request data type")
|
||||
|
||||
if completed_output:
|
||||
break
|
||||
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
if requests:
|
||||
responses = prompt_for_responses(requests)
|
||||
continue
|
||||
|
||||
raise RuntimeError("Workflow stopped without completing or requesting input")
|
||||
|
||||
return completed_output
|
||||
|
||||
|
||||
async def resume_from_checkpoint(
|
||||
workflow: "Workflow",
|
||||
checkpoint_id: str,
|
||||
storage: FileCheckpointStorage,
|
||||
pre_supplied: dict[str, str] | None,
|
||||
) -> None:
|
||||
"""Resume a stored checkpoint and continue until completion or another pause."""
|
||||
|
||||
print(f"\nResuming from checkpoint: {checkpoint_id}")
|
||||
events = await _consume(
|
||||
workflow.run_stream_from_checkpoint(
|
||||
checkpoint_id,
|
||||
checkpoint_storage=storage,
|
||||
responses=pre_supplied,
|
||||
)
|
||||
)
|
||||
completed_output, requests = _print_events(events)
|
||||
if pre_supplied and not requests and completed_output is None:
|
||||
# When the checkpoint only needed the provided answers we let the user
|
||||
# know the workflow is waiting for the next superstep (usually another
|
||||
# agent response).
|
||||
print("Pre-supplied responses applied automatically; workflow is now waiting for the next step.")
|
||||
|
||||
pending = _prompt_for_responses(requests)
|
||||
while completed_output is None and pending:
|
||||
events = await _consume(workflow.send_responses_streaming(pending))
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending = _prompt_for_responses(requests)
|
||||
else:
|
||||
break
|
||||
|
||||
if completed_output:
|
||||
print(f"Workflow completed with: {completed_output}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Entry point used by both the initial run and subsequent resumes."""
|
||||
|
||||
@@ -428,11 +295,8 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
print("Running workflow (human approval required)...")
|
||||
completed = await run_interactive_session(workflow, initial_message=brief)
|
||||
if completed:
|
||||
print(f"Initial run completed with final copy: {completed}")
|
||||
else:
|
||||
print("Initial run paused for human input.")
|
||||
result = await run_interactive_session(workflow, initial_message=brief)
|
||||
print(f"Workflow completed with: {result}")
|
||||
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
if not checkpoints:
|
||||
@@ -441,7 +305,7 @@ async def main() -> None:
|
||||
|
||||
# Show the user what is available before we prompt for the index. The
|
||||
# summary helper keeps this output consistent with other tooling.
|
||||
_render_checkpoint_summary(checkpoints)
|
||||
render_checkpoint_summary(checkpoints)
|
||||
|
||||
sorted_cps = sorted(checkpoints, key=lambda c: c.timestamp)
|
||||
print("\nAvailable checkpoints:")
|
||||
@@ -472,14 +336,11 @@ async def main() -> None:
|
||||
print("Selected checkpoint already reflects a completed workflow; nothing to resume.")
|
||||
return
|
||||
|
||||
# If the user wants, capture their decisions now so the resume call can
|
||||
# push them into the workflow and avoid re-prompting.
|
||||
pre_responses = _maybe_pre_supply_responses(chosen)
|
||||
|
||||
resumed_workflow = create_workflow()
|
||||
new_workflow = create_workflow(checkpoint_storage=storage)
|
||||
# Resume with a fresh workflow instance. The checkpoint carries the
|
||||
# persistent state while this object holds the runtime wiring.
|
||||
await resume_from_checkpoint(resumed_workflow, chosen.checkpoint_id, storage, pre_responses)
|
||||
result = await run_interactive_session(new_workflow, checkpoint_id=chosen.checkpoint_id)
|
||||
print(f"Workflow completed with: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -11,9 +12,8 @@ from agent_framework import (
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
@@ -22,6 +22,7 @@ from agent_framework import (
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_checkpoints"
|
||||
@@ -30,7 +31,7 @@ CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_c
|
||||
Sample: Checkpointing for workflows that embed sub-workflows.
|
||||
|
||||
This sample shows how a parent workflow that wraps a sub-workflow can:
|
||||
- run until the sub-workflow emits a human approval request via RequestInfoExecutor
|
||||
- run until the sub-workflow emits a human approval request
|
||||
- persist a checkpoint that captures the pending request (including complex payloads)
|
||||
- resume later, supplying the human decision directly at restore time
|
||||
|
||||
@@ -78,9 +79,10 @@ class FinalDraft:
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewRequest(RequestInfoMessage):
|
||||
"""Human approval request surfaced via RequestInfoExecutor."""
|
||||
class ReviewRequest:
|
||||
"""Human approval request surfaced via `request_info`."""
|
||||
|
||||
id: str = str(uuid.uuid4())
|
||||
topic: str = ""
|
||||
iteration: int = 1
|
||||
draft_excerpt: str = ""
|
||||
@@ -88,6 +90,14 @@ class ReviewRequest(RequestInfoMessage):
|
||||
reviewer_guidance: list[str] = field(default_factory=list) # type: ignore
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewDecision:
|
||||
"""The review decision to be sent to downstream executors along with the original request."""
|
||||
|
||||
decision: str
|
||||
original_request: ReviewRequest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-workflow executors
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -122,7 +132,8 @@ class DraftReviewRouter(Executor):
|
||||
super().__init__(id="draft_review")
|
||||
|
||||
@handler
|
||||
async def request_review(self, draft: DraftPackage, ctx: WorkflowContext[ReviewRequest]) -> None:
|
||||
async def request_review(self, draft: DraftPackage, ctx: WorkflowContext) -> None:
|
||||
"""Request a review upon receiving a draft."""
|
||||
excerpt = draft.content.splitlines()[0]
|
||||
request = ReviewRequest(
|
||||
topic=draft.topic,
|
||||
@@ -134,15 +145,17 @@ class DraftReviewRouter(Executor):
|
||||
"Confirm CTA is action-oriented",
|
||||
],
|
||||
)
|
||||
await ctx.send_message(request, target_id="sub_review_requests")
|
||||
await ctx.request_info(request, ReviewRequest, str)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def forward_decision(
|
||||
self,
|
||||
decision: RequestResponse[ReviewRequest, str],
|
||||
ctx: WorkflowContext[RequestResponse[ReviewRequest, str]],
|
||||
original_request: ReviewRequest,
|
||||
decision: str,
|
||||
ctx: WorkflowContext[ReviewDecision],
|
||||
) -> None:
|
||||
await ctx.send_message(decision, target_id="draft_finaliser")
|
||||
"""Route the decision to the next executor."""
|
||||
await ctx.send_message(ReviewDecision(decision=decision, original_request=original_request))
|
||||
|
||||
|
||||
class DraftFinaliser(Executor):
|
||||
@@ -154,11 +167,11 @@ class DraftFinaliser(Executor):
|
||||
@handler
|
||||
async def on_review_decision(
|
||||
self,
|
||||
decision: RequestResponse[ReviewRequest, str],
|
||||
review_decision: ReviewDecision,
|
||||
ctx: WorkflowContext[DraftTask, FinalDraft],
|
||||
) -> None:
|
||||
reply = (decision.data or "").strip().lower()
|
||||
original = decision.original_request
|
||||
reply = review_decision.decision.strip().lower()
|
||||
original = review_decision.original_request
|
||||
topic = original.topic if original else "unknown topic"
|
||||
iteration = original.iteration if original else 1
|
||||
|
||||
@@ -192,12 +205,11 @@ class LaunchCoordinator(Executor):
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="launch_coordinator")
|
||||
self._final: FinalDraft | None = None
|
||||
|
||||
@handler
|
||||
async def kick_off(self, topic: str, ctx: WorkflowContext[DraftTask]) -> None:
|
||||
task = DraftTask(topic=topic, due=_utc_now() + timedelta(hours=2))
|
||||
await ctx.send_message(task, target_id="launch_subworkflow")
|
||||
await ctx.send_message(task)
|
||||
|
||||
@handler
|
||||
async def collect_final(self, draft: FinalDraft, ctx: WorkflowContext[None, FinalDraft]) -> None:
|
||||
@@ -209,8 +221,6 @@ class LaunchCoordinator(Executor):
|
||||
normalised = replace(draft, approved_at=parsed)
|
||||
approved_at = parsed
|
||||
|
||||
self._final = normalised
|
||||
|
||||
approved_display = approved_at.isoformat() if hasattr(approved_at, "isoformat") else str(approved_at)
|
||||
|
||||
print("\n>>> Parent workflow received approved draft:")
|
||||
@@ -221,9 +231,50 @@ class LaunchCoordinator(Executor):
|
||||
|
||||
await ctx.yield_output(normalised)
|
||||
|
||||
@property
|
||||
def final_result(self) -> FinalDraft | None:
|
||||
return self._final
|
||||
@handler
|
||||
async def handler_sub_workflow_request(
|
||||
self,
|
||||
request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext,
|
||||
) -> None:
|
||||
"""Handle requests from the sub-workflow.
|
||||
|
||||
Note that the message type must be SubWorkflowRequestMessage to intercept the request.
|
||||
"""
|
||||
if not isinstance(request.source_event.data, ReviewRequest):
|
||||
raise TypeError(f"Expected 'ReviewRequest', got {type(request.source_event.data)}")
|
||||
|
||||
# Record the request to response matching
|
||||
review_request = request.source_event.data
|
||||
executor_state = await ctx.get_executor_state() or {}
|
||||
executor_state[review_request.id] = request
|
||||
await ctx.set_executor_state(executor_state)
|
||||
|
||||
# Send the request without modification
|
||||
await ctx.request_info(review_request, ReviewRequest, str)
|
||||
|
||||
@response_handler
|
||||
async def handle_request_response(
|
||||
self,
|
||||
original_request: ReviewRequest,
|
||||
response: str,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Process the response and send it back to the sub-workflow.
|
||||
|
||||
Note that the response must be sent back using SubWorkflowResponseMessage to route
|
||||
the response back to the sub-workflow.
|
||||
"""
|
||||
executor_state = await ctx.get_executor_state() or {}
|
||||
request_message = executor_state.pop(original_request.id, None)
|
||||
|
||||
# Save the executor state back to the context
|
||||
await ctx.set_executor_state(executor_state)
|
||||
|
||||
if request_message is None:
|
||||
raise ValueError("No matching pending request found for the resource response")
|
||||
|
||||
await ctx.send_message(request_message.create_response(response))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,17 +285,13 @@ class LaunchCoordinator(Executor):
|
||||
def build_sub_workflow() -> WorkflowExecutor:
|
||||
writer = DraftWriter()
|
||||
router = DraftReviewRouter()
|
||||
request_info = RequestInfoExecutor(id="sub_review_requests")
|
||||
finaliser = DraftFinaliser()
|
||||
|
||||
sub_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(writer)
|
||||
.add_edge(writer, router)
|
||||
.add_edge(router, request_info)
|
||||
.add_edge(request_info, router, condition=lambda msg: isinstance(msg, RequestResponse))
|
||||
.add_edge(router, finaliser, condition=lambda msg: isinstance(msg, RequestResponse))
|
||||
.add_edge(request_info, finaliser)
|
||||
.add_edge(router, finaliser)
|
||||
.add_edge(finaliser, writer) # permits revision loops
|
||||
.build()
|
||||
)
|
||||
@@ -252,28 +299,19 @@ def build_sub_workflow() -> WorkflowExecutor:
|
||||
return WorkflowExecutor(sub_workflow, id="launch_subworkflow")
|
||||
|
||||
|
||||
def build_parent_workflow(storage: FileCheckpointStorage) -> tuple[LaunchCoordinator, Workflow]:
|
||||
def build_parent_workflow(storage: FileCheckpointStorage) -> Workflow:
|
||||
coordinator = LaunchCoordinator()
|
||||
sub_executor = build_sub_workflow()
|
||||
parent_request_info = RequestInfoExecutor(id="parent_review_gateway")
|
||||
|
||||
workflow = (
|
||||
return (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(coordinator)
|
||||
.add_edge(coordinator, sub_executor)
|
||||
.add_edge(sub_executor, coordinator, condition=lambda msg: isinstance(msg, FinalDraft))
|
||||
.add_edge(
|
||||
sub_executor,
|
||||
parent_request_info,
|
||||
condition=lambda msg: isinstance(msg, RequestInfoMessage),
|
||||
)
|
||||
.add_edge(parent_request_info, sub_executor)
|
||||
.add_edge(sub_executor, coordinator)
|
||||
.with_checkpointing(storage)
|
||||
.build()
|
||||
)
|
||||
|
||||
return coordinator, workflow
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -282,9 +320,10 @@ async def main() -> None:
|
||||
|
||||
storage = FileCheckpointStorage(CHECKPOINT_DIR)
|
||||
|
||||
_, workflow = build_parent_workflow(storage)
|
||||
workflow = build_parent_workflow(storage)
|
||||
|
||||
print("\n=== Stage 1: run until sub-workflow requests human review ===")
|
||||
|
||||
request_id: str | None = None
|
||||
async for event in workflow.run_stream("Contoso Gadget Launch"):
|
||||
if isinstance(event, RequestInfoEvent) and request_id is None:
|
||||
@@ -294,52 +333,52 @@ async def main() -> None:
|
||||
break
|
||||
|
||||
if request_id is None:
|
||||
print("Sub-workflow completed without requesting review.")
|
||||
return
|
||||
raise RuntimeError("Sub-workflow completed without requesting review.")
|
||||
|
||||
checkpoints = await storage.list_checkpoints(workflow.id)
|
||||
if not checkpoints:
|
||||
print("No checkpoints written.")
|
||||
return
|
||||
raise RuntimeError("No checkpoints found.")
|
||||
|
||||
# Print the checkpoint to show pending requests
|
||||
# We didn't handle the request above so the request is still pending the last checkpoint
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
resume_checkpoint = checkpoints[-1]
|
||||
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
|
||||
|
||||
checkpoint_path = storage.storage_path / f"{resume_checkpoint.checkpoint_id}.json"
|
||||
if checkpoint_path.exists():
|
||||
snapshot = json.loads(checkpoint_path.read_text())
|
||||
exec_states = snapshot.get("executor_states", {})
|
||||
sub_pending = exec_states.get("sub_review_requests", {}).get("request_events", {})
|
||||
parent_pending = exec_states.get("parent_review_gateway", {}).get("request_events", {})
|
||||
print(f"Pending review requests (sub executor snapshot): {list(sub_pending.keys())}")
|
||||
print(f"Pending review requests (parent executor snapshot): {list(parent_pending.keys())}")
|
||||
checkpoint_content_dict = json.loads(checkpoint_path.read_text())
|
||||
print(f"Pending review requests: {checkpoint_content_dict.get('pending_request_info_events', {})}")
|
||||
|
||||
print("\n=== Stage 2: resume from checkpoint ===")
|
||||
|
||||
print("\n=== Stage 2: resume from checkpoint and approve draft ===")
|
||||
# Rebuild fresh instances to mimic a separate process resuming
|
||||
coordinator2, workflow2 = build_parent_workflow(storage)
|
||||
workflow2 = build_parent_workflow(storage)
|
||||
|
||||
approval_response = "approve"
|
||||
final_event: WorkflowOutputEvent | None = None
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in workflow2.run_stream_from_checkpoint(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
responses={request_id: approval_response},
|
||||
):
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
request_info_event = event
|
||||
|
||||
if request_info_event is None:
|
||||
raise RuntimeError("No request_info_event captured.")
|
||||
|
||||
print("\n=== Stage 3: approve draft ==")
|
||||
|
||||
approval_response = "approve"
|
||||
output_event: WorkflowOutputEvent | None = None
|
||||
async for event in workflow2.send_responses_streaming({request_info_event.request_id: approval_response}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
final_event = event
|
||||
output_event = event
|
||||
|
||||
if final_event is None:
|
||||
print("Workflow did not complete after resume.")
|
||||
return
|
||||
if output_event is None:
|
||||
raise RuntimeError("Workflow did not complete after resume.")
|
||||
|
||||
final = final_event.data
|
||||
output = output_event.data
|
||||
print("\n=== Final Draft (from resumed run) ===")
|
||||
print(final)
|
||||
|
||||
if coordinator2.final_result is None:
|
||||
print("Coordinator did not capture final result via handler.")
|
||||
else:
|
||||
print("Coordinator stored final draft successfully.")
|
||||
print(output)
|
||||
|
||||
""""
|
||||
Sample Output:
|
||||
|
||||
+277
-354
@@ -1,87 +1,59 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
RequestInfoEvent,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
Sample: Sub-workflow with parallel request handling by specialized interceptors
|
||||
This sample demonstrates how to handle multiple parallel requests from a sub-workflow to
|
||||
different executors in the main workflow.
|
||||
|
||||
This sample demonstrates how different parent executors can handle different types of requests
|
||||
from the same sub-workflow using regular @handler methods for RequestInfoMessage subclasses.
|
||||
Prerequisite:
|
||||
- Understanding of sub-workflows.
|
||||
- Understanding of requests and responses.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required (external handling simulated via `RequestInfoExecutor`).
|
||||
This pattern is useful when a sub-workflow needs to interact with multiple external systems
|
||||
or services.
|
||||
|
||||
Key architectural principles:
|
||||
1. Specialized interceptors: Each parent executor handles only specific request types
|
||||
2. Type-based routing: ResourceCache handles ResourceRequest, PolicyEngine handles PolicyCheckRequest
|
||||
3. Automatic type filtering: Each interceptor only receives requests with matching types
|
||||
4. Fallback forwarding: Unhandled requests are forwarded to external services
|
||||
This sample implements a resource request distribution system where:
|
||||
1. A sub-workflow generates requests for computing resources and policy checks.
|
||||
2. The main workflow has executors that handle resource allocation and policy checking.
|
||||
3. Responses are routed back to the sub-workflow, which collects and processes them.
|
||||
|
||||
The example simulates a resource allocation system where:
|
||||
- Sub-workflow makes mixed requests for resources (CPU, memory) and policy checks
|
||||
- ResourceCache executor intercepts ResourceRequest messages, serves from cache or forwards
|
||||
- PolicyEngine executor intercepts PolicyCheckRequest messages, applies rules or forwards
|
||||
- Each interceptor uses typed @handler methods for automatic filtering
|
||||
The sub-workflow sends two types of requests:
|
||||
- ResourceRequest: Requests for computing resources (e.g., CPU, memory).
|
||||
- PolicyRequest: Requests to check resource allocation policies.
|
||||
|
||||
Flow visualization:
|
||||
|
||||
Coordinator
|
||||
|
|
||||
| Mixed list[resource + policy requests]
|
||||
v
|
||||
[ Sub-workflow: WorkflowExecutor(ResourceRequester) ]
|
||||
|
|
||||
| Emits different RequestInfoMessage types:
|
||||
| - ResourceRequest
|
||||
| - PolicyCheckRequest
|
||||
v
|
||||
Parent workflow routes to specialized handlers:
|
||||
| |
|
||||
| ResourceCache.handle_resource_request | PolicyEngine.handle_policy_request
|
||||
| (@handler ResourceRequest) | (@handler PolicyCheckRequest)
|
||||
v v
|
||||
Cache hit/miss decision Policy allow/deny decision
|
||||
| |
|
||||
| RequestResponse OR forward | RequestResponse OR forward
|
||||
v v
|
||||
Back to sub-workflow <----------> External RequestInfoExecutor
|
||||
|
|
||||
v
|
||||
External responses route back
|
||||
The main workflow contains:
|
||||
- ResourceAllocator: Simulates a system that allocates computing resources.
|
||||
- PolicyEngine: Simulates a policy engine that approves or denies resource requests.
|
||||
"""
|
||||
|
||||
|
||||
# 1. Define domain-specific request/response types
|
||||
@dataclass
|
||||
class ResourceRequest(RequestInfoMessage):
|
||||
class ComputingResourceRequest:
|
||||
"""Request for computing resources."""
|
||||
|
||||
resource_type: str = "cpu" # cpu, memory, disk, etc.
|
||||
amount: int = 1
|
||||
priority: str = "normal" # low, normal, high
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyCheckRequest(RequestInfoMessage):
|
||||
"""Request to check resource allocation policy."""
|
||||
|
||||
resource_type: str = ""
|
||||
amount: int = 0
|
||||
policy_type: str = "quota" # quota, compliance, security
|
||||
request_type: Literal["resource", "policy"]
|
||||
resource_type: Literal["cpu", "memory", "disk", "gpu"]
|
||||
amount: int
|
||||
priority: Literal["low", "normal", "high"] | None = None
|
||||
policy_type: Literal["quota", "security"] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -102,340 +74,291 @@ class PolicyResponse:
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestFinished:
|
||||
pass
|
||||
class ResourceRequest:
|
||||
"""Request for computing resources."""
|
||||
|
||||
resource_type: Literal["cpu", "memory", "disk", "gpu"]
|
||||
amount: int
|
||||
priority: Literal["low", "normal", "high"]
|
||||
id: str = str(uuid.uuid4())
|
||||
|
||||
|
||||
# 2. Implement the sub-workflow executor - makes resource and policy requests
|
||||
class ResourceRequester(Executor):
|
||||
"""Simple executor that requests resources and checks policies."""
|
||||
@dataclass
|
||||
class PolicyRequest:
|
||||
"""Request to check resource allocation policy."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="resource_requester")
|
||||
self._request_count = 0
|
||||
policy_type: Literal["quota", "security"]
|
||||
resource_type: Literal["cpu", "memory", "disk", "gpu"]
|
||||
amount: int
|
||||
id: str = str(uuid.uuid4())
|
||||
|
||||
|
||||
def build_resource_request_distribution_workflow() -> Workflow:
|
||||
class RequestDistribution(Executor):
|
||||
"""Distributes computing resource requests to appropriate executors."""
|
||||
|
||||
@handler
|
||||
async def distribute_requests(
|
||||
self,
|
||||
requests: list[ComputingResourceRequest],
|
||||
ctx: WorkflowContext[ResourceRequest | PolicyRequest | int],
|
||||
) -> None:
|
||||
for req in requests:
|
||||
if req.request_type == "resource":
|
||||
if req.priority is None:
|
||||
raise ValueError("Priority must be set for resource requests")
|
||||
await ctx.send_message(ResourceRequest(req.resource_type, req.amount, req.priority))
|
||||
elif req.request_type == "policy":
|
||||
if req.policy_type is None:
|
||||
raise ValueError("Policy type must be set for policy requests")
|
||||
await ctx.send_message(PolicyRequest(req.policy_type, req.resource_type, req.amount))
|
||||
else:
|
||||
raise ValueError(f"Unknown request type: {req.request_type}")
|
||||
# Notify the collector about the number of requests sent
|
||||
await ctx.send_message(len(requests))
|
||||
|
||||
class ResourceRequester(Executor):
|
||||
"""Handles resource allocation requests."""
|
||||
|
||||
@handler
|
||||
async def run(self, request: ResourceRequest, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(request, ResourceRequest, ResourceResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: ResourceRequest, response: ResourceResponse, ctx: WorkflowContext[ResourceResponse]
|
||||
) -> None:
|
||||
print(f"Resource allocated: {response.allocated} {response.resource_type} from {response.source}")
|
||||
await ctx.send_message(response)
|
||||
|
||||
class PolicyChecker(Executor):
|
||||
"""Handles policy check requests."""
|
||||
|
||||
@handler
|
||||
async def run(self, request: PolicyRequest, ctx: WorkflowContext) -> None:
|
||||
await ctx.request_info(request, PolicyRequest, PolicyResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: PolicyRequest, response: PolicyResponse, ctx: WorkflowContext[PolicyResponse]
|
||||
) -> None:
|
||||
print(f"Policy check result: {response.approved} - {response.reason}")
|
||||
await ctx.send_message(response)
|
||||
|
||||
class ResultCollector(Executor):
|
||||
"""Collects and processes all responses."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id)
|
||||
self._request_count = 0
|
||||
self._responses: list[ResourceResponse | PolicyResponse] = []
|
||||
|
||||
@handler
|
||||
async def set_request_count(self, count: int, ctx: WorkflowContext) -> None:
|
||||
if count <= 0:
|
||||
raise ValueError("Request count must be positive")
|
||||
self._request_count = count
|
||||
|
||||
@handler
|
||||
async def collect(self, response: ResourceResponse | PolicyResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
self._responses.append(response)
|
||||
print(f"Collected {len(self._responses)}/{self._request_count} responses")
|
||||
if len(self._responses) == self._request_count:
|
||||
# All responses received, process them
|
||||
await ctx.yield_output(f"All {self._request_count} requests processed.")
|
||||
elif len(self._responses) > self._request_count:
|
||||
raise ValueError("Received more responses than expected")
|
||||
|
||||
orchestrator = RequestDistribution("orchestrator")
|
||||
resource_requester = ResourceRequester("resource_requester")
|
||||
policy_checker = PolicyChecker("policy_checker")
|
||||
result_collector = ResultCollector("result_collector")
|
||||
|
||||
return (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(orchestrator)
|
||||
.add_edge(orchestrator, resource_requester)
|
||||
.add_edge(orchestrator, policy_checker)
|
||||
.add_edge(resource_requester, result_collector)
|
||||
.add_edge(policy_checker, result_collector)
|
||||
.add_edge(orchestrator, result_collector) # For request count
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
class ResourceAllocator(Executor):
|
||||
"""Simulates a system that allocates computing resources."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id)
|
||||
self._cache: dict[str, int] = {"cpu": 10, "memory": 50, "disk": 100}
|
||||
# Record pending requests to match responses
|
||||
self._pending_requests: dict[str, RequestInfoEvent] = {}
|
||||
|
||||
async def _handle_resource_request(self, request: ResourceRequest) -> ResourceResponse | None:
|
||||
"""Allocates resources based on request and available cache."""
|
||||
available = self._cache.get(request.resource_type, 0)
|
||||
if available >= request.amount:
|
||||
self._cache[request.resource_type] -= request.amount
|
||||
return ResourceResponse(request.resource_type, request.amount, "cache")
|
||||
return None
|
||||
|
||||
@handler
|
||||
async def request_resources(
|
||||
self,
|
||||
requests: list[dict[str, Any]],
|
||||
ctx: WorkflowContext[ResourceRequest | PolicyCheckRequest],
|
||||
async def handle_subworkflow_request(
|
||||
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
|
||||
) -> None:
|
||||
"""Process a list of resource requests."""
|
||||
print(f"🏭 Sub-workflow processing {len(requests)} requests")
|
||||
self._request_count += len(requests)
|
||||
|
||||
for req_data in requests:
|
||||
req_type = req_data.get("request_type", "resource")
|
||||
|
||||
request: ResourceRequest | PolicyCheckRequest
|
||||
if req_type == "resource":
|
||||
print(f" 📦 Requesting resource: {req_data.get('type', 'cpu')} x{req_data.get('amount', 1)}")
|
||||
request = ResourceRequest(
|
||||
resource_type=req_data.get("type", "cpu"),
|
||||
amount=req_data.get("amount", 1),
|
||||
priority=req_data.get("priority", "normal"),
|
||||
)
|
||||
# Send to parent workflow for interception - not to target_id
|
||||
await ctx.send_message(request)
|
||||
elif req_type == "policy":
|
||||
print(
|
||||
f" 🛡️ Checking policy: {req_data.get('type', 'cpu')} x{req_data.get('amount', 1)} "
|
||||
f"({req_data.get('policy_type', 'quota')})"
|
||||
)
|
||||
request = PolicyCheckRequest(
|
||||
resource_type=req_data.get("type", "cpu"),
|
||||
amount=req_data.get("amount", 1),
|
||||
policy_type=req_data.get("policy_type", "quota"),
|
||||
)
|
||||
# Send to parent workflow for interception - not to target_id
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def handle_resource_response(
|
||||
self,
|
||||
response: RequestResponse[ResourceRequest, ResourceResponse],
|
||||
ctx: WorkflowContext[Never, RequestFinished],
|
||||
) -> None:
|
||||
"""Handle resource allocation response."""
|
||||
if response.data:
|
||||
source_icon = "🏪" if response.data.source == "cache" else "🌐"
|
||||
print(
|
||||
f"📦 {source_icon} Sub-workflow received: {response.data.allocated} {response.data.resource_type} "
|
||||
f"from {response.data.source}"
|
||||
)
|
||||
if self._collect_results():
|
||||
# Yield completion result to the parent workflow.
|
||||
await ctx.yield_output(RequestFinished())
|
||||
|
||||
@handler
|
||||
async def handle_policy_response(
|
||||
self,
|
||||
response: RequestResponse[PolicyCheckRequest, PolicyResponse],
|
||||
ctx: WorkflowContext[Never, RequestFinished],
|
||||
) -> None:
|
||||
"""Handle policy check response."""
|
||||
if response.data:
|
||||
status_icon = "✅" if response.data.approved else "❌"
|
||||
print(
|
||||
f"🛡️ {status_icon} Sub-workflow received policy response: "
|
||||
f"{response.data.approved} - {response.data.reason}"
|
||||
)
|
||||
if self._collect_results():
|
||||
# Yield completion result to the parent workflow.
|
||||
await ctx.yield_output(RequestFinished())
|
||||
|
||||
def _collect_results(self) -> bool:
|
||||
"""Collect and summarize results."""
|
||||
self._request_count -= 1
|
||||
print(f"📊 Sub-workflow completed request ({self._request_count} remaining)")
|
||||
return self._request_count == 0
|
||||
|
||||
|
||||
# 3. Implement the Resource Cache - Uses typed handler for ResourceRequest
|
||||
class ResourceCache(Executor):
|
||||
"""Interceptor that handles RESOURCE requests from cache using typed routing."""
|
||||
|
||||
# Use class attributes to avoid Pydantic assignment restrictions
|
||||
cache: dict[str, int] = {"cpu": 10, "memory": 50, "disk": 100}
|
||||
results: list[ResourceResponse] = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="resource_cache")
|
||||
# Instance initialization only; state kept in class attributes as above
|
||||
|
||||
@handler
|
||||
async def handle_resource_request(
|
||||
self, request: ResourceRequest, ctx: WorkflowContext[RequestResponse[ResourceRequest, Any] | ResourceRequest]
|
||||
) -> None:
|
||||
"""Handle RESOURCE requests from sub-workflows and check cache first."""
|
||||
resource_request = request
|
||||
print(f"🏪 CACHE interceptor checking: {resource_request.amount} {resource_request.resource_type}")
|
||||
|
||||
available = self.cache.get(resource_request.resource_type, 0)
|
||||
|
||||
if available >= resource_request.amount:
|
||||
# We can satisfy from cache
|
||||
self.cache[resource_request.resource_type] -= resource_request.amount
|
||||
response_data = ResourceResponse(
|
||||
resource_type=resource_request.resource_type, allocated=resource_request.amount, source="cache"
|
||||
)
|
||||
print(f" ✅ Cache satisfied: {resource_request.amount} {resource_request.resource_type}")
|
||||
self.results.append(response_data)
|
||||
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data=response_data, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
# Cache miss - forward to external
|
||||
print(f" ❌ Cache miss: need {resource_request.amount}, have {available} {resource_request.resource_type}")
|
||||
await ctx.send_message(request)
|
||||
|
||||
@handler
|
||||
async def collect_result(
|
||||
self, response: RequestResponse[ResourceRequest, ResourceResponse], ctx: WorkflowContext
|
||||
) -> None:
|
||||
"""Collect results from external requests that were forwarded."""
|
||||
if response.data and response.data.source != "cache": # Don't double-count our own results
|
||||
self.results.append(response.data)
|
||||
print(
|
||||
f"🏪 🌐 Cache received external response: {response.data.allocated} {response.data.resource_type} "
|
||||
f"from {response.data.source}"
|
||||
)
|
||||
|
||||
|
||||
# 4. Implement the Policy Engine - Uses typed handler for PolicyCheckRequest
|
||||
class PolicyEngine(Executor):
|
||||
"""Interceptor that handles POLICY requests using typed routing."""
|
||||
|
||||
# Use class attributes for simple sample state
|
||||
quota: dict[str, int] = {
|
||||
"cpu": 5, # Only allow up to 5 CPU units
|
||||
"memory": 20, # Only allow up to 20 memory units
|
||||
"disk": 1000, # Liberal disk policy
|
||||
}
|
||||
results: list[PolicyResponse] = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(id="policy_engine")
|
||||
# Instance initialization only; state kept in class attributes as above
|
||||
|
||||
@handler
|
||||
async def handle_policy_request(
|
||||
self,
|
||||
request: PolicyCheckRequest,
|
||||
ctx: WorkflowContext[RequestResponse[PolicyCheckRequest, Any] | PolicyCheckRequest],
|
||||
) -> None:
|
||||
"""Handle POLICY requests from sub-workflows and apply rules."""
|
||||
policy_request = request
|
||||
print(
|
||||
f"🛡️ POLICY interceptor checking: {policy_request.amount} {policy_request.resource_type}, policy={policy_request.policy_type}"
|
||||
)
|
||||
|
||||
quota_limit = self.quota.get(policy_request.resource_type, 0)
|
||||
|
||||
if policy_request.policy_type == "quota":
|
||||
if policy_request.amount <= quota_limit:
|
||||
response_data = PolicyResponse(approved=True, reason=f"Within quota ({quota_limit})")
|
||||
print(f" ✅ Policy approved: {policy_request.amount} <= {quota_limit}")
|
||||
self.results.append(response_data)
|
||||
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data=response_data, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
return
|
||||
|
||||
# Exceeds quota - forward to external for review
|
||||
print(f" ❌ Policy exceeds quota: {policy_request.amount} > {quota_limit}, forwarding to external")
|
||||
await ctx.send_message(request)
|
||||
"""Handles requests from sub-workflows."""
|
||||
source_event: RequestInfoEvent = request.source_event
|
||||
if not isinstance(source_event.data, ResourceRequest):
|
||||
return
|
||||
|
||||
# Unknown policy type - forward to external
|
||||
print(f" ❓ Unknown policy type: {policy_request.policy_type}, forwarding")
|
||||
await ctx.send_message(request)
|
||||
request_payload: ResourceRequest = source_event.data
|
||||
response = await self._handle_resource_request(request_payload)
|
||||
if response:
|
||||
await ctx.send_message(request.create_response(response))
|
||||
else:
|
||||
# Request cannot be fulfilled via cache, forward the request to external
|
||||
self._pending_requests[request_payload.id] = source_event
|
||||
await ctx.request_info(request_payload, ResourceRequest, ResourceResponse)
|
||||
|
||||
@handler
|
||||
async def collect_policy_result(
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext
|
||||
@response_handler
|
||||
async def handle_external_response(
|
||||
self,
|
||||
original_request: ResourceRequest,
|
||||
response: ResourceResponse,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Collect policy results from external requests that were forwarded."""
|
||||
if response.data:
|
||||
self.results.append(response.data)
|
||||
print(f"🛡️ 🌐 Policy received external response: {response.data.approved} - {response.data.reason}")
|
||||
"""Handles responses from external systems and routes them to the sub-workflow."""
|
||||
print(f"External resource allocated: {response.allocated} {response.resource_type} from {response.source}")
|
||||
source_event = self._pending_requests.pop(original_request.id, None)
|
||||
if source_event is None:
|
||||
raise ValueError("No matching pending request found for the resource response")
|
||||
await ctx.send_message(SubWorkflowResponseMessage(data=response, source_event=source_event))
|
||||
|
||||
|
||||
class Coordinator(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="coordinator")
|
||||
class PolicyEngine(Executor):
|
||||
"""Simulates a policy engine that approves or denies resource requests."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id)
|
||||
self._quota: dict[str, int] = {
|
||||
"cpu": 5, # Only allow up to 5 CPU units
|
||||
"memory": 20, # Only allow up to 20 memory units
|
||||
"disk": 1000, # Liberal disk policy
|
||||
}
|
||||
# Record pending requests to match responses
|
||||
self._pending_requests: dict[str, RequestInfoEvent] = {}
|
||||
|
||||
@handler
|
||||
async def start(self, requests: list[dict[str, Any]], ctx: WorkflowContext[list[dict[str, Any]]]) -> None:
|
||||
"""Start the resource allocation process."""
|
||||
await ctx.send_message(requests, target_id="resource_workflow")
|
||||
async def handle_subworkflow_request(
|
||||
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
|
||||
) -> None:
|
||||
"""Handles requests from sub-workflows."""
|
||||
source_event: RequestInfoEvent = request.source_event
|
||||
if not isinstance(source_event.data, PolicyRequest):
|
||||
return
|
||||
|
||||
@handler
|
||||
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext) -> None:
|
||||
"""Handle sub-workflow completion.
|
||||
request_payload: PolicyRequest = source_event.data
|
||||
# Simple policy logic for demonstration
|
||||
if request_payload.policy_type == "quota":
|
||||
allowed_amount = self._quota.get(request_payload.resource_type, 0)
|
||||
if request_payload.amount <= allowed_amount:
|
||||
response = PolicyResponse(True, "Within quota limits")
|
||||
else:
|
||||
response = PolicyResponse(False, "Exceeds quota limits")
|
||||
await ctx.send_message(request.create_response(response))
|
||||
else:
|
||||
# For other policy types, forward to external system
|
||||
self._pending_requests[request_payload.id] = source_event
|
||||
await ctx.request_info(request_payload, PolicyRequest, PolicyResponse)
|
||||
|
||||
It comes from the sub-workflow yielded output.
|
||||
"""
|
||||
print("🎯 Main workflow received completion.")
|
||||
@response_handler
|
||||
async def handle_external_response(
|
||||
self,
|
||||
original_request: PolicyRequest,
|
||||
response: PolicyResponse,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
"""Handles responses from external systems and routes them to the sub-workflow."""
|
||||
print(f"External policy check result: {response.approved} - {response.reason}")
|
||||
source_event = self._pending_requests.pop(original_request.id, None)
|
||||
if source_event is None:
|
||||
raise ValueError("No matching pending request found for the policy response")
|
||||
await ctx.send_message(SubWorkflowResponseMessage(data=response, source_event=source_event))
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Demonstrate parallel request interception patterns."""
|
||||
print("🚀 Starting Sub-Workflow Parallel Request Interception Demo...")
|
||||
print("=" * 60)
|
||||
# Create executors in the main workflow
|
||||
sub_workflow = build_resource_request_distribution_workflow()
|
||||
resource_allocator = ResourceAllocator("resource_allocator")
|
||||
policy_engine = PolicyEngine("policy_engine")
|
||||
|
||||
# 5. Create the sub-workflow
|
||||
resource_requester = ResourceRequester()
|
||||
sub_request_info = RequestInfoExecutor(id="sub_request_info")
|
||||
|
||||
sub_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(resource_requester)
|
||||
.add_edge(resource_requester, sub_request_info)
|
||||
.add_edge(sub_request_info, resource_requester)
|
||||
.build()
|
||||
# Create the WorkflowExecutor for the sub-workflow
|
||||
# Setting allow_direct_output=True to let the sub-workflow output directly.
|
||||
# This is because the sub-workflow is the both the entry point and the exit
|
||||
# point of the main workflow.
|
||||
sub_workflow_executor = WorkflowExecutor(
|
||||
sub_workflow,
|
||||
"sub_workflow_executor",
|
||||
allow_direct_output=True,
|
||||
)
|
||||
|
||||
# 6. Create parent workflow with PROPER interceptor pattern
|
||||
cache = ResourceCache() # Intercepts ResourceRequest
|
||||
policy = PolicyEngine() # Intercepts PolicyCheckRequest (different type!)
|
||||
workflow_executor = WorkflowExecutor(sub_workflow, id="resource_workflow")
|
||||
main_request_info = RequestInfoExecutor(id="main_request_info")
|
||||
|
||||
# Create a simple coordinator that starts the process
|
||||
coordinator = Coordinator()
|
||||
|
||||
# TYPED ROUTING: Each executor handles specific typed RequestInfoMessage messages
|
||||
# Build the main workflow
|
||||
main_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(coordinator)
|
||||
.add_edge(coordinator, workflow_executor) # Start sub-workflow
|
||||
.add_edge(workflow_executor, coordinator) # Sub-workflow completion back to coordinator
|
||||
.add_edge(workflow_executor, cache) # WorkflowExecutor sends ResourceRequest to cache
|
||||
.add_edge(workflow_executor, policy) # WorkflowExecutor sends PolicyCheckRequest to policy
|
||||
.add_edge(cache, workflow_executor) # Cache sends RequestResponse back
|
||||
.add_edge(policy, workflow_executor) # Policy sends RequestResponse back
|
||||
.add_edge(cache, main_request_info) # Cache forwards ResourceRequest to external
|
||||
.add_edge(policy, main_request_info) # Policy forwards PolicyCheckRequest to external
|
||||
.add_edge(main_request_info, workflow_executor) # External responses back to sub-workflow
|
||||
.set_start_executor(sub_workflow_executor)
|
||||
.add_edge(sub_workflow_executor, resource_allocator)
|
||||
.add_edge(resource_allocator, sub_workflow_executor)
|
||||
.add_edge(sub_workflow_executor, policy_engine)
|
||||
.add_edge(policy_engine, sub_workflow_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# 7. Test with various requests (mixed resource and policy)
|
||||
# Test requests
|
||||
test_requests = [
|
||||
{"request_type": "resource", "type": "cpu", "amount": 2, "priority": "normal"}, # Cache hit
|
||||
{"request_type": "policy", "type": "cpu", "amount": 3, "policy_type": "quota"}, # Policy hit
|
||||
{"request_type": "resource", "type": "memory", "amount": 15, "priority": "normal"}, # Cache hit
|
||||
{"request_type": "policy", "type": "memory", "amount": 100, "policy_type": "quota"}, # Policy miss -> external
|
||||
{"request_type": "resource", "type": "gpu", "amount": 1, "priority": "high"}, # Cache miss -> external
|
||||
{"request_type": "policy", "type": "disk", "amount": 500, "policy_type": "quota"}, # Policy hit
|
||||
{"request_type": "policy", "type": "cpu", "amount": 1, "policy_type": "security"}, # Unknown policy -> external
|
||||
ComputingResourceRequest("resource", "cpu", 2, priority="normal"), # cache hit
|
||||
ComputingResourceRequest("policy", "cpu", 3, policy_type="quota"), # policy hit
|
||||
ComputingResourceRequest("resource", "memory", 15, priority="normal"), # cache hit
|
||||
ComputingResourceRequest("policy", "memory", 100, policy_type="quota"), # policy miss -> external
|
||||
ComputingResourceRequest("resource", "gpu", 1, priority="high"), # cache miss -> external
|
||||
ComputingResourceRequest("policy", "disk", 500, policy_type="quota"), # policy hit
|
||||
ComputingResourceRequest("policy", "cpu", 1, policy_type="security"), # unknown policy -> external
|
||||
]
|
||||
|
||||
print(f"🧪 Testing with {len(test_requests)} mixed requests:")
|
||||
for i, req in enumerate(test_requests, 1):
|
||||
req_icon = "📦" if req["request_type"] == "resource" else "🛡️"
|
||||
print(
|
||||
f" {i}. {req_icon} {req['type']} x{req['amount']} "
|
||||
f"({req.get('priority', req.get('policy_type', 'default'))})"
|
||||
)
|
||||
print("=" * 70)
|
||||
# Run the workflow
|
||||
print(f"🧪 Testing with {len(test_requests)} mixed requests.")
|
||||
print("🚀 Starting main workflow...")
|
||||
run_result = await main_workflow.run(test_requests)
|
||||
|
||||
# 8. Run the workflow
|
||||
print("🎬 Running workflow...")
|
||||
events = await main_workflow.run(test_requests)
|
||||
# Handle request info events
|
||||
request_info_events = run_result.get_request_info_events()
|
||||
if request_info_events:
|
||||
print(f"\n🔍 Handling {len(request_info_events)} request info events...\n")
|
||||
|
||||
# 9. Handle any external requests that couldn't be intercepted
|
||||
request_events = events.get_request_info_events()
|
||||
if request_events:
|
||||
print(f"\n🌐 Handling {len(request_events)} external request(s)...")
|
||||
|
||||
external_responses: dict[str, Any] = {}
|
||||
for event in request_events:
|
||||
responses: dict[str, ResourceResponse | PolicyResponse] = {}
|
||||
for event in request_info_events:
|
||||
if isinstance(event.data, ResourceRequest):
|
||||
# Handle ResourceRequest - create ResourceResponse
|
||||
# Simulate external resource allocation
|
||||
resource_response = ResourceResponse(
|
||||
resource_type=event.data.resource_type, allocated=event.data.amount, source="external_provider"
|
||||
)
|
||||
external_responses[event.request_id] = resource_response
|
||||
print(f" 🏭 External provider: {resource_response.allocated} {resource_response.resource_type}")
|
||||
elif isinstance(event.data, PolicyCheckRequest):
|
||||
# Handle PolicyCheckRequest - create PolicyResponse
|
||||
policy_response = PolicyResponse(approved=True, reason="External policy service approved")
|
||||
external_responses[event.request_id] = policy_response
|
||||
print(f" 🔒 External policy: {'✅ APPROVED' if policy_response.approved else '❌ DENIED'}")
|
||||
responses[event.request_id] = resource_response
|
||||
elif isinstance(event.data, PolicyRequest):
|
||||
# Simulate external policy check
|
||||
response = PolicyResponse(True, "External system approved")
|
||||
responses[event.request_id] = response
|
||||
else:
|
||||
print(f"Unknown request info event data type: {type(event.data)}")
|
||||
|
||||
await main_workflow.send_responses(external_responses)
|
||||
run_result = await main_workflow.send_responses(responses)
|
||||
|
||||
outputs = run_result.get_outputs()
|
||||
if outputs:
|
||||
print("\nWorkflow completed with outputs:")
|
||||
for output in outputs:
|
||||
print(f"- {output}")
|
||||
else:
|
||||
print("\n🎯 All requests were intercepted internally!")
|
||||
|
||||
# 10. Show results and analysis
|
||||
print("\n" + "=" * 70)
|
||||
print("📊 RESULTS ANALYSIS")
|
||||
print("=" * 70)
|
||||
|
||||
print(f"\n🏪 Cache Results ({len(cache.results)} handled):")
|
||||
for result in cache.results:
|
||||
print(f" ✅ {result.allocated} {result.resource_type} from {result.source}")
|
||||
|
||||
print(f"\n🛡️ Policy Results ({len(policy.results)} handled):")
|
||||
for result in policy.results:
|
||||
status_icon = "✅" if result.approved else "❌"
|
||||
print(f" {status_icon} Approved: {result.approved} - {result.reason}")
|
||||
|
||||
print("\n💾 Final Cache State:")
|
||||
for resource, amount in cache.cache.items():
|
||||
print(f" 📦 {resource}: {amount} remaining")
|
||||
|
||||
print("\n📈 Summary:")
|
||||
print(f" 🎯 Total requests: {len(test_requests)}")
|
||||
print(f" 🏪 Resource requests handled: {len(cache.results)}")
|
||||
print(f" 🛡️ Policy requests handled: {len(policy.results)}")
|
||||
print(f" 🌐 External requests: {len(request_events) if request_events else 0}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
raise RuntimeError("Workflow did not produce an output.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+244
-226
@@ -5,289 +5,307 @@ from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
WorkflowOutputEvent,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
"""
|
||||
Sample: Sub-Workflows with Request Interception
|
||||
This sample demonstrates how to handle request from the sub-workflow in the main workflow.
|
||||
|
||||
This sample shows how to:
|
||||
1. Create workflows that execute other workflows as sub-workflows
|
||||
2. Intercept requests from sub-workflows using an executor with @handler for RequestInfoMessage subclasses
|
||||
3. Conditionally handle or forward requests using RequestResponse messages
|
||||
4. Handle external requests that are forwarded by the parent workflow
|
||||
5. Proper request/response correlation for concurrent processing
|
||||
Prerequisite:
|
||||
- Understanding of sub-workflows.
|
||||
- Understanding of requests and responses.
|
||||
|
||||
The example simulates an email validation system where:
|
||||
- Sub-workflows validate multiple email addresses concurrently
|
||||
- Parent workflows can intercept domain check requests for optimization
|
||||
- Known domains (example.com, company.com) are approved locally
|
||||
- Unknown domains (unknown.org) are forwarded to external services
|
||||
- Request correlation ensures each email gets the correct domain check response
|
||||
- External domain check requests are processed and responses routed back correctly
|
||||
This pattern is useful when you want to reuse a workflow that makes requests to an external system,
|
||||
but you want to intercept those requests in the main workflow and handle them without further propagation
|
||||
to the external system.
|
||||
|
||||
Key concepts demonstrated:
|
||||
- WorkflowExecutor: Wraps a workflow to make it behave as an executor
|
||||
- RequestInfoMessage handler: @handler method to intercept sub-workflow requests
|
||||
- Request correlation: Using request_id and source_executor_id to match responses with original requests
|
||||
- Concurrent processing: Multiple emails processed simultaneously without interference
|
||||
- External request routing: RequestInfoExecutor handles forwarded external requests
|
||||
- Sub-workflow isolation: Sub-workflows work normally without knowing they're nested
|
||||
- Sub-workflows complete by yielding outputs when validation is finished
|
||||
|
||||
Prerequisites:
|
||||
- No external services required (external calls are simulated via `RequestInfoExecutor`).
|
||||
|
||||
Simple flow visualization:
|
||||
|
||||
Parent Orchestrator (handles DomainCheckRequest)
|
||||
|
|
||||
| EmailValidationRequest(email) x3 (concurrent)
|
||||
v
|
||||
[ Sub-workflow: WorkflowExecutor(EmailValidator) ]
|
||||
|
|
||||
| DomainCheckRequest(domain) with request_id and source_executor_id
|
||||
v
|
||||
Interception? yes -> handled locally with RequestResponse(data=True)
|
||||
no -> forwarded to RequestInfoExecutor -> external service
|
||||
|
|
||||
v
|
||||
Response routed back to sub-workflow using source_executor_id
|
||||
This sample implements a smart email delivery system that validates email addresses before sending emails.
|
||||
1. We will start by creating a workflow that validates email addresses in a sequential manner. The validation
|
||||
consists of three steps: sanitization, format validation, and domain validation. The domain validation
|
||||
step will involve checking if the email domain is valid by making a request to an external system.
|
||||
2. Then we will create a main workflow that uses the email validation workflow as a sub-workflow. The main
|
||||
workflow will intercept the domain validation requests from the sub-workflow and handle them internally
|
||||
without propagating them to an external system.
|
||||
3. Once the email address is validated, the main workflow will proceed to send the email if the address is valid,
|
||||
or block the email if the address is invalid.
|
||||
"""
|
||||
|
||||
|
||||
# 1. Define domain-specific message types
|
||||
@dataclass
|
||||
class EmailValidationRequest:
|
||||
"""Request to validate an email address."""
|
||||
class SanitizedEmailResult:
|
||||
"""Result of email sanitization and validation.
|
||||
|
||||
email: str
|
||||
The properties get built up as the email address goes through
|
||||
the validation steps in the workflow.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomainCheckRequest(RequestInfoMessage):
|
||||
"""Request to check if a domain is approved."""
|
||||
|
||||
domain: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of email validation."""
|
||||
|
||||
email: str
|
||||
original: str
|
||||
sanitized: str
|
||||
is_valid: bool
|
||||
reason: str
|
||||
|
||||
|
||||
# 2. Implement the sub-workflow executor (completely standard)
|
||||
class EmailValidator(Executor):
|
||||
"""Validates email addresses - doesn't know it's in a sub-workflow."""
|
||||
def build_email_address_validation_workflow() -> Workflow:
|
||||
"""Build an email address validation workflow.
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the EmailValidator executor."""
|
||||
super().__init__(id="email_validator")
|
||||
# Use a dict to track multiple pending emails by request_id
|
||||
self._pending_emails: dict[str, str] = {}
|
||||
This workflow consists of three steps (each is represented by an executor):
|
||||
1. Sanitize the email address, such as removing leading/trailing spaces.
|
||||
2. Validate the email address format, such as checking for "@" and domain.
|
||||
3. Extract the domain from the email address and request domain validation,
|
||||
after which it completes with the final result.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def validate_request(
|
||||
self,
|
||||
request: EmailValidationRequest,
|
||||
ctx: WorkflowContext[DomainCheckRequest | ValidationResult, ValidationResult],
|
||||
) -> None:
|
||||
"""Validate an email address."""
|
||||
print(f"🔍 Sub-workflow validating email: {request.email}")
|
||||
class EmailSanitizer(Executor):
|
||||
"""Sanitize email address by trimming spaces."""
|
||||
|
||||
# Extract domain
|
||||
domain = request.email.split("@")[1] if "@" in request.email else ""
|
||||
@handler
|
||||
async def handle(self, email_address: str, ctx: WorkflowContext[SanitizedEmailResult]) -> None:
|
||||
"""Trim leading and trailing spaces from the email address.
|
||||
|
||||
if not domain:
|
||||
print(f"❌ Invalid email format: {request.email}")
|
||||
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
|
||||
await ctx.yield_output(result)
|
||||
return
|
||||
This executor doesn't produce any workflow output, but sends the sanitized
|
||||
email address to the next executor in the workflow.
|
||||
"""
|
||||
sanitized = email_address.strip()
|
||||
print(f"✂️ Sanitized email address: '{sanitized}'")
|
||||
await ctx.send_message(SanitizedEmailResult(original=email_address, sanitized=sanitized, is_valid=False))
|
||||
|
||||
print(f"🌐 Sub-workflow requesting domain check for: {domain}")
|
||||
# Request domain check
|
||||
domain_check = DomainCheckRequest(domain=domain)
|
||||
# Store the pending email with the request_id for correlation
|
||||
self._pending_emails[domain_check.request_id] = request.email
|
||||
await ctx.send_message(domain_check, target_id="email_request_info")
|
||||
class EmailFormatValidator(Executor):
|
||||
"""Validate email address format."""
|
||||
|
||||
@handler
|
||||
async def handle_domain_response(
|
||||
self,
|
||||
response: RequestResponse[DomainCheckRequest, bool],
|
||||
ctx: WorkflowContext[ValidationResult, ValidationResult],
|
||||
) -> None:
|
||||
"""Handle domain check response from RequestInfo with correlation."""
|
||||
approved = bool(response.data)
|
||||
domain = (
|
||||
response.original_request.domain
|
||||
if (hasattr(response, "original_request") and response.original_request)
|
||||
else "unknown"
|
||||
)
|
||||
print(f"📬 Sub-workflow received domain response for '{domain}': {approved}")
|
||||
@handler
|
||||
async def handle(
|
||||
self,
|
||||
partial_result: SanitizedEmailResult,
|
||||
ctx: WorkflowContext[SanitizedEmailResult, SanitizedEmailResult],
|
||||
) -> None:
|
||||
"""Validate the email address format.
|
||||
|
||||
# Find the corresponding email using the request_id
|
||||
request_id = (
|
||||
response.original_request.request_id
|
||||
if (hasattr(response, "original_request") and response.original_request)
|
||||
else None
|
||||
)
|
||||
if request_id and request_id in self._pending_emails:
|
||||
email = self._pending_emails.pop(request_id) # Remove from pending
|
||||
result = ValidationResult(
|
||||
email=email,
|
||||
is_valid=approved,
|
||||
reason="Domain approved" if approved else "Domain not approved",
|
||||
This executor can potentially produce a workflow output (False if the format is invalid).
|
||||
When the format is valid, it sends the validated email address to the next executor in the workflow.
|
||||
"""
|
||||
if "@" not in partial_result.sanitized or "." not in partial_result.sanitized.split("@")[-1]:
|
||||
print(f"❌ Invalid email format: '{partial_result.sanitized}'")
|
||||
await ctx.yield_output(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
|
||||
)
|
||||
)
|
||||
return
|
||||
print(f"✅ Validated email format: '{partial_result.sanitized}'")
|
||||
await ctx.send_message(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
|
||||
)
|
||||
)
|
||||
print(f"✅ Sub-workflow completing validation for: {email}")
|
||||
await ctx.yield_output(result)
|
||||
|
||||
class DomainValidator(Executor):
|
||||
"""Validate email domain."""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
self._pending_domains: dict[str, SanitizedEmailResult] = {}
|
||||
|
||||
@handler
|
||||
async def handle(self, partial_result: SanitizedEmailResult, ctx: WorkflowContext) -> None:
|
||||
"""Extract the domain from the email address and request domain validation.
|
||||
|
||||
This executor doesn't produce any workflow output, but sends a domain validation request
|
||||
to an external system to user for validation.
|
||||
"""
|
||||
domain = partial_result.sanitized.split("@")[-1]
|
||||
print(f"🔍 Validating domain: '{domain}'")
|
||||
self._pending_domains[domain] = partial_result
|
||||
# Send a request to the external system via the request_info mechanism
|
||||
await ctx.request_info(domain, str, bool)
|
||||
|
||||
@response_handler
|
||||
async def handle_domain_validation_response(
|
||||
self, original_request: str, is_valid: bool, ctx: WorkflowContext[Never, SanitizedEmailResult]
|
||||
) -> None:
|
||||
"""Handle the domain validation response.
|
||||
|
||||
This method receives the response from the external system and yields the final
|
||||
validation result (True if both format and domain are valid, False otherwise).
|
||||
"""
|
||||
if original_request not in self._pending_domains:
|
||||
raise ValueError(f"Received response for unknown domain: '{original_request}'")
|
||||
partial_result = self._pending_domains.pop(original_request)
|
||||
if is_valid:
|
||||
print(f"✅ Domain '{original_request}' is valid.")
|
||||
await ctx.yield_output(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=True
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"❌ Domain '{original_request}' is invalid.")
|
||||
await ctx.yield_output(
|
||||
SanitizedEmailResult(
|
||||
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
|
||||
)
|
||||
)
|
||||
|
||||
# Build the workflow
|
||||
sanitizer = EmailSanitizer(id="email_sanitizer")
|
||||
format_validator = EmailFormatValidator(id="email_format_validator")
|
||||
domain_validator = DomainValidator(id="domain_validator")
|
||||
|
||||
return (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(sanitizer)
|
||||
.add_edge(sanitizer, format_validator)
|
||||
.add_edge(format_validator, domain_validator)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Email:
|
||||
recipient: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
# 3. Implement the parent workflow with request interception
|
||||
class SmartEmailOrchestrator(Executor):
|
||||
"""Parent orchestrator that can intercept domain checks."""
|
||||
"""Orchestrates email address validation using a sub-workflow."""
|
||||
|
||||
approved_domains: set[str] = set()
|
||||
|
||||
def __init__(self, approved_domains: set[str] | None = None):
|
||||
"""Initialize the SmartEmailOrchestrator with approved domains.
|
||||
def __init__(self, id: str, approved_domains: set[str]):
|
||||
"""Initialize the orchestrator with a set of approved domains.
|
||||
|
||||
Args:
|
||||
approved_domains: Set of pre-approved domains, defaults to example.com, test.org, company.com
|
||||
id: The executor ID.
|
||||
approved_domains: A set of domains that are considered valid.
|
||||
"""
|
||||
super().__init__(id="email_orchestrator", approved_domains=approved_domains)
|
||||
self._results: list[ValidationResult] = []
|
||||
super().__init__(id=id)
|
||||
self._approved_domains = approved_domains
|
||||
# Keep track of previously approved and disapproved recipients
|
||||
self._approved_recipients: set[str] = set()
|
||||
self._disapproved_recipients: set[str] = set()
|
||||
# Record pending emails waiting for validation results
|
||||
self._pending_emails: dict[str, Email] = {}
|
||||
|
||||
@handler
|
||||
async def start_validation(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
"""Start validating a batch of emails."""
|
||||
print(f"📧 Starting validation of {len(emails)} email addresses")
|
||||
print("=" * 60)
|
||||
for email in emails:
|
||||
print(f"📤 Sending '{email}' to sub-workflow for validation")
|
||||
request = EmailValidationRequest(email=email)
|
||||
await ctx.send_message(request, target_id="email_validator_workflow")
|
||||
async def run(self, email: Email, ctx: WorkflowContext[Email | str, bool]) -> None:
|
||||
"""Start the email delivery process.
|
||||
|
||||
This handler receives an Email object. If the recipient has been previously approved,
|
||||
it sends the email object to the next executor to handle delivery. If the recipient
|
||||
has been previously disapproved, it yields False as the final result. Otherwise,
|
||||
it sends the recipient email address to the sub-workflow for validation.
|
||||
"""
|
||||
recipient = email.recipient
|
||||
if recipient in self._approved_recipients:
|
||||
print(f"📧 Recipient '{recipient}' has been previously approved.")
|
||||
await ctx.send_message(email)
|
||||
return
|
||||
if recipient in self._disapproved_recipients:
|
||||
print(f"🚫 Blocking email to previously disapproved recipient: '{recipient}'")
|
||||
await ctx.yield_output(False)
|
||||
return
|
||||
|
||||
print(f"🔍 Validating new recipient email address: '{recipient}'")
|
||||
self._pending_emails[recipient] = email
|
||||
await ctx.send_message(recipient)
|
||||
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
request: DomainCheckRequest,
|
||||
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, bool] | DomainCheckRequest],
|
||||
async def handler_domain_validation_request(
|
||||
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
|
||||
) -> None:
|
||||
"""Handle requests from sub-workflows."""
|
||||
print(f"🔍 Parent intercepting domain check for: {request.domain}")
|
||||
"""Handle requests from the sub-workflow for domain validation.
|
||||
|
||||
if request.domain in self.approved_domains:
|
||||
print(f"✅ Domain '{request.domain}' is pre-approved locally!")
|
||||
# Send response back to sub-workflow
|
||||
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
|
||||
await ctx.send_message(response, target_id=request.source_executor_id)
|
||||
else:
|
||||
print(f"❓ Domain '{request.domain}' unknown, forwarding to external service...")
|
||||
# Forward to external handler
|
||||
await ctx.send_message(request)
|
||||
Note that the message type must be SubWorkflowRequestMessage to intercept the request. And
|
||||
the response must be sent back using SubWorkflowResponseMessage to route the response
|
||||
back to the sub-workflow.
|
||||
"""
|
||||
if not isinstance(request.source_event.data, str):
|
||||
raise TypeError(f"Expected domain string, got {type(request.source_event.data)}")
|
||||
domain = request.source_event.data
|
||||
is_valid = domain in self._approved_domains
|
||||
print(f"🌐 External domain validation for '{domain}': {'valid' if is_valid else 'invalid'}")
|
||||
await ctx.send_message(request.create_response(is_valid), target_id=request.executor_id)
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect validation results. It comes from the sub-workflow yielded output."""
|
||||
status_icon = "✅" if result.is_valid else "❌"
|
||||
print(f"📥 {status_icon} Validation result: {result.email} -> {result.reason}")
|
||||
self._results.append(result)
|
||||
async def handle_validation_result(self, result: SanitizedEmailResult, ctx: WorkflowContext[Email, bool]) -> None:
|
||||
"""Handle the email address validation result.
|
||||
|
||||
@property
|
||||
def results(self) -> list[ValidationResult]:
|
||||
"""Get the collected validation results."""
|
||||
return self._results
|
||||
This handler receives the validation result from the sub-workflow.
|
||||
If the email address is valid, it adds the recipient to the approved list
|
||||
and sends the email object to the next executor to handle delivery.
|
||||
If the email address is invalid, it adds the recipient to the disapproved list
|
||||
and yields False as the final result.
|
||||
"""
|
||||
email = self._pending_emails.pop(result.original)
|
||||
email.recipient = result.sanitized # Use the sanitized email address
|
||||
if result.is_valid:
|
||||
print(f"✅ Email address '{result.original}' is valid.")
|
||||
self._approved_recipients.add(result.original)
|
||||
await ctx.send_message(email)
|
||||
else:
|
||||
print(f"🚫 Email address '{result.original}' is invalid. Blocking email.")
|
||||
self._disapproved_recipients.add(result.original)
|
||||
await ctx.yield_output(False)
|
||||
|
||||
|
||||
async def run_example() -> None:
|
||||
"""Run the sub-workflow example."""
|
||||
print("🚀 Setting up sub-workflow with request interception...")
|
||||
print()
|
||||
class EmailDelivery(Executor):
|
||||
"""Simulates email delivery."""
|
||||
|
||||
# 4. Build the sub-workflow
|
||||
email_validator = EmailValidator()
|
||||
# Match the target_id used in EmailValidator ("email_request_info")
|
||||
request_info = RequestInfoExecutor(id="email_request_info")
|
||||
@handler
|
||||
async def handle(self, email: Email, ctx: WorkflowContext[Never, bool]) -> None:
|
||||
"""Simulate sending the email and yield True as the final result."""
|
||||
print(f"📤 Sending email to '{email.recipient}' with subject '{email.subject}'")
|
||||
await asyncio.sleep(1) # Simulate network delay
|
||||
print(f"✅ Email sent to '{email.recipient}' successfully.")
|
||||
await ctx.yield_output(True)
|
||||
|
||||
validation_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(email_validator)
|
||||
.add_edge(email_validator, request_info)
|
||||
.add_edge(request_info, email_validator)
|
||||
.build()
|
||||
)
|
||||
|
||||
# 5. Build the parent workflow with interception
|
||||
orchestrator = SmartEmailOrchestrator(approved_domains={"example.com", "company.com"})
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, id="email_validator_workflow")
|
||||
# Add a RequestInfoExecutor to handle forwarded external requests
|
||||
main_request_info = RequestInfoExecutor(id="main_request_info")
|
||||
async def main() -> None:
|
||||
# A list of approved domains
|
||||
approved_domains = {"example.com", "company.com"}
|
||||
|
||||
main_workflow = (
|
||||
# Create executors in the main workflow
|
||||
orchestrator = SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains)
|
||||
email_delivery = EmailDelivery(id="email_delivery")
|
||||
|
||||
# Create the sub-workflow for email address validation
|
||||
validation_workflow = build_email_address_validation_workflow()
|
||||
validation_workflow_executor = WorkflowExecutor(validation_workflow, id="email_validation_workflow")
|
||||
|
||||
# Build the main workflow
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(orchestrator)
|
||||
.add_edge(orchestrator, workflow_executor)
|
||||
.add_edge(workflow_executor, orchestrator) # For ValidationResult collection and request interception
|
||||
# Add edges for external request handling
|
||||
.add_edge(orchestrator, main_request_info)
|
||||
.add_edge(main_request_info, workflow_executor) # Route external responses to sub-workflow
|
||||
.add_edge(orchestrator, validation_workflow_executor)
|
||||
.add_edge(validation_workflow_executor, orchestrator)
|
||||
.add_edge(orchestrator, email_delivery)
|
||||
.build()
|
||||
)
|
||||
|
||||
# 6. Prepare test inputs: known domain, unknown domain
|
||||
test_emails = [
|
||||
"user@example.com", # Should be intercepted and approved
|
||||
"admin@company.com", # Should be intercepted and approved
|
||||
"guest@unknown.org", # Should be forwarded externally
|
||||
Email(recipient="user1@example.com", subject="Hello User1", body="This is a test email."),
|
||||
Email(recipient=" user2@invalid", subject="Hello User2", body="This is a test email."),
|
||||
Email(recipient=" user3@company.com ", subject="Hello User3", body="This is a test email."),
|
||||
Email(recipient="user4@unknown.com", subject="Hello User4", body="This is a test email."),
|
||||
# Re-send to an approved recipient
|
||||
Email(recipient="user1@example.com", subject="Hello User1", body="This is a test email."),
|
||||
# Re-send to a disapproved recipient
|
||||
Email(recipient=" user2@invalid", subject="Hello User2", body="This is a test email."),
|
||||
]
|
||||
|
||||
# 7. Run the workflow
|
||||
result = await main_workflow.run(test_emails)
|
||||
|
||||
# 8. Handle any external requests
|
||||
request_events = result.get_request_info_events()
|
||||
if request_events:
|
||||
print(f"\n🌐 Handling {len(request_events)} external request(s)...")
|
||||
for event in request_events:
|
||||
if event.data and hasattr(event.data, "domain"):
|
||||
print(f"🔍 External domain check needed for: {event.data.domain}")
|
||||
|
||||
# Simulate external responses
|
||||
external_responses: dict[str, bool] = {}
|
||||
for event in request_events:
|
||||
# Simulate external domain checking
|
||||
if event.data and hasattr(event.data, "domain"):
|
||||
domain = event.data.domain
|
||||
# Let's say unknown.org is actually approved externally
|
||||
approved = domain == "unknown.org"
|
||||
print(f"🌐 External service response for '{domain}': {'APPROVED' if approved else 'REJECTED'}")
|
||||
external_responses[event.request_id] = approved
|
||||
|
||||
# 9. Send external responses
|
||||
await main_workflow.send_responses(external_responses)
|
||||
else:
|
||||
print("\n🎯 All requests were intercepted and handled locally!")
|
||||
|
||||
# 10. Display final summary
|
||||
print("\n📊 Final Results Summary:")
|
||||
print("=" * 60)
|
||||
for result in orchestrator.results:
|
||||
status = "✅ VALID" if result.is_valid else "❌ INVALID"
|
||||
print(f"{status} {result.email}: {result.reason}")
|
||||
|
||||
print(f"\n🏁 Processed {len(orchestrator.results)} emails total")
|
||||
# Execute the workflow
|
||||
for email in test_emails:
|
||||
print(f"\n🚀 Processing email to '{email.recipient}'")
|
||||
async for event in workflow.run_stream(email):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
print(f"🎉 Final result for '{email.recipient}': {'Delivered' if event.data else 'Blocked'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_example())
|
||||
asyncio.run(main())
|
||||
|
||||
+43
-66
@@ -10,16 +10,14 @@ from agent_framework import (
|
||||
ChatMessage, # Chat message structure
|
||||
Executor, # Base class for workflow executors
|
||||
RequestInfoEvent, # Event emitted when human input is requested
|
||||
RequestInfoExecutor, # Special executor that collects human input out of band
|
||||
RequestInfoMessage, # Base class for request payloads sent to RequestInfoExecutor
|
||||
RequestResponse, # Correlates a human response with the original request
|
||||
Role, # Enum of chat roles (user, assistant, system)
|
||||
WorkflowBuilder, # Fluent builder for assembling the graph
|
||||
WorkflowContext, # Per run context and event bus
|
||||
WorkflowOutputEvent, # Event emitted when workflow yields output
|
||||
WorkflowRunState, # Enum of workflow run states
|
||||
WorkflowStatusEvent, # Event emitted on run state changes
|
||||
handler, # Decorator to expose an Executor method as a step
|
||||
handler,
|
||||
response_handler, # Decorator to expose an Executor method as a step
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -29,12 +27,12 @@ from pydantic import BaseModel
|
||||
Sample: Human in the loop guessing game
|
||||
|
||||
An agent guesses a number, then a human guides it with higher, lower, or
|
||||
correct via RequestInfoExecutor. The loop continues until the human confirms
|
||||
correct, at which point the workflow completes when idle with no pending work.
|
||||
correct. The loop continues until the human confirms correct, at which point
|
||||
the workflow completes when idle with no pending work.
|
||||
|
||||
Purpose:
|
||||
Show how to integrate a human step in the middle of an LLM workflow using RequestInfoExecutor and correlated
|
||||
RequestResponse objects.
|
||||
Show how to integrate a human step in the middle of an LLM workflow by using
|
||||
`request_info` and `send_responses_streaming`.
|
||||
|
||||
Demonstrate:
|
||||
- Alternating turns between an AgentExecutor and a human, driven by events.
|
||||
@@ -47,27 +45,20 @@ Prerequisites:
|
||||
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
|
||||
"""
|
||||
|
||||
# What RequestInfoExecutor does:
|
||||
# RequestInfoExecutor is a workflow-native bridge that pauses the graph at a request for information,
|
||||
# emits a RequestInfoEvent with a typed payload, and then resumes the graph only after your application
|
||||
# supplies a matching RequestResponse keyed by the emitted request_id. It does not gather input by itself.
|
||||
# Your application is responsible for collecting the human reply from any UI or CLI and then calling
|
||||
# send_responses_streaming with a dict mapping request_id to the human's answer. The executor exists to
|
||||
# standardize pause-and-resume human gating, to carry typed request payloads, and to preserve correlation.
|
||||
# How human-in-the-loop is achieved via `request_info` and `send_responses_streaming`:
|
||||
# - An executor (TurnManager) calls `ctx.request_info` with a payload (HumanFeedbackRequest).
|
||||
# - The workflow run pauses and emits a RequestInfoEvent with the payload and the request_id.
|
||||
# - The application captures the event, prompts the user, and collects replies.
|
||||
# - The application calls `send_responses_streaming` with a map of request_ids to replies.
|
||||
# - The workflow resumes, and the response is delivered to the executor method decorated with @response_handler.
|
||||
# - The executor can then continue the workflow, e.g., by sending a new message to the agent.
|
||||
|
||||
|
||||
# Request type sent to the RequestInfoExecutor for human feedback.
|
||||
# Including the agent's last guess allows the UI or CLI to display context and helps
|
||||
# the turn manager avoid extra state reads.
|
||||
# Why subclass RequestInfoMessage:
|
||||
# Subclassing RequestInfoMessage defines the exact schema of the request that the human will see.
|
||||
# This gives you strong typing, forward-compatible validation, and clear correlation semantics.
|
||||
# It also lets you attach contextual fields (such as the previous guess) so the UI can render a rich prompt
|
||||
# without fetching extra state from elsewhere.
|
||||
@dataclass
|
||||
class HumanFeedbackRequest(RequestInfoMessage):
|
||||
prompt: str = ""
|
||||
guess: int | None = None
|
||||
class HumanFeedbackRequest:
|
||||
"""Request sent to the human for feedback on the agent's guess."""
|
||||
|
||||
prompt: str
|
||||
|
||||
|
||||
class GuessOutput(BaseModel):
|
||||
@@ -103,47 +94,45 @@ class TurnManager(Executor):
|
||||
async def on_agent_response(
|
||||
self,
|
||||
result: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[HumanFeedbackRequest],
|
||||
ctx: WorkflowContext,
|
||||
) -> None:
|
||||
"""Handle the agent's guess and request human guidance.
|
||||
|
||||
Steps:
|
||||
1) Parse the agent's JSON into GuessOutput for robustness.
|
||||
2) Send a HumanFeedbackRequest to the RequestInfoExecutor with a clear instruction:
|
||||
- higher means the human's secret number is higher than the agent's guess.
|
||||
- lower means the human's secret number is lower than the agent's guess.
|
||||
- correct confirms the guess is exactly right.
|
||||
- exit quits the demo.
|
||||
2) Request info with a HumanFeedbackRequest as the payload.
|
||||
"""
|
||||
# Parse structured model output (defensive default if the agent did not reply).
|
||||
text = result.agent_run_response.text or ""
|
||||
last_guess = GuessOutput.model_validate_json(text).guess if text else None
|
||||
# Parse structured model output
|
||||
text = result.agent_run_response.text
|
||||
last_guess = GuessOutput.model_validate_json(text).guess
|
||||
|
||||
# Craft a precise human prompt that defines higher and lower relative to the agent's guess.
|
||||
prompt = (
|
||||
f"The agent guessed: {last_guess if last_guess is not None else text}. "
|
||||
f"The agent guessed: {last_guess}. "
|
||||
"Type one of: higher (your number is higher than this guess), "
|
||||
"lower (your number is lower than this guess), correct, or exit."
|
||||
)
|
||||
await ctx.send_message(HumanFeedbackRequest(prompt=prompt, guess=last_guess))
|
||||
# Send a request with a prompt as the payload and expect a string reply.
|
||||
await ctx.request_info(
|
||||
request_data=HumanFeedbackRequest(prompt=prompt),
|
||||
request_type=HumanFeedbackRequest,
|
||||
response_type=str,
|
||||
)
|
||||
|
||||
@handler
|
||||
@response_handler
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanFeedbackRequest, str],
|
||||
original_request: HumanFeedbackRequest,
|
||||
feedback: str,
|
||||
ctx: WorkflowContext[AgentExecutorRequest, str],
|
||||
) -> None:
|
||||
"""Continue the game or finish based on human feedback.
|
||||
"""Continue the game or finish based on human feedback."""
|
||||
print(f"Feedback for prompt '{original_request.prompt}' received: {feedback}")
|
||||
|
||||
The RequestResponse contains both the human's string reply and the correlated HumanFeedbackRequest,
|
||||
which carries the prior guess for convenience.
|
||||
"""
|
||||
reply = (feedback.data or "").strip().lower()
|
||||
# Prefer the correlated request's guess to avoid extra shared state reads.
|
||||
last_guess = getattr(feedback.original_request, "guess", None)
|
||||
reply = feedback.strip().lower()
|
||||
|
||||
if reply == "correct":
|
||||
await ctx.yield_output(f"Guessed correctly: {last_guess}")
|
||||
await ctx.yield_output("Guessed correctly!")
|
||||
return
|
||||
|
||||
# Provide feedback to the agent to try again.
|
||||
@@ -166,35 +155,24 @@ async def main() -> None:
|
||||
'You MUST return ONLY a JSON object exactly matching this schema: {"guess": <integer 1..10>}. '
|
||||
"No explanations or additional text."
|
||||
),
|
||||
# Structured output enforced via Pydantic model.
|
||||
response_format=GuessOutput,
|
||||
)
|
||||
|
||||
# Build a simple loop: TurnManager <-> AgentExecutor <-> RequestInfoExecutor.
|
||||
# TurnManager coordinates, AgentExecutor runs the model, RequestInfoExecutor gathers human replies.
|
||||
# Build a simple loop: TurnManager <-> AgentExecutor.
|
||||
# TurnManager coordinates and gathers human replies while AgentExecutor runs the model.
|
||||
turn_manager = TurnManager(id="turn_manager")
|
||||
agent_exec = AgentExecutor(agent=agent, id="agent")
|
||||
|
||||
# Naming note:
|
||||
# This variable is currently named hitl for historical reasons. The name can feel ambiguous or magical.
|
||||
# Consider renaming to request_info_executor in your own code for clarity, since it directly represents
|
||||
# the RequestInfoExecutor node that gathers human replies out of band.
|
||||
hitl = RequestInfoExecutor(id="request_info")
|
||||
|
||||
top_builder = (
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(turn_manager)
|
||||
.add_edge(turn_manager, agent_exec) # Ask agent to make/adjust a guess
|
||||
.add_edge(agent_exec, turn_manager) # Agent's response comes back to coordinator
|
||||
.add_edge(turn_manager, hitl) # Ask human for guidance
|
||||
.add_edge(hitl, turn_manager) # Feed human guidance back to coordinator
|
||||
)
|
||||
|
||||
# Build the workflow (no checkpointing in this minimal sample).
|
||||
workflow = top_builder.build()
|
||||
).build()
|
||||
|
||||
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed = False
|
||||
workflow_output: str | None = None
|
||||
|
||||
# User guidance printing:
|
||||
@@ -206,7 +184,7 @@ async def main() -> None:
|
||||
# flush=True,
|
||||
# )
|
||||
|
||||
while not completed:
|
||||
while workflow_output is None:
|
||||
# First iteration uses run_stream("start").
|
||||
# Subsequent iterations use send_responses_streaming with pending_responses from the console.
|
||||
stream = (
|
||||
@@ -228,7 +206,6 @@ async def main() -> None:
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
# Capture workflow output as they're yielded
|
||||
workflow_output = str(event.data)
|
||||
completed = True # In this sample, we finish after one output.
|
||||
|
||||
# Detect run state transitions for a better developer experience.
|
||||
pending_status = any(
|
||||
@@ -245,7 +222,7 @@ async def main() -> None:
|
||||
print("State: IDLE_WITH_PENDING_REQUESTS (awaiting human input)")
|
||||
|
||||
# If we have any human requests, prompt the user and prepare responses.
|
||||
if requests and not completed:
|
||||
if requests:
|
||||
responses: dict[str, str] = {}
|
||||
for req_id, prompt in requests:
|
||||
# Simple console prompt for the sample.
|
||||
|
||||
@@ -17,7 +17,8 @@ from agent_framework import (
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework.azure import AzureOpenAIChatClient
|
||||
from azure.identity._credentials import AzureCliCredential
|
||||
|
||||
"""
|
||||
Sample: Magentic Orchestration + Checkpointing
|
||||
@@ -29,8 +30,8 @@ checkpoint, and later resume the workflow by feeding in the saved response.
|
||||
Concepts highlighted here:
|
||||
1. **Deterministic executor IDs** - the orchestrator and plan-review request executor
|
||||
must keep stable IDs so the checkpoint state aligns when we rebuild the graph.
|
||||
2. **Executor snapshotting** - checkpoints capture the `RequestInfoExecutor` state,
|
||||
specifically the pending plan-review request map, at superstep boundaries.
|
||||
2. **Executor snapshotting** - checkpoints capture the pending plan-review request
|
||||
map, at superstep boundaries.
|
||||
3. **Resume with responses** - `Workflow.run_stream_from_checkpoint` accepts a
|
||||
`responses` mapping so we can inject the stored human reply during restoration.
|
||||
|
||||
@@ -58,14 +59,14 @@ def build_workflow(checkpoint_storage: FileCheckpointStorage):
|
||||
name="ResearcherAgent",
|
||||
description="Collects background facts and references for the project.",
|
||||
instructions=("You are the research lead. Gather crisp bullet points the team should know."),
|
||||
chat_client=OpenAIChatClient(),
|
||||
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
)
|
||||
|
||||
writer = ChatAgent(
|
||||
name="WriterAgent",
|
||||
description="Synthesizes the final brief for stakeholders.",
|
||||
instructions=("You convert the research notes into a structured brief with milestones and risks."),
|
||||
chat_client=OpenAIChatClient(),
|
||||
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
)
|
||||
|
||||
# The builder wires in the Magentic orchestrator, sets the plan review path, and
|
||||
@@ -75,7 +76,7 @@ def build_workflow(checkpoint_storage: FileCheckpointStorage):
|
||||
.participants(researcher=researcher, writer=writer)
|
||||
.with_plan_review()
|
||||
.with_standard_manager(
|
||||
chat_client=OpenAIChatClient(),
|
||||
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
||||
max_round_count=10,
|
||||
max_stall_count=3,
|
||||
)
|
||||
@@ -135,16 +136,23 @@ async def main() -> None:
|
||||
print("\n=== Stage 2: resume from checkpoint and approve plan ===")
|
||||
resumed_workflow = build_workflow(checkpoint_storage)
|
||||
|
||||
# Construct an approval reply to supply when the plan review request is re-emitted.
|
||||
approval = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
|
||||
# Resume execution and supply the recorded approval in a single call.
|
||||
# `run_stream_from_checkpoint` rebuilds executor state, applies the provided responses,
|
||||
# and then continues the workflow. Because we only captured the initial plan review
|
||||
# checkpoint, the resumed run should complete almost immediately.
|
||||
|
||||
# Resume execution and capture the re-emitted plan review request.
|
||||
request_info_event: RequestInfoEvent | None = None
|
||||
async for event in resumed_workflow.run_stream_from_checkpoint(resume_checkpoint.checkpoint_id):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, MagenticPlanReviewRequest):
|
||||
request_info_event = event
|
||||
|
||||
if request_info_event is None:
|
||||
print("No plan review request re-emitted on resume; cannot approve.")
|
||||
return
|
||||
print(f"Resumed plan review request: {request_info_event.request_id}")
|
||||
|
||||
# Supply the approval and continue to run to completion.
|
||||
final_event: WorkflowOutputEvent | None = None
|
||||
async for event in resumed_workflow.run_stream_from_checkpoint(
|
||||
resume_checkpoint.checkpoint_id,
|
||||
responses={plan_review_request_id: approval},
|
||||
):
|
||||
async for event in resumed_workflow.send_responses_streaming({request_info_event.request_id: approval}):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
final_event = event
|
||||
|
||||
@@ -204,10 +212,7 @@ async def main() -> None:
|
||||
final_event_post: WorkflowOutputEvent | None = None
|
||||
post_emitted_events = False
|
||||
post_plan_workflow = build_workflow(checkpoint_storage)
|
||||
async for event in post_plan_workflow.run_stream_from_checkpoint(
|
||||
post_plan_checkpoint.checkpoint_id,
|
||||
responses={},
|
||||
):
|
||||
async for event in post_plan_workflow.run_stream_from_checkpoint(post_plan_checkpoint.checkpoint_id):
|
||||
post_emitted_events = True
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
final_event_post = event
|
||||
|
||||
Generated
+3430
-3390
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user