mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [Breaking] Remove WorkflowCompletedEvent, introduce workflow output and migrate to ctx.yield_output() + a huge refactoring (#845)
* Introduce input and output types for executor and workflow * WorkflowOutputContext handles two types * Remove can_handle_types from Executor * Update validation * Move workflow executor * Move workflow executor * Fix issues in WorkflowExecutor * refactor executor * update execute signature to create workflow context within Executor * fix simple sub workflow test; fix validation * fix output types in WorkflowExecutor * fix issue in Executor handling of SubWorkflowRequestInfo * update tests to use proper workflow output * update orchestration patterns to use output * Update sample -- not finished * Update python/packages/main/tests/workflow/test_workflow_states.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/packages/main/tests/workflow/test_concurrent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address comments * WorkflowOutputContext --> WorkflowContext * remove WorkflowCompletedEvent * update samples * Update doc string for important classes; update WorkflowExecutor to support concurrent execution * use Never instead of None for default type * Update usage of WorkflowContext[None to WorkflowContext[Never * address comments * remove filter for None * address comments, minor fixes * quality of life improvement on interceptor types --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
0f913bcdeb
commit
2133043f11
@@ -23,11 +23,11 @@ from typing import Literal
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
|
||||
class DataType(Enum):
|
||||
@@ -315,7 +315,9 @@ class ValidationAggregator(Executor):
|
||||
"""Aggregates validation results and decides on next steps."""
|
||||
|
||||
@handler
|
||||
async def aggregate_validations(self, reports: list[ValidationReport], ctx: WorkflowContext[DataBatch]) -> None:
|
||||
async def aggregate_validations(
|
||||
self, reports: list[ValidationReport], ctx: WorkflowContext[DataBatch, str]
|
||||
) -> None:
|
||||
"""Aggregate all validation reports and make processing decision."""
|
||||
if not reports:
|
||||
return
|
||||
@@ -345,11 +347,9 @@ class ValidationAggregator(Executor):
|
||||
)
|
||||
|
||||
reason = " and ".join(failure_reason)
|
||||
await ctx.add_event(
|
||||
WorkflowCompletedEvent(
|
||||
f"Batch {batch_id} failed validation: {reason}. "
|
||||
f"Total issues: {total_issues}, Quality score: {quality_score:.2f}"
|
||||
)
|
||||
await ctx.yield_output(
|
||||
f"Batch {batch_id} failed validation: {reason}. "
|
||||
f"Total issues: {total_issues}, Quality score: {quality_score:.2f}"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -584,10 +584,12 @@ class FinalProcessor(Executor):
|
||||
"""Final processing stage that combines all results."""
|
||||
|
||||
@handler
|
||||
async def process_final_results(self, assessments: list[QualityAssessment], ctx: WorkflowContext[None]) -> None:
|
||||
async def process_final_results(
|
||||
self, assessments: list[QualityAssessment], ctx: WorkflowContext[Never, str]
|
||||
) -> None:
|
||||
"""Generate final processing summary and complete workflow."""
|
||||
if not assessments:
|
||||
await ctx.add_event(WorkflowCompletedEvent("No quality assessments received"))
|
||||
await ctx.yield_output("No quality assessments received")
|
||||
return
|
||||
|
||||
batch_id = assessments[0].batch_id
|
||||
@@ -618,7 +620,7 @@ class FinalProcessor(Executor):
|
||||
f"🎖️ Final Status: {final_status}"
|
||||
)
|
||||
|
||||
await ctx.add_event(WorkflowCompletedEvent(completion_message))
|
||||
await ctx.yield_output(completion_message)
|
||||
|
||||
|
||||
# Workflow Builder Helper
|
||||
|
||||
@@ -24,11 +24,11 @@ from agent_framework import (
|
||||
Default,
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -259,7 +259,7 @@ class FinalProcessor(Executor):
|
||||
async def handle_processing_result(
|
||||
self,
|
||||
result: ProcessingResult,
|
||||
ctx: WorkflowContext[None],
|
||||
ctx: WorkflowContext[Never, str],
|
||||
) -> None:
|
||||
"""Complete the workflow with final processing and logging."""
|
||||
await asyncio.sleep(1.5) # Simulate final processing time
|
||||
@@ -278,7 +278,7 @@ class FinalProcessor(Executor):
|
||||
f"Total time: {total_time:.1f}s"
|
||||
)
|
||||
|
||||
await ctx.add_event(WorkflowCompletedEvent(completion_message))
|
||||
await ctx.yield_output(completion_message)
|
||||
|
||||
|
||||
# Create the workflow instance that DevUI can discover
|
||||
|
||||
@@ -33,12 +33,12 @@ from ._events import (
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokedEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowLifecycleEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
@@ -53,7 +53,6 @@ from ._executor import (
|
||||
RequestResponse,
|
||||
SubWorkflowRequestInfo,
|
||||
SubWorkflowResponse,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
intercepts_request,
|
||||
)
|
||||
@@ -92,7 +91,6 @@ from ._validation import (
|
||||
EdgeDuplicationError,
|
||||
ExecutorDuplicationError,
|
||||
GraphConnectivityError,
|
||||
HandlerOutputAnnotationError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
WorkflowValidationError,
|
||||
@@ -101,6 +99,7 @@ from ._validation import (
|
||||
from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
@@ -126,7 +125,6 @@ __all__ = [
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
"GraphConnectivityError",
|
||||
"HandlerOutputAnnotationError",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"MagenticAgentDeltaEvent",
|
||||
@@ -170,7 +168,6 @@ __all__ = [
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
@@ -178,6 +175,7 @@ __all__ = [
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowLifecycleEvent",
|
||||
"WorkflowOutputEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
|
||||
@@ -29,12 +29,12 @@ from ._events import (
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokedEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowLifecycleEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
@@ -49,7 +49,6 @@ from ._executor import (
|
||||
RequestResponse,
|
||||
SubWorkflowRequestInfo,
|
||||
SubWorkflowResponse,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
intercepts_request,
|
||||
)
|
||||
@@ -88,7 +87,6 @@ from ._validation import (
|
||||
EdgeDuplicationError,
|
||||
ExecutorDuplicationError,
|
||||
GraphConnectivityError,
|
||||
HandlerOutputAnnotationError,
|
||||
TypeCompatibilityError,
|
||||
ValidationTypeEnum,
|
||||
WorkflowValidationError,
|
||||
@@ -97,6 +95,7 @@ from ._validation import (
|
||||
from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
@@ -122,7 +121,6 @@ __all__ = [
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
"GraphConnectivityError",
|
||||
"HandlerOutputAnnotationError",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"MagenticAgentDeltaEvent",
|
||||
@@ -166,7 +164,6 @@ __all__ = [
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
@@ -174,6 +171,7 @@ __all__ = [
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowLifecycleEvent",
|
||||
"WorkflowOutputEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
|
||||
@@ -79,7 +79,7 @@ class WorkflowAgent(BaseAgent):
|
||||
except KeyError as exc: # Defensive: workflow lacks a configured entry point
|
||||
raise ValueError("Workflow's start executor is not defined.") from exc
|
||||
|
||||
if not start_executor.can_handle_type(list[ChatMessage]):
|
||||
if list[ChatMessage] not in start_executor.input_types:
|
||||
raise ValueError("Workflow's start executor cannot handle list[ChatMessage]")
|
||||
|
||||
super().__init__(id=id, name=name, description=description, **kwargs)
|
||||
|
||||
@@ -6,9 +6,10 @@ import logging
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage, Role
|
||||
|
||||
from ._events import WorkflowCompletedEvent
|
||||
from ._executor import AgentExecutorRequest, AgentExecutorResponse, Executor, handler
|
||||
from ._workflow import Workflow, WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -25,12 +26,14 @@ parallel workflow with:
|
||||
Notes:
|
||||
- Participants should be AgentProtocol instances or Executors.
|
||||
- A custom aggregator can be provided as:
|
||||
- an Executor instance (it should handle list[AgentExecutorResponse] and add a WorkflowCompletedEvent), or
|
||||
- an Executor instance (it should handle list[AgentExecutorResponse],
|
||||
yield output), or
|
||||
- a callback function with signature:
|
||||
def cb(results: list[AgentExecutorResponse]) -> Any | None
|
||||
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None
|
||||
If the callback returns a non-None value, it is sent as the data of a WorkflowCompletedEvent.
|
||||
If it returns None, the callback may have already emitted a completion event via ctx.
|
||||
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None
|
||||
The callback is wrapped in _CallbackAggregator.
|
||||
If the callback returns a non-None value, _CallbackAggregator yields that as output.
|
||||
If it returns None, the callback may have already yielded an output via ctx, so no further action is taken.
|
||||
"""
|
||||
|
||||
|
||||
@@ -70,7 +73,9 @@ class _AggregateAgentConversations(Executor):
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
|
||||
async def aggregate(
|
||||
self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[ChatMessage]]
|
||||
) -> None:
|
||||
if not results:
|
||||
logger.error("Concurrent aggregator received empty results list")
|
||||
raise ValueError("Aggregation failed: no results provided")
|
||||
@@ -128,7 +133,7 @@ class _AggregateAgentConversations(Executor):
|
||||
logger.warning("No user prompt found in any conversation; emitting assistants only")
|
||||
output.extend(assistant_replies)
|
||||
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=output))
|
||||
await ctx.yield_output(output)
|
||||
|
||||
|
||||
class _CallbackAggregator(Executor):
|
||||
@@ -141,7 +146,7 @@ class _CallbackAggregator(Executor):
|
||||
Notes:
|
||||
- Async callbacks are awaited directly.
|
||||
- Sync callbacks are executed via asyncio.to_thread to avoid blocking the event loop.
|
||||
- If the callback returns a non-None value, it is wrapped in a WorkflowCompletedEvent.
|
||||
- If the callback returns a non-None value, it is yielded as an output.
|
||||
"""
|
||||
|
||||
def __init__(self, callback: Callable[..., Any], id: str | None = None) -> None:
|
||||
@@ -153,7 +158,7 @@ class _CallbackAggregator(Executor):
|
||||
self._param_count = len(inspect.signature(callback).parameters)
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, Any]) -> None:
|
||||
# Call according to provided signature, always non-blocking for sync callbacks
|
||||
if self._param_count >= 2:
|
||||
if inspect.iscoroutinefunction(self._callback):
|
||||
@@ -168,7 +173,7 @@ class _CallbackAggregator(Executor):
|
||||
|
||||
# If the callback returned a value, finalize the workflow with it
|
||||
if ret is not None:
|
||||
await ctx.add_event(WorkflowCompletedEvent(ret))
|
||||
await ctx.yield_output(ret)
|
||||
|
||||
|
||||
class ConcurrentBuilder:
|
||||
@@ -187,8 +192,7 @@ class ConcurrentBuilder:
|
||||
|
||||
|
||||
# Custom aggregator via callback (sync or async). The callback receives
|
||||
# list[AgentExecutorResponse] and its return value becomes
|
||||
# WorkflowCompletedEvent.data
|
||||
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
|
||||
def summarize(results):
|
||||
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
|
||||
|
||||
@@ -245,13 +249,13 @@ class ConcurrentBuilder:
|
||||
def with_aggregator(self, aggregator: Executor | Callable[..., Any]) -> "ConcurrentBuilder":
|
||||
r"""Override the default aggregator with an Executor or a callback.
|
||||
|
||||
- Executor: must handle `list[AgentExecutorResponse]` and add a
|
||||
`WorkflowCompletedEvent` to the context.
|
||||
- Executor: must handle `list[AgentExecutorResponse]` and
|
||||
yield output using `ctx.yield_output(...)` and add a
|
||||
output and the workflow becomes idle.
|
||||
- Callback: sync or async callable with one of the signatures:
|
||||
`(results: list[AgentExecutorResponse]) -> Any | None` or
|
||||
`(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None`.
|
||||
If the callback returns a non-None value, it becomes the
|
||||
`WorkflowCompletedEvent.data`.
|
||||
`(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None`.
|
||||
If the callback returns a non-None value, it becomes the workflow's output.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -277,7 +281,7 @@ class ConcurrentBuilder:
|
||||
Wiring pattern:
|
||||
- Dispatcher (internal) fans out the input to all `participants`
|
||||
- Fan-in aggregator collects `AgentExecutorResponse` objects
|
||||
- Aggregator emits a `WorkflowCompletedEvent` with either:
|
||||
- Aggregator yields output and the workflow becomes idle. The output is either:
|
||||
- list[ChatMessage] (default aggregator: one user + one assistant per agent)
|
||||
- custom payload from the provided callback/executor
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from ._edge import Edge, EdgeGroup, FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeG
|
||||
from ._executor import Executor
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,19 +63,16 @@ class EdgeRunner(ABC):
|
||||
|
||||
target_executor = self._executors[target_id]
|
||||
|
||||
# Create WorkflowContext with trace contexts from message
|
||||
workflow_context: WorkflowContext[Any] = WorkflowContext(
|
||||
target_id,
|
||||
source_ids,
|
||||
shared_state,
|
||||
ctx,
|
||||
trace_contexts=message.trace_contexts, # Pass trace contexts to WorkflowContext
|
||||
# Execute with trace context parameters
|
||||
await target_executor.execute(
|
||||
message.data,
|
||||
source_ids, # source_executor_ids
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
trace_contexts=message.trace_contexts, # Pass trace contexts
|
||||
source_span_ids=message.source_span_ids, # Pass source span IDs for linking
|
||||
)
|
||||
|
||||
# Execute with trace context in WorkflowContext
|
||||
await target_executor.execute(message.data, workflow_context)
|
||||
|
||||
|
||||
class SingleEdgeRunner(EdgeRunner):
|
||||
"""Runner for single edge groups."""
|
||||
|
||||
@@ -66,16 +66,6 @@ class WorkflowStartedEvent(WorkflowEvent):
|
||||
...
|
||||
|
||||
|
||||
class WorkflowCompletedEvent(WorkflowEvent):
|
||||
"""Built-in lifecycle event emitted when a workflow run completes successfully.
|
||||
|
||||
Unlike the framework-only `WorkflowLifecycleEvent` union, this event can be
|
||||
emitted by developer-provided executors to return final workflow output.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class WorkflowWarningEvent(WorkflowEvent):
|
||||
"""Executor-origin event signaling a warning surfaced by user code."""
|
||||
|
||||
@@ -120,16 +110,14 @@ class WorkflowRunState(str, Enum):
|
||||
request-for-information operations are outstanding. New work may still
|
||||
be scheduled while requests are in flight.
|
||||
|
||||
- IDLE: The workflow is quiescent with no outstanding requests, but has
|
||||
not yet emitted a terminal result. Rare in practice but provided for
|
||||
orchestration integrations that distinguish a quiescent state.
|
||||
- IDLE: The workflow is quiescent with no outstanding requests and no more
|
||||
work to do. This is the normal terminal state for workflows that have
|
||||
finished executing, potentially having produced outputs along the way.
|
||||
|
||||
- IDLE_WITH_PENDING_REQUESTS: The workflow is paused awaiting external
|
||||
input (e.g., emitted a `RequestInfoEvent`). This is a non-terminal
|
||||
state; the workflow can resume when responses are supplied.
|
||||
|
||||
- COMPLETED: Normal terminal state indicating successful completion.
|
||||
|
||||
- FAILED: Terminal state indicating an error surfaced. Accompanied by a
|
||||
`WorkflowFailedEvent` with structured error details.
|
||||
|
||||
@@ -143,7 +131,6 @@ class WorkflowRunState(str, Enum):
|
||||
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS" # Active execution with outstanding requests
|
||||
IDLE = "IDLE" # No active work and no outstanding requests
|
||||
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS" # Paused awaiting external responses
|
||||
COMPLETED = "COMPLETED" # Finished successfully
|
||||
FAILED = "FAILED" # Finished with an error
|
||||
CANCELLED = "CANCELLED" # Finished due to cancellation
|
||||
|
||||
@@ -250,6 +237,28 @@ class RequestInfoEvent(WorkflowEvent):
|
||||
)
|
||||
|
||||
|
||||
class WorkflowOutputEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow executor yields output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: Any,
|
||||
source_executor_id: str,
|
||||
):
|
||||
"""Initialize the workflow output event.
|
||||
|
||||
Args:
|
||||
data: The output yielded by the executor.
|
||||
source_executor_id: ID of the executor that yielded the output.
|
||||
"""
|
||||
super().__init__(data)
|
||||
self.source_executor_id = source_executor_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string representation of the workflow output event."""
|
||||
return f"{self.__class__.__name__}(data={self.data}, source_executor_id={self.source_executor_id})"
|
||||
|
||||
|
||||
class ExecutorEvent(WorkflowEvent):
|
||||
"""Base class for executor events."""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,67 +11,11 @@ This module provides:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from types import UnionType
|
||||
from typing import Any, Union, get_args, get_origin, overload
|
||||
from typing import Any, overload
|
||||
|
||||
from ._executor import Executor
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
def _is_workflow_context_type(annotation: Any) -> bool:
|
||||
"""Check if an annotation represents WorkflowContext[T]."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is WorkflowContext:
|
||||
return True
|
||||
# Also handle the case where the raw WorkflowContext class is used
|
||||
return annotation is WorkflowContext
|
||||
|
||||
|
||||
def _infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> list[type]:
|
||||
"""Infer output types list from the WorkflowContext generic parameter.
|
||||
|
||||
Examples:
|
||||
- WorkflowContext[str] -> [str]
|
||||
- WorkflowContext[str | int] -> [str, int]
|
||||
- WorkflowContext[Union[str, int]] -> [str, int]
|
||||
- WorkflowContext[Any] -> [] (unknown)
|
||||
- WorkflowContext[None] -> []
|
||||
"""
|
||||
# If no annotation or not parameterized, return empty list
|
||||
try:
|
||||
origin = get_origin(ctx_annotation)
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
# If annotation is unsubscripted WorkflowContext, nothing to infer
|
||||
if origin is None:
|
||||
return []
|
||||
|
||||
# Expecting WorkflowContext[T]
|
||||
if origin is not WorkflowContext:
|
||||
return []
|
||||
|
||||
args = get_args(ctx_annotation)
|
||||
if not args:
|
||||
return []
|
||||
|
||||
t = args[0]
|
||||
# If t is a Union, flatten it
|
||||
t_origin = get_origin(t)
|
||||
# If Any, treat as unknown -> no output types inferred
|
||||
if t is Any:
|
||||
return []
|
||||
|
||||
if t_origin in (Union, UnionType):
|
||||
# Return all union args as-is (may include generic aliases like list[str])
|
||||
return [arg for arg in get_args(t) if arg is not Any and arg is not type(None)]
|
||||
|
||||
# Single concrete or generic alias type (e.g., str, int, list[str])
|
||||
if t is Any or t is type(None):
|
||||
return []
|
||||
return [t]
|
||||
from ._workflow_context import WorkflowContext, validate_function_signature
|
||||
|
||||
|
||||
class FunctionExecutor(Executor):
|
||||
@@ -85,61 +29,19 @@ class FunctionExecutor(Executor):
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _validate_function(func: Callable[..., Any]) -> None:
|
||||
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
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
if len(params) not in (1, 2):
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} must have one or two parameters: "
|
||||
f"(message: T) or (message: T, ctx: WorkflowContext[U]). Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
message_param = params[0]
|
||||
|
||||
# Check message parameter has type annotation
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
# If there's a second parameter, validate it's WorkflowContext[T]
|
||||
if len(params) == 2:
|
||||
ctx_param = params[1]
|
||||
|
||||
# Check ctx parameter has proper type annotation
|
||||
if ctx_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function {func.__name__} second parameter must be annotated as WorkflowContext[T]")
|
||||
|
||||
# Validate that ctx parameter is WorkflowContext[T]
|
||||
if not _is_workflow_context_type(ctx_param.annotation):
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} second parameter must be annotated as WorkflowContext[T], "
|
||||
f"got {ctx_param.annotation}"
|
||||
)
|
||||
|
||||
# Check that WorkflowContext has a concrete type parameter
|
||||
if ctx_param.annotation is WorkflowContext:
|
||||
# This is unparameterized WorkflowContext
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} WorkflowContext must be parameterized with a concrete T. "
|
||||
f"Use WorkflowContext[str], WorkflowContext[int], etc."
|
||||
)
|
||||
|
||||
if hasattr(ctx_param.annotation, "__args__") and ctx_param.annotation.__args__:
|
||||
# This is WorkflowContext[T] with a concrete T
|
||||
pass
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} WorkflowContext must be parameterized with a concrete T. "
|
||||
f"Use WorkflowContext[str], WorkflowContext[int], etc."
|
||||
)
|
||||
return validate_function_signature(func, "Function")
|
||||
|
||||
def __init__(self, func: Callable[..., Any], id: str | None = None):
|
||||
"""Initialize the FunctionExecutor with a user-defined function.
|
||||
@@ -148,27 +50,13 @@ class FunctionExecutor(Executor):
|
||||
func: The function to wrap as an executor (can be sync or async)
|
||||
id: Optional executor ID. If None, uses the function name.
|
||||
"""
|
||||
# Validate function signature first
|
||||
self._validate_function(func)
|
||||
|
||||
# Extract types from function signature
|
||||
signature = inspect.signature(func)
|
||||
params = list(signature.parameters.values())
|
||||
|
||||
message_type = params[0].annotation
|
||||
# Validate function signature and extract types
|
||||
message_type, ctx_annotation, output_types, workflow_output_types = self._validate_function(func)
|
||||
|
||||
# Determine if function has WorkflowContext parameter
|
||||
has_context = len(params) == 2
|
||||
has_context = ctx_annotation is not None
|
||||
is_async = asyncio.iscoroutinefunction(func)
|
||||
|
||||
if has_context:
|
||||
ctx_annotation = params[1].annotation
|
||||
output_types = _infer_output_types_from_ctx_annotation(ctx_annotation)
|
||||
else:
|
||||
# For single-parameter functions, we can't infer output types
|
||||
ctx_annotation = None
|
||||
output_types = []
|
||||
|
||||
# Initialize parent WITHOUT calling _discover_handlers yet
|
||||
# We'll manually set up the attributes first
|
||||
executor_id = id or getattr(func, "__name__", "FunctionExecutor")
|
||||
@@ -181,7 +69,7 @@ class FunctionExecutor(Executor):
|
||||
|
||||
self._handlers: dict[type, Callable[[Any, WorkflowContext[Any]], Any]] = {}
|
||||
self._request_interceptors: dict[type | str, list[dict[str, Any]]] = {}
|
||||
self._instance_handler_specs: list[dict[str, Any]] = []
|
||||
self._handler_specs: list[dict[str, Any]] = []
|
||||
|
||||
# Store the original function and whether it has context
|
||||
self._original_func = func
|
||||
@@ -211,12 +99,13 @@ class FunctionExecutor(Executor):
|
||||
return await asyncio.to_thread(func, message) # type: ignore
|
||||
|
||||
# Now register our instance handler
|
||||
self.register_instance_handler(
|
||||
self._register_instance_handler(
|
||||
name=func.__name__,
|
||||
func=wrapped_func,
|
||||
message_type=message_type,
|
||||
ctx_annotation=ctx_annotation,
|
||||
output_types=output_types,
|
||||
workflow_output_types=workflow_output_types,
|
||||
)
|
||||
|
||||
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
|
||||
|
||||
@@ -28,7 +28,7 @@ from agent_framework import (
|
||||
from agent_framework._agents import BaseAgent
|
||||
from agent_framework._pydantic import AFBaseModel
|
||||
|
||||
from ._events import WorkflowCompletedEvent, WorkflowEvent
|
||||
from ._events import WorkflowEvent
|
||||
from ._executor import Executor, RequestInfoMessage, RequestResponse, handler
|
||||
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -840,7 +840,9 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
async def handle_start_message(
|
||||
self,
|
||||
message: MagenticStartMessage,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest],
|
||||
context: WorkflowContext[
|
||||
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage
|
||||
],
|
||||
) -> None:
|
||||
"""Handle the initial start message to begin orchestration."""
|
||||
if getattr(self, "_terminated", False):
|
||||
@@ -877,7 +879,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
# Start the inner loop
|
||||
ctx2 = cast(
|
||||
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
context,
|
||||
)
|
||||
await self._run_inner_loop(ctx2)
|
||||
@@ -886,7 +888,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
async def handle_response_message(
|
||||
self,
|
||||
message: MagenticResponseMessage,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> None:
|
||||
"""Handle responses from agents."""
|
||||
if getattr(self, "_terminated", False):
|
||||
@@ -916,7 +918,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
response: RequestResponse[MagenticPlanReviewRequest, MagenticPlanReviewReply],
|
||||
context: WorkflowContext[
|
||||
# may broadcast ledger next, or ask for another round of review
|
||||
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest
|
||||
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage
|
||||
],
|
||||
) -> None:
|
||||
if getattr(self, "_terminated", False):
|
||||
@@ -968,7 +970,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
# Enter the normal coordination loop
|
||||
ctx2 = cast(
|
||||
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
context,
|
||||
)
|
||||
await self._run_inner_loop(ctx2)
|
||||
@@ -997,7 +999,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
self._context.chat_history.append(self._task_ledger)
|
||||
# No further review requests; proceed directly into coordination
|
||||
ctx2 = cast(
|
||||
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
context,
|
||||
)
|
||||
await self._run_inner_loop(ctx2)
|
||||
@@ -1032,7 +1034,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
async def _run_outer_loop(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> None:
|
||||
"""Run the outer orchestration loop - planning phase."""
|
||||
if self._context is None:
|
||||
@@ -1056,7 +1058,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
async def _run_inner_loop(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> None:
|
||||
"""Run the inner orchestration loop. Coordination phase. Serialized with a lock."""
|
||||
if self._context is None or self._task_ledger is None:
|
||||
@@ -1066,7 +1068,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
async def _run_inner_loop_locked(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> None:
|
||||
"""Run inner loop with exclusive access."""
|
||||
# Narrow optional context for the remainder of this method
|
||||
@@ -1154,7 +1156,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
async def _reset_and_replan(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> None:
|
||||
"""Reset context and replan."""
|
||||
if self._context is None:
|
||||
@@ -1178,7 +1180,7 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
async def _prepare_final_answer(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> None:
|
||||
"""Prepare the final answer using the manager."""
|
||||
if self._context is None:
|
||||
@@ -1188,14 +1190,14 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
final_answer = await self._manager.prepare_final_answer(self._context.model_copy(deep=True))
|
||||
|
||||
# Emit a completed event for the workflow
|
||||
await context.add_event(WorkflowCompletedEvent(final_answer))
|
||||
await context.yield_output(final_answer)
|
||||
|
||||
if self._result_callback:
|
||||
await self._result_callback(final_answer)
|
||||
|
||||
async def _check_within_limits_or_complete(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
|
||||
) -> bool:
|
||||
"""Check if orchestrator is within operational limits."""
|
||||
if self._context is None:
|
||||
@@ -1221,8 +1223,8 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
author_name=MAGENTIC_MANAGER_NAME,
|
||||
)
|
||||
|
||||
# Emit a completed event with the partial result
|
||||
await context.add_event(WorkflowCompletedEvent(partial_result))
|
||||
# Yield the partial result and signal completion
|
||||
await context.yield_output(partial_result)
|
||||
|
||||
if self._result_callback:
|
||||
await self._result_callback(partial_result)
|
||||
@@ -1232,7 +1234,9 @@ class MagenticOrchestratorExecutor(Executor):
|
||||
|
||||
async def _send_plan_review_request(
|
||||
self,
|
||||
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest],
|
||||
context: WorkflowContext[
|
||||
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage
|
||||
],
|
||||
) -> None:
|
||||
"""Emit a PlanReviewRequest via RequestInfoExecutor."""
|
||||
# If plan sign-off is disabled (e.g., ran out of review rounds), do nothing
|
||||
|
||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from ._edge import EdgeGroup
|
||||
from ._edge_runner import EdgeRunner, create_edge_runner
|
||||
from ._events import WorkflowCompletedEvent, WorkflowEvent, _framework_event_origin
|
||||
from ._events import WorkflowEvent, WorkflowOutputEvent, _framework_event_origin
|
||||
from ._executor import Executor
|
||||
from ._runner_context import (
|
||||
_DATACLASS_MARKER,
|
||||
@@ -23,7 +23,6 @@ from ._runner_context import (
|
||||
)
|
||||
from ._shared_state import SharedState
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -204,16 +203,14 @@ class Runner:
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' "
|
||||
f"to executor '{executor.id}' for interception."
|
||||
)
|
||||
# Create WorkflowContext with trace context from message
|
||||
workflow_ctx: WorkflowContext[Any] = WorkflowContext(
|
||||
executor.id,
|
||||
[message.source_id],
|
||||
self._shared_state,
|
||||
self._ctx,
|
||||
await executor.execute(
|
||||
sub_request,
|
||||
[message.source_id], # source_executor_ids
|
||||
self._shared_state, # shared_state
|
||||
self._ctx, # runner_context
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
await executor.execute(sub_request, workflow_ctx)
|
||||
interceptor_found = True
|
||||
break
|
||||
if interceptor_found:
|
||||
@@ -226,20 +223,19 @@ class Runner:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
|
||||
if request_info_executor:
|
||||
request_info_workflow_ctx: WorkflowContext[None] = WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[message.source_id],
|
||||
self._shared_state,
|
||||
self._ctx,
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
logger.info(
|
||||
f"Sending sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' to RequestInfoExecutor "
|
||||
f"'{request_info_executor.id}'"
|
||||
)
|
||||
await request_info_executor.execute(sub_request, request_info_workflow_ctx)
|
||||
await request_info_executor.execute(
|
||||
sub_request,
|
||||
[message.source_id], # source_executor_ids
|
||||
self._shared_state, # shared_state
|
||||
self._ctx, # runner_context
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
@@ -303,8 +299,9 @@ class Runner:
|
||||
final_messages = message.data.agent_run_response.messages
|
||||
final_text = final_messages[-1].text if final_messages else "(no content)"
|
||||
with _framework_event_origin():
|
||||
completion_event = WorkflowCompletedEvent(final_text)
|
||||
await self._ctx.add_event(completion_event)
|
||||
# TODO(moonbox3): does user expect this event to contain the final text?
|
||||
output_event = WorkflowOutputEvent(data=final_text, source_executor_id="<Runner>")
|
||||
await self._ctx.add_event(output_event)
|
||||
continue # Terminal handled
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Suppressed exception during terminal message type check: %s", exc)
|
||||
@@ -326,8 +323,9 @@ class Runner:
|
||||
final_messages = message.data.agent_run_response.messages
|
||||
final_text = final_messages[-1].text if final_messages else "(no content)"
|
||||
with _framework_event_origin():
|
||||
completion_event = WorkflowCompletedEvent(final_text)
|
||||
await self._ctx.add_event(completion_event)
|
||||
# TODO(moonbox3): does user expect this event to contain the final text?
|
||||
output_event = WorkflowOutputEvent(data=final_text, source_executor_id="<Runner>")
|
||||
await self._ctx.add_event(output_event)
|
||||
continue
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.debug("Terminal completion emission failed: %s", exc)
|
||||
|
||||
@@ -8,10 +8,10 @@ workflow where:
|
||||
- A shared conversation context (list[ChatMessage]) is passed along the chain
|
||||
- Agents append their assistant messages to the context
|
||||
- Custom executors can transform or summarize and return a refined context
|
||||
- The workflow completes with the final context produced by the last participant
|
||||
- The workflow finishes with the final context produced by the last participant
|
||||
|
||||
Typical wiring:
|
||||
input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _CompleteWithConversation
|
||||
input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _EndWithConversation
|
||||
|
||||
Notes:
|
||||
- Participants can mix AgentProtocol and Executor objects
|
||||
@@ -27,9 +27,8 @@ Why include the small internal adapter executors?
|
||||
- Agent response adaptation ("to-conversation:<participant>"): agents (via AgentExecutor)
|
||||
emit `AgentExecutorResponse`. The adapter converts that to a `list[ChatMessage]`
|
||||
using `full_conversation` so original prompts aren't lost when chaining.
|
||||
- Explicit completion ("complete"): emits a `WorkflowCompletedEvent` with the final
|
||||
conversation list, giving a consistent terminal payload shape for both agents and
|
||||
custom executors.
|
||||
- Result output ("end"): yields the final conversation list and the workflow becomes idle
|
||||
giving a consistent terminal payload shape for both agents and custom executors.
|
||||
|
||||
These adapters are first-class executors by design so they are type-checked at edges,
|
||||
observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are
|
||||
@@ -43,7 +42,6 @@ from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage, Role
|
||||
|
||||
from ._events import WorkflowCompletedEvent
|
||||
from ._executor import (
|
||||
AgentExecutor,
|
||||
AgentExecutorResponse,
|
||||
@@ -84,12 +82,12 @@ class _ResponseToConversation(Executor):
|
||||
await ctx.send_message(list(response.full_conversation))
|
||||
|
||||
|
||||
class _CompleteWithConversation(Executor):
|
||||
class _EndWithConversation(Executor):
|
||||
"""Terminates the workflow by emitting the final conversation context."""
|
||||
|
||||
@handler
|
||||
async def complete(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any]) -> None:
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=list(conversation)))
|
||||
async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, list[ChatMessage]]) -> None:
|
||||
await ctx.yield_output(list(conversation))
|
||||
|
||||
|
||||
class SequentialBuilder:
|
||||
@@ -148,14 +146,14 @@ class SequentialBuilder:
|
||||
- If Agent (or AgentExecutor): pass conversation to the agent, then convert response
|
||||
to conversation via _ResponseToConversation
|
||||
- Else (custom Executor): pass conversation directly to the executor
|
||||
- _CompleteWithConversation emits WorkflowCompletedEvent with the final conversation
|
||||
- _EndWithConversation yields the final conversation and the workflow becomes idle
|
||||
"""
|
||||
if not self._participants:
|
||||
raise ValueError("No participants provided. Call .participants([...]) first.")
|
||||
|
||||
# Internal nodes
|
||||
input_conv = _InputToConversation(id="input-conversation")
|
||||
complete = _CompleteWithConversation(id="complete")
|
||||
end = _EndWithConversation(id="end")
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.set_start_executor(input_conv)
|
||||
@@ -182,6 +180,6 @@ class SequentialBuilder:
|
||||
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
|
||||
|
||||
# Terminate with the final conversation
|
||||
builder.add_edge(prior, complete)
|
||||
builder.add_edge(prior, end)
|
||||
|
||||
return builder.build()
|
||||
|
||||
@@ -9,26 +9,12 @@ from types import UnionType
|
||||
from typing import Any, Union, get_args, get_origin
|
||||
|
||||
from ._edge import Edge, EdgeGroup, FanInEdgeGroup
|
||||
from ._executor import Executor
|
||||
from ._executor import Executor, RequestInfoExecutor
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_type_like(x: Any) -> bool:
|
||||
"""Check if a value is a type-like entity.
|
||||
|
||||
A "type-like" entry is either a class/type or a typing alias
|
||||
(e.g., list[str] has an origin and args).
|
||||
|
||||
Args:
|
||||
x: The value to check
|
||||
|
||||
Returns:
|
||||
True if the value is type-like, False otherwise
|
||||
"""
|
||||
return isinstance(x, type) or get_origin(x) is not None
|
||||
|
||||
|
||||
# region Enums and Base Classes
|
||||
class ValidationTypeEnum(Enum):
|
||||
"""Enumeration of workflow validation types."""
|
||||
@@ -108,23 +94,6 @@ class GraphConnectivityError(WorkflowValidationError):
|
||||
super().__init__(message, validation_type=ValidationTypeEnum.GRAPH_CONNECTIVITY)
|
||||
|
||||
|
||||
class HandlerOutputAnnotationError(WorkflowValidationError):
|
||||
"""Exception raised when a handler's WorkflowContext output annotation is invalid or missing."""
|
||||
|
||||
def __init__(self, executor_id: str, handler_name: str, reason: str):
|
||||
super().__init__(
|
||||
message=(
|
||||
"Invalid WorkflowContext output annotation in handler "
|
||||
f"'{handler_name}' of executor '{executor_id}': {reason}. "
|
||||
"Handlers must annotate their third parameter as WorkflowContext[T]. "
|
||||
"Use WorkflowContext[None] if the handler emits no messages."
|
||||
),
|
||||
validation_type=ValidationTypeEnum.HANDLER_OUTPUT_ANNOTATION,
|
||||
)
|
||||
self.executor_id = executor_id
|
||||
self.handler_name = handler_name
|
||||
|
||||
|
||||
class InterceptorConflictError(WorkflowValidationError):
|
||||
"""Exception raised when multiple executors intercept the same request type from the same sub-workflow."""
|
||||
|
||||
@@ -216,7 +185,6 @@ class WorkflowGraphValidator:
|
||||
self._validate_type_compatibility()
|
||||
self._validate_graph_connectivity(start_executor_id)
|
||||
self._validate_self_loops()
|
||||
self._validate_handler_ambiguity()
|
||||
self._validate_dead_ends()
|
||||
self._validate_cycles()
|
||||
self._validate_interceptor_uniqueness()
|
||||
@@ -224,158 +192,18 @@ class WorkflowGraphValidator:
|
||||
def _validate_handler_output_annotations(self) -> None:
|
||||
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
|
||||
|
||||
Requirements:
|
||||
- WorkflowContext annotation must be present
|
||||
- T_Out must be provided; if no outputs, it must be None
|
||||
- T_Out elements must be valid types (class) or typing generics (e.g., list[str]);
|
||||
values like int() or 123 are invalid
|
||||
Note: This validation is now primarily handled at handler registration time
|
||||
via the unified validation functions in _workflow_context.py when the @handler
|
||||
decorator is applied. This method is kept minimal for any edge cases.
|
||||
"""
|
||||
from ._workflow_context import WorkflowContext # Local import to avoid cycles
|
||||
|
||||
# Iterate over all registered executors in the workflow graph
|
||||
for executor_id, executor in self._executors.items():
|
||||
for attr_name in dir(executor.__class__):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
# Retrieve attributes without binding (so the first parameter remains 'self').
|
||||
# This ensures inspect.signature sees all three parameters: (self, message, ctx).
|
||||
attr = None
|
||||
from contextlib import suppress
|
||||
|
||||
with suppress(Exception):
|
||||
attr = inspect.getattr_static(executor.__class__, attr_name)
|
||||
if attr is None:
|
||||
continue
|
||||
# Consider only callables that were decorated with @handler
|
||||
if not callable(attr) or not hasattr(attr, "_handler_spec"):
|
||||
continue
|
||||
|
||||
handler_spec = attr._handler_spec # type: ignore[attr-defined]
|
||||
handler_name = handler_spec.get("name", attr_name)
|
||||
|
||||
try:
|
||||
# Inspect the function signature of the unbound function
|
||||
sig = inspect.signature(attr)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
params = list(sig.parameters.values())
|
||||
# Handlers must have exactly three parameters: (self, message, ctx)
|
||||
if len(params) != 3:
|
||||
continue
|
||||
|
||||
ctx_param = params[2]
|
||||
ctx_ann = ctx_param.annotation
|
||||
|
||||
# If ctx lacks an annotation entirely, fail fast with a clear message
|
||||
if ctx_ann is inspect.Parameter.empty:
|
||||
raise HandlerOutputAnnotationError(executor_id, handler_name, "missing type annotation for ctx")
|
||||
|
||||
# Validate that the ctx annotation is WorkflowContext[...] and is properly parameterized
|
||||
ctx_origin = get_origin(ctx_ann)
|
||||
if ctx_origin is None:
|
||||
# If it's exactly the WorkflowContext class, T_Out is missing (e.g., WorkflowContext)
|
||||
if ctx_ann is WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
else:
|
||||
# The annotation is parameterized, but must be for WorkflowContext
|
||||
if ctx_origin is not WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id, handler_name, f"ctx must be WorkflowContext[T], got {ctx_ann}"
|
||||
)
|
||||
|
||||
# Extract and validate T_Out
|
||||
type_args = get_args(ctx_ann)
|
||||
if not type_args:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
|
||||
t_out = type_args[0]
|
||||
|
||||
# Allow Any for T_Out (unspecified outputs). We accept this here and
|
||||
# skip type compatibility later, but still enforce shape validity elsewhere.
|
||||
if t_out is Any:
|
||||
continue
|
||||
|
||||
# Allow None (no outputs) explicitly declared
|
||||
if t_out is type(None):
|
||||
continue
|
||||
|
||||
# If T_Out is a union, validate each member (e.g., str | int)
|
||||
union_origin = get_origin(t_out)
|
||||
type_items: list[Any]
|
||||
type_items = list(get_args(t_out)) if union_origin in (Union, UnionType) else [t_out]
|
||||
|
||||
invalid = [x for x in type_items if not _is_type_like(x) and x is not type(None)]
|
||||
if invalid:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
f"T_Out contains invalid entries: {invalid}. Use proper types or typing generics",
|
||||
)
|
||||
|
||||
# Also validate instance-level handler specs if present
|
||||
if hasattr(executor, "_instance_handler_specs"):
|
||||
for spec in executor._instance_handler_specs:
|
||||
handler_name = spec.get("name", "unknown")
|
||||
ctx_ann = spec.get("ctx_annotation")
|
||||
|
||||
if ctx_ann is None:
|
||||
continue # Skip if no annotation stored
|
||||
|
||||
# Validate that the ctx annotation is WorkflowContext[...] and is properly parameterized
|
||||
ctx_origin = get_origin(ctx_ann)
|
||||
if ctx_origin is None:
|
||||
if ctx_ann is WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
else:
|
||||
if ctx_origin is not WorkflowContext:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id, handler_name, f"ctx must be WorkflowContext[T], got {ctx_ann}"
|
||||
)
|
||||
|
||||
# Extract and validate T_Out
|
||||
type_args = get_args(ctx_ann)
|
||||
if not type_args:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
|
||||
)
|
||||
|
||||
t_out = type_args[0]
|
||||
|
||||
# Allow Any for T_Out (unspecified outputs)
|
||||
if t_out is Any:
|
||||
continue
|
||||
|
||||
# Allow None (no outputs) explicitly declared
|
||||
if t_out is type(None):
|
||||
continue
|
||||
|
||||
# If T_Out is a union, validate each member
|
||||
union_origin = get_origin(t_out)
|
||||
instance_type_items: list[Any]
|
||||
instance_type_items = list(get_args(t_out)) if union_origin in (Union, UnionType) else [t_out]
|
||||
|
||||
invalid = [x for x in instance_type_items if not _is_type_like(x) and x is not type(None)]
|
||||
if invalid:
|
||||
raise HandlerOutputAnnotationError(
|
||||
executor_id,
|
||||
handler_name,
|
||||
f"T_Out contains invalid entries: {invalid}. Use proper types or typing generics",
|
||||
)
|
||||
# The comprehensive validation is already done during handler registration:
|
||||
# 1. @handler decorator calls validate_function_signature()
|
||||
# 2. FunctionExecutor constructor calls validate_function_signature()
|
||||
# 3. Both use validate_workflow_context_annotation() for WorkflowContext validation
|
||||
#
|
||||
# All executors in the workflow must have gone through one of these paths,
|
||||
# so redundant validation here is unnecessary and has been removed.
|
||||
pass
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -444,29 +272,25 @@ class WorkflowGraphValidator:
|
||||
target_executor = self._executors[edge.target_id]
|
||||
|
||||
# Get output types from source executor
|
||||
source_output_types = self._get_executor_output_types(source_executor)
|
||||
source_output_types = list(source_executor.output_types)
|
||||
# Also include intercepted request types as potential outputs
|
||||
# since @intercepts_request methods can forward requests
|
||||
source_output_types.extend(source_executor.request_types)
|
||||
|
||||
# Get input types from target executor
|
||||
target_input_types = self._get_executor_input_types(target_executor)
|
||||
target_input_types = target_executor.input_types
|
||||
|
||||
# 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 built-in workflow components where dynamic typing is expected
|
||||
try:
|
||||
from ._executor import RequestInfoExecutor, WorkflowExecutor # local import to avoid cycles
|
||||
|
||||
builtin_types = (RequestInfoExecutor, WorkflowExecutor)
|
||||
except Exception:
|
||||
builtin_types = tuple() # type: ignore[assignment]
|
||||
|
||||
if not source_output_types and not isinstance(source_executor, builtin_types):
|
||||
# Suppress warnings for RequestInfoExecutor where dynamic typing is expected
|
||||
if not source_output_types and not isinstance(source_executor, RequestInfoExecutor):
|
||||
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, builtin_types):
|
||||
if not target_input_types and not isinstance(target_executor, RequestInfoExecutor):
|
||||
logger.warning(
|
||||
f"Executor '{target_executor.id}' has no input type annotations. "
|
||||
f"Type compatibility validation will be skipped for edges to this executor. "
|
||||
@@ -506,62 +330,6 @@ class WorkflowGraphValidator:
|
||||
target_input_types,
|
||||
)
|
||||
|
||||
def _get_executor_output_types(self, executor: Executor) -> list[type[Any]]:
|
||||
"""Extract output types from an executor's message handlers.
|
||||
|
||||
Args:
|
||||
executor: The executor to analyze
|
||||
|
||||
Returns:
|
||||
list of types that this executor can output
|
||||
"""
|
||||
output_types: list[type[Any]] = []
|
||||
|
||||
for attr_name in dir(executor.__class__):
|
||||
if attr_name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
attr = getattr(executor.__class__, attr_name)
|
||||
if callable(attr) and hasattr(attr, "_handler_spec"):
|
||||
handler_spec = attr._handler_spec # type: ignore
|
||||
handler_output_types = handler_spec.get("output_types", [])
|
||||
output_types.extend(handler_output_types)
|
||||
except AttributeError:
|
||||
# Skip attributes that may not be accessible
|
||||
continue
|
||||
|
||||
# Also include intercepted request types as potential outputs
|
||||
# since @intercepts_request methods can forward requests
|
||||
if hasattr(executor, "_request_interceptors"):
|
||||
for request_type in executor._request_interceptors:
|
||||
if isinstance(request_type, type):
|
||||
output_types.append(request_type)
|
||||
|
||||
# Include output types from instance-level handler specs
|
||||
if hasattr(executor, "_instance_handler_specs"):
|
||||
for spec in executor._instance_handler_specs:
|
||||
handler_output_types = spec.get("output_types", [])
|
||||
output_types.extend(handler_output_types)
|
||||
|
||||
return output_types
|
||||
|
||||
def _get_executor_input_types(self, executor: Executor) -> list[type[Any]]:
|
||||
"""Extract input types from an executor's message handlers.
|
||||
|
||||
Args:
|
||||
executor: The executor to analyze
|
||||
|
||||
Returns:
|
||||
list of types that this executor can handle as input
|
||||
"""
|
||||
input_types: list[type[Any]] = []
|
||||
|
||||
# Access the private _handlers attribute to get input types
|
||||
if hasattr(executor, "_handlers"):
|
||||
input_types.extend(executor._handlers.keys()) # type: ignore
|
||||
|
||||
return input_types
|
||||
|
||||
# endregion
|
||||
|
||||
# region Graph Connectivity Validation
|
||||
@@ -650,30 +418,6 @@ class WorkflowGraphValidator:
|
||||
f"This may cause infinite recursion if not properly handled with conditions."
|
||||
)
|
||||
|
||||
def _validate_handler_ambiguity(self) -> None:
|
||||
"""Check for potential ambiguity in message handlers.
|
||||
|
||||
Warns when executors have multiple handlers that could handle the same type,
|
||||
which might lead to unexpected behavior.
|
||||
"""
|
||||
for executor_id, executor in self._executors.items():
|
||||
input_types = self._get_executor_input_types(executor)
|
||||
|
||||
# Check for duplicate input types
|
||||
seen_types: set[type[Any]] = set()
|
||||
duplicate_types: set[type[Any]] = set()
|
||||
|
||||
for input_type in input_types:
|
||||
if input_type in seen_types:
|
||||
duplicate_types.add(input_type)
|
||||
seen_types.add(input_type)
|
||||
|
||||
if duplicate_types:
|
||||
logger.warning(
|
||||
f"Executor '{executor_id}' has multiple handlers for the same input types: "
|
||||
f"{[str(t) for t in duplicate_types]}. This may lead to ambiguous message routing."
|
||||
)
|
||||
|
||||
def _validate_dead_ends(self) -> None:
|
||||
"""Identify executors that have no outgoing edges (potential dead ends).
|
||||
|
||||
@@ -744,8 +488,6 @@ class WorkflowGraphValidator:
|
||||
This prevents non-deterministic behavior where multiple executors could intercept
|
||||
the same request type from the same sub-workflow.
|
||||
"""
|
||||
from ._executor import WorkflowExecutor
|
||||
|
||||
# Find all WorkflowExecutor instances in the workflow
|
||||
workflow_executors: dict[str, WorkflowExecutor] = {}
|
||||
for executor_id, executor in self._executors.items():
|
||||
|
||||
@@ -253,7 +253,7 @@ class WorkflowViz:
|
||||
"""Emit DOT subgraphs for any WorkflowExecutor instances found in the workflow."""
|
||||
# Lazy import to avoid any potential import cycles
|
||||
try:
|
||||
from ._executor import WorkflowExecutor # type: ignore
|
||||
from ._workflow_executor import WorkflowExecutor # type: ignore
|
||||
except ImportError: # pragma: no cover - best-effort; if unavailable, skip subgraphs
|
||||
return
|
||||
|
||||
@@ -327,7 +327,7 @@ class WorkflowViz:
|
||||
|
||||
def _emit_sub_workflows_mermaid(self, wf: Workflow, lines: list[str], indent: str) -> None:
|
||||
try:
|
||||
from ._executor import WorkflowExecutor # type: ignore
|
||||
from ._workflow_executor import WorkflowExecutor # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
return
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ from ._edge import (
|
||||
)
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
@@ -56,32 +56,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowRunResult(list[WorkflowEvent]):
|
||||
"""A list of events generated during the workflow execution in non-streaming mode.
|
||||
"""Container for events generated during non-streaming workflow execution.
|
||||
|
||||
Preserves the historical contract that the list contains data-plane events
|
||||
only (executor invoke/complete, completed, requests), while exposing the
|
||||
control-plane status timeline via accessors.
|
||||
## Overview
|
||||
Represents the complete execution results of a workflow run, containing all events
|
||||
generated from start to idle state. Workflows produce outputs incrementally through
|
||||
ctx.yield_output() calls during execution.
|
||||
|
||||
## Event Structure
|
||||
Maintains separation between data-plane and control-plane events:
|
||||
- Data-plane events: Executor invocations, completions, outputs, and requests (in main list)
|
||||
- Control-plane events: Status timeline accessible via status_timeline() method
|
||||
|
||||
## Key Methods
|
||||
- get_outputs(): Extract all workflow outputs from the execution
|
||||
- get_request_info_events(): Retrieve external input requests made during execution
|
||||
- get_final_state(): Get the final workflow state (IDLE, IDLE_WITH_PENDING_REQUESTS, etc.)
|
||||
- status_timeline(): Access the complete status event history
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[WorkflowEvent], status_events: list[WorkflowStatusEvent] | None = None) -> None:
|
||||
super().__init__(events)
|
||||
self._status_events: list[WorkflowStatusEvent] = status_events or []
|
||||
|
||||
def get_completed_event(self) -> WorkflowCompletedEvent | None:
|
||||
"""Get the completed event from the workflow run result.
|
||||
def get_outputs(self) -> list[Any]:
|
||||
"""Get all outputs from the workflow run result.
|
||||
|
||||
Returns:
|
||||
A completed WorkflowEvent instance if the workflow has a completed event, otherwise None.
|
||||
|
||||
Raises:
|
||||
ValueError: If there are multiple completed events in the workflow run result.
|
||||
A list of outputs produced by the workflow during its execution.
|
||||
"""
|
||||
completed_events = [event for event in self if isinstance(event, WorkflowCompletedEvent)]
|
||||
if not completed_events:
|
||||
return None
|
||||
if len(completed_events) > 1:
|
||||
raise ValueError("Multiple completed events found.")
|
||||
return completed_events[0]
|
||||
return [event.data for event in self if isinstance(event, WorkflowOutputEvent)]
|
||||
|
||||
def get_request_info_events(self) -> list[RequestInfoEvent]:
|
||||
"""Get all request info events from the workflow run result.
|
||||
@@ -113,10 +117,54 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
|
||||
|
||||
class Workflow(AFBaseModel):
|
||||
"""A class representing a workflow that can be executed.
|
||||
"""A graph-based execution engine that orchestrates connected executors.
|
||||
|
||||
This class is a placeholder for the workflow logic and does not implement any specific functionality.
|
||||
It serves as a base class for more complex workflows that can be defined in subclasses.
|
||||
## Overview
|
||||
A workflow executes a directed graph of executors connected via edge groups using a Pregel-like model,
|
||||
running in supersteps until the graph becomes idle. Workflows are created using the
|
||||
WorkflowBuilder class - do not instantiate this class directly.
|
||||
|
||||
## Execution Model
|
||||
Executors run in synchronized supersteps where each executor:
|
||||
- Is invoked when it receives messages from connected edge groups
|
||||
- Can send messages to downstream executors via ctx.send_message()
|
||||
- Can yield workflow-level outputs via ctx.yield_output()
|
||||
- Can emit custom events via ctx.add_event()
|
||||
|
||||
Messages between executors are delivered at the end of each superstep and are not
|
||||
visible in the event stream. Only workflow-level events (outputs, custom events)
|
||||
and status events are observable to callers.
|
||||
|
||||
## Input/Output Types
|
||||
Workflow types are discovered at runtime by inspecting:
|
||||
- Input types: From the start executor's input types
|
||||
- Output types: Union of all executors' workflow output types
|
||||
Access these via the input_types and output_types properties.
|
||||
|
||||
## Execution Methods
|
||||
- run(): Execute to completion, returns WorkflowRunResult with all events
|
||||
- run_stream(): Returns async generator yielding events as they occur
|
||||
- run_from_checkpoint(): Resume from a saved checkpoint
|
||||
- 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
|
||||
|
||||
## Checkpointing
|
||||
When enabled, checkpoints are created at the end of each superstep, capturing:
|
||||
- Executor states
|
||||
- Messages in transit
|
||||
- Shared state
|
||||
Workflows can be paused and resumed across process restarts using checkpoint storage.
|
||||
|
||||
## Composition
|
||||
Workflows can be nested using WorkflowExecutor, which wraps a child workflow as an executor.
|
||||
The nested workflow's input/output types become part of the WorkflowExecutor's types.
|
||||
When invoked, the WorkflowExecutor runs the nested workflow to completion and processes its outputs.
|
||||
"""
|
||||
|
||||
edge_groups: list[EdgeGroup] = Field(
|
||||
@@ -202,7 +250,7 @@ class Workflow(AFBaseModel):
|
||||
# Get the original executor object and serialize its workflow
|
||||
original_executor = self.executors.get(executor_id)
|
||||
if original_executor and hasattr(original_executor, "workflow"):
|
||||
from ._executor import WorkflowExecutor
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
|
||||
if isinstance(original_executor, WorkflowExecutor):
|
||||
executor_data["workflow"] = original_executor.workflow.model_dump(**kwargs)
|
||||
@@ -249,7 +297,6 @@ class Workflow(AFBaseModel):
|
||||
OtelAttr.WORKFLOW_ID: self.id,
|
||||
},
|
||||
) as span:
|
||||
saw_completed = False
|
||||
saw_request = False
|
||||
emitted_in_progress_pending = False
|
||||
try:
|
||||
@@ -273,25 +320,19 @@ class Workflow(AFBaseModel):
|
||||
|
||||
# All executor executions happen within workflow span
|
||||
async for event in self._runner.run_until_convergence():
|
||||
# Track terminal indicators while forwarding events
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
saw_completed = True
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
# Track request events for final status determination
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
saw_request = True
|
||||
yield event
|
||||
|
||||
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending and not saw_completed:
|
||||
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending:
|
||||
emitted_in_progress_pending = True
|
||||
with _framework_event_origin():
|
||||
pending_status = WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
|
||||
yield pending_status
|
||||
|
||||
# Success path: emit a final status based on observed terminal signals
|
||||
if saw_completed:
|
||||
with _framework_event_origin():
|
||||
terminal_status = WorkflowStatusEvent(WorkflowRunState.COMPLETED)
|
||||
yield terminal_status
|
||||
elif saw_request:
|
||||
# Workflow runs until idle - emit final status based on whether requests are pending
|
||||
if saw_request:
|
||||
with _framework_event_origin():
|
||||
terminal_status = WorkflowStatusEvent(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
|
||||
yield terminal_status
|
||||
@@ -334,14 +375,11 @@ class Workflow(AFBaseModel):
|
||||
executor = self.get_start_executor()
|
||||
await executor.execute(
|
||||
message,
|
||||
WorkflowContext(
|
||||
executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for workflow start
|
||||
source_span_ids=None, # No source span for workflow start
|
||||
),
|
||||
[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):
|
||||
@@ -774,6 +812,36 @@ class Workflow(AFBaseModel):
|
||||
def graph_signature_hash(self) -> str:
|
||||
return self._graph_signature_hash
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any]]:
|
||||
"""Get the input types of the workflow.
|
||||
|
||||
The input types are the list of input types of the start executor.
|
||||
|
||||
Returns:
|
||||
A list of input types that the workflow can accept.
|
||||
"""
|
||||
start_executor = self.get_start_executor()
|
||||
return start_executor.input_types
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any]]:
|
||||
"""Get the output types of the workflow.
|
||||
|
||||
The output types are the list of all workflow output types from executors
|
||||
that have workflow output types.
|
||||
|
||||
Returns:
|
||||
A list of output types that the workflow can produce.
|
||||
"""
|
||||
output_types: set[type[Any]] = set()
|
||||
|
||||
for executor in self.executors.values():
|
||||
workflow_output_types = executor.workflow_output_types
|
||||
output_types.update(workflow_output_types)
|
||||
|
||||
return list(output_types)
|
||||
|
||||
def as_agent(self, name: str | None = None) -> WorkflowAgent:
|
||||
"""Create a WorkflowAgent that wraps this workflow.
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any, Generic, TypeVar, cast, get_args
|
||||
from collections.abc import Callable
|
||||
from types import UnionType
|
||||
from typing import Any, Generic, Union, cast, get_args, get_origin
|
||||
|
||||
from opentelemetry.propagate import inject
|
||||
from opentelemetry.trace import SpanKind
|
||||
from typing_extensions import Never, TypeVar
|
||||
|
||||
from ..observability import OtelAttr, create_workflow_span
|
||||
from ._events import (
|
||||
@@ -12,19 +18,249 @@ from ._events import (
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowLifecycleEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowWarningEvent,
|
||||
_framework_event_origin,
|
||||
)
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
|
||||
T_Out = TypeVar("T_Out")
|
||||
T_Out = TypeVar("T_Out", default=Never)
|
||||
T_W_Out = TypeVar("T_W_Out", default=Never)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[type[Any]], list[type[Any]]]:
|
||||
"""Infer message types and workflow output types from the WorkflowContext generic parameters.
|
||||
|
||||
Examples:
|
||||
- WorkflowContext -> ([], [])
|
||||
- WorkflowContext[str] -> ([str], [])
|
||||
- WorkflowContext[str, int] -> ([str], [int])
|
||||
- WorkflowContext[str | int, bool | int] -> ([str, int], [bool, int])
|
||||
- WorkflowContext[Union[str, int], Union[bool, int]] -> ([str, int], [bool, int])
|
||||
- WorkflowContext[Any] -> ([Any], [])
|
||||
- WorkflowContext[Any, Any] -> ([Any], [Any])
|
||||
- WorkflowContext[Never, Never] -> ([], [])
|
||||
- WorkflowContext[Never, int] -> ([], [int])
|
||||
|
||||
Returns:
|
||||
Tuple of (message_types, workflow_output_types)
|
||||
"""
|
||||
# If no annotation or not parameterized, return empty lists
|
||||
try:
|
||||
origin = get_origin(ctx_annotation)
|
||||
except Exception:
|
||||
origin = None
|
||||
|
||||
# If annotation is unsubscripted WorkflowContext, nothing to infer
|
||||
if origin is None:
|
||||
return [], []
|
||||
|
||||
# Expecting WorkflowContext[T_Out, T_W_Out]
|
||||
if origin is not WorkflowContext:
|
||||
return [], []
|
||||
|
||||
args = list(get_args(ctx_annotation))
|
||||
if not args:
|
||||
return [], []
|
||||
|
||||
# WorkflowContext[T_Out] -> message_types from T_Out, no workflow output types
|
||||
if len(args) == 1:
|
||||
t = args[0]
|
||||
t_origin = get_origin(t)
|
||||
if t is Any:
|
||||
return [cast(type[Any], Any)], []
|
||||
|
||||
if t_origin in (Union, UnionType):
|
||||
message_types = [arg for arg in get_args(t) if arg is not Any and arg is not Never]
|
||||
return message_types, []
|
||||
|
||||
if t is Never:
|
||||
return [], []
|
||||
return [t], []
|
||||
|
||||
# WorkflowContext[T_Out, T_W_Out] -> message_types from T_Out, workflow_output_types from T_W_Out
|
||||
t_out, t_w_out = args[:2] # Take first two args in case there are more
|
||||
|
||||
# Process T_Out for message_types
|
||||
message_types = []
|
||||
t_out_origin = get_origin(t_out)
|
||||
if t_out is Any:
|
||||
message_types = [cast(type[Any], Any)]
|
||||
elif t_out is not Never:
|
||||
if t_out_origin in (Union, UnionType):
|
||||
message_types = [arg for arg in get_args(t_out) if arg is not Any and arg is not Never]
|
||||
else:
|
||||
message_types = [t_out]
|
||||
|
||||
# Process T_W_Out for workflow_output_types
|
||||
workflow_output_types = []
|
||||
t_w_out_origin = get_origin(t_w_out)
|
||||
if t_w_out is Any:
|
||||
workflow_output_types = [cast(type[Any], Any)]
|
||||
elif t_w_out is not Never:
|
||||
if t_w_out_origin in (Union, UnionType):
|
||||
workflow_output_types = [arg for arg in get_args(t_w_out) if arg is not Any and arg is not Never]
|
||||
else:
|
||||
workflow_output_types = [t_w_out]
|
||||
|
||||
return message_types, workflow_output_types
|
||||
|
||||
|
||||
def _is_workflow_context_type(annotation: Any) -> bool:
|
||||
"""Check if an annotation represents WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U]."""
|
||||
origin = get_origin(annotation)
|
||||
if origin is WorkflowContext:
|
||||
return True
|
||||
# Also handle the case where the raw class is used
|
||||
return annotation is WorkflowContext
|
||||
|
||||
|
||||
def validate_workflow_context_annotation(
|
||||
annotation: Any,
|
||||
parameter_name: str,
|
||||
context_description: str,
|
||||
) -> tuple[list[type[Any]], list[type[Any]]]:
|
||||
"""Validate a WorkflowContext annotation and return inferred types.
|
||||
|
||||
Args:
|
||||
annotation: The type annotation to validate
|
||||
parameter_name: Name of the parameter (for error messages)
|
||||
context_description: Description of the context (e.g., "Function func1", "Handler method")
|
||||
|
||||
Returns:
|
||||
Tuple of (output_types, workflow_output_types)
|
||||
|
||||
Raises:
|
||||
ValueError: If the annotation is invalid
|
||||
"""
|
||||
if annotation == inspect.Parameter.empty:
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} must have a WorkflowContext, "
|
||||
f"WorkflowContext[T] or WorkflowContext[T, U] type annotation, "
|
||||
f"where T is output message type and U is workflow output type"
|
||||
)
|
||||
|
||||
if not _is_workflow_context_type(annotation):
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} must be annotated as "
|
||||
f"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
|
||||
f"got {annotation}"
|
||||
)
|
||||
|
||||
# Validate type arguments for WorkflowContext[T] or WorkflowContext[T, U]
|
||||
type_args = get_args(annotation)
|
||||
|
||||
if len(type_args) > 2:
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} must have at most 2 type arguments, "
|
||||
"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
|
||||
f"got {len(type_args)} arguments"
|
||||
)
|
||||
|
||||
if type_args:
|
||||
# Helper function to check if a value is a valid type annotation
|
||||
def _is_type_like(x: Any) -> bool:
|
||||
"""Check if a value is a type-like entity (class, type, or typing construct)."""
|
||||
return isinstance(x, type) or get_origin(x) is not None or x is Never
|
||||
|
||||
for i, type_arg in enumerate(type_args):
|
||||
param_description = "T_Out" if i == 0 else "T_W_Out"
|
||||
|
||||
# Allow Any explicitly
|
||||
if type_arg is Any:
|
||||
continue
|
||||
|
||||
# Check if it's a union type and validate each member
|
||||
union_origin = get_origin(type_arg)
|
||||
if union_origin in (Union, UnionType):
|
||||
union_members = get_args(type_arg)
|
||||
invalid_members = [m for m in union_members if not _is_type_like(m) and m is not Any]
|
||||
if invalid_members:
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} {param_description} "
|
||||
f"contains invalid type entries: {invalid_members}. "
|
||||
f"Use proper types or typing generics"
|
||||
)
|
||||
else:
|
||||
# Check if it's a valid type
|
||||
if not _is_type_like(type_arg):
|
||||
raise ValueError(
|
||||
f"{context_description} {parameter_name} {param_description} "
|
||||
f"contains invalid type entry: {type_arg}. "
|
||||
f"Use proper types or typing generics"
|
||||
)
|
||||
|
||||
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))
|
||||
@@ -36,11 +272,47 @@ _FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
|
||||
)
|
||||
|
||||
|
||||
class WorkflowContext(Generic[T_Out]):
|
||||
"""Context for executors in a workflow.
|
||||
class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
"""Execution context that enables executors to interact with workflows and other executors.
|
||||
|
||||
This class is used to provide a way for executors to interact with the workflow
|
||||
context and shared state, while preventing direct access to the runtime context.
|
||||
## Overview
|
||||
WorkflowContext provides a controlled interface for executors to send messages, yield outputs,
|
||||
manage state, and interact with the broader workflow ecosystem. It enforces type safety through
|
||||
generic parameters while preventing direct access to internal runtime components.
|
||||
|
||||
## Type Parameters
|
||||
The context is parameterized to enforce type safety for different operations:
|
||||
|
||||
### WorkflowContext (no parameters)
|
||||
For executors that only perform side effects without sending messages or yielding outputs:
|
||||
```python
|
||||
async def log_handler(message: str, ctx: WorkflowContext) -> None:
|
||||
print(f"Received: {message}") # Only side effects
|
||||
```
|
||||
|
||||
### WorkflowContext[T_Out]
|
||||
Enables sending messages of type T_Out to other executors:
|
||||
```python
|
||||
async def processor(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
result = len(message)
|
||||
await ctx.send_message(result) # Send int to downstream executors
|
||||
```
|
||||
|
||||
### WorkflowContext[T_Out, T_W_Out]
|
||||
Enables both sending messages (T_Out) and yielding workflow outputs (T_W_Out):
|
||||
```python
|
||||
async def dual_output(message: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
await ctx.send_message(42) # Send int message
|
||||
await ctx.yield_output("complete") # Yield str workflow output
|
||||
```
|
||||
|
||||
### Union Types
|
||||
Multiple types can be specified using union notation:
|
||||
```python
|
||||
async def flexible(message: str, ctx: WorkflowContext[int | str, bool | dict]) -> None:
|
||||
await ctx.send_message("text") # or send 42
|
||||
await ctx.yield_output(True) # or yield {"status": "done"}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -105,6 +377,17 @@ class WorkflowContext(Generic[T_Out]):
|
||||
|
||||
await self._runner_context.send_message(msg)
|
||||
|
||||
async def yield_output(self, output: T_W_Out) -> None:
|
||||
"""Set the output of the workflow.
|
||||
|
||||
Args:
|
||||
output: The output to yield. This must conform to the workflow output type(s)
|
||||
declared on this context.
|
||||
"""
|
||||
with _framework_event_origin():
|
||||
event = WorkflowOutputEvent(data=output, source_executor_id=self._executor_id)
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""Add an event to the workflow context."""
|
||||
if event.origin == WorkflowEventSource.EXECUTOR and isinstance(event, _FRAMEWORK_LIFECYCLE_EVENT_TYPES):
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from ._events import (
|
||||
WorkflowErrorEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from ._executor import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
SubWorkflowRequestInfo,
|
||||
SubWorkflowResponse,
|
||||
handler,
|
||||
)
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Context for tracking a single sub-workflow execution."""
|
||||
|
||||
execution_id: str
|
||||
collected_responses: dict[str, Any] # request_id -> response_data
|
||||
expected_response_count: int
|
||||
pending_requests: dict[str, Any] # request_id -> original request data
|
||||
|
||||
|
||||
class WorkflowExecutor(Executor):
|
||||
"""An executor that wraps a workflow to enable hierarchical workflow composition.
|
||||
|
||||
## Overview
|
||||
WorkflowExecutor makes a workflow behave as a single executor within a parent workflow,
|
||||
enabling nested workflow architectures. It handles the complete lifecycle of sub-workflow
|
||||
execution including event processing, output forwarding, and request/response coordination
|
||||
between parent and child workflows.
|
||||
|
||||
## Execution Model
|
||||
When invoked, WorkflowExecutor:
|
||||
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
|
||||
5. Handles external requests by routing them to the parent workflow
|
||||
6. Accumulates responses and resumes sub-workflow execution
|
||||
|
||||
## Event Stream Processing
|
||||
WorkflowExecutor processes events after sub-workflow completion:
|
||||
|
||||
### Output Forwarding
|
||||
All outputs from the sub-workflow are automatically forwarded to the parent:
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
### Request/Response Coordination
|
||||
When sub-workflows need external information:
|
||||
```python
|
||||
# Sub-workflow makes request
|
||||
request = MyDataRequest(query="user info")
|
||||
# RequestInfoExecutor emits RequestInfoEvent
|
||||
|
||||
# WorkflowExecutor wraps and forwards to parent
|
||||
wrapped = SubWorkflowRequestInfo(request_id="...", sub_workflow_id="child_workflow", data=request)
|
||||
# Parent workflow can intercept via @intercepts_request
|
||||
```
|
||||
|
||||
### State Management
|
||||
WorkflowExecutor maintains execution state across request/response cycles:
|
||||
- Tracks pending requests by request_id
|
||||
- Accumulates responses until all expected responses are received
|
||||
- Resumes sub-workflow execution with complete response batch
|
||||
- Handles concurrent executions and multiple pending requests
|
||||
|
||||
## Type System Integration
|
||||
WorkflowExecutor inherits its type signature from the wrapped workflow:
|
||||
|
||||
### Input Types
|
||||
Matches the wrapped workflow's start executor input types:
|
||||
```python
|
||||
# If sub-workflow accepts str, WorkflowExecutor accepts str
|
||||
workflow_executor = WorkflowExecutor(my_workflow, id="wrapper")
|
||||
assert workflow_executor.input_types == my_workflow.input_types
|
||||
```
|
||||
|
||||
### Output Types
|
||||
Combines sub-workflow outputs with request coordination types:
|
||||
```python
|
||||
# Includes all sub-workflow output types
|
||||
# Plus SubWorkflowRequestInfo if sub-workflow can make requests
|
||||
output_types = workflow.output_types + [SubWorkflowRequestInfo] # if applicable
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
WorkflowExecutor propagates sub-workflow failures:
|
||||
- Captures WorkflowFailedEvent from sub-workflow
|
||||
- Converts to WorkflowErrorEvent in parent context
|
||||
- Provides detailed error information including sub-workflow ID
|
||||
|
||||
## Concurrent Execution Support
|
||||
WorkflowExecutor fully supports multiple concurrent sub-workflow executions:
|
||||
|
||||
### Per-Execution State Isolation
|
||||
Each sub-workflow invocation creates an isolated ExecutionContext:
|
||||
```python
|
||||
# Multiple concurrent invocations are supported
|
||||
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
|
||||
|
||||
# Each invocation gets its own execution context
|
||||
# Execution 1: processes input_1 independently
|
||||
# Execution 2: processes input_2 independently
|
||||
# No state interference between executions
|
||||
```
|
||||
|
||||
### Request/Response Coordination
|
||||
Responses are correctly routed to the originating execution:
|
||||
- Each execution tracks its own pending requests and expected responses
|
||||
- Request-to-execution mapping ensures responses reach the correct sub-workflow
|
||||
- Response accumulation is isolated per execution
|
||||
- Automatic cleanup when execution completes
|
||||
|
||||
### Memory Management
|
||||
- Unlimited concurrent executions supported
|
||||
- Each execution has unique UUID-based identification
|
||||
- Cleanup of completed execution contexts
|
||||
- Thread-safe state management for concurrent access
|
||||
|
||||
### Important Considerations
|
||||
**Shared Workflow Instance**: All concurrent executions use the same underlying workflow instance.
|
||||
For proper isolation, ensure that:
|
||||
- The wrapped workflow and its executors are stateless
|
||||
- Executors use WorkflowContext state management instead of instance variables
|
||||
- Any shared state is managed through WorkflowContext.get_shared_state/set_shared_state
|
||||
```python
|
||||
# Good: Stateless executor using context state
|
||||
class StatelessExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, data: str, ctx: WorkflowContext[str]) -> None:
|
||||
# Use context state instead of instance variables
|
||||
state = await ctx.get_state() or {}
|
||||
state["processed"] = data
|
||||
await ctx.set_state(state)
|
||||
|
||||
|
||||
# Avoid: Stateful executor with instance variables
|
||||
class StatefulExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="stateful")
|
||||
self.data = [] # This will be shared across concurrent executions!
|
||||
```
|
||||
|
||||
## Integration with Parent Workflows
|
||||
Parent workflows can intercept sub-workflow requests:
|
||||
```python
|
||||
class ParentExecutor(Executor):
|
||||
@intercepts_request
|
||||
async def handle_child_request(
|
||||
self, request: MyDataRequest, ctx: WorkflowContext[Any]
|
||||
) -> RequestResponse[MyDataRequest, str]:
|
||||
# Handle request locally or forward to external source
|
||||
if self.can_handle_locally(request):
|
||||
return RequestResponse.handled("local result")
|
||||
return RequestResponse.forward() # Send to external handler
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
- Sub-workflows run to completion before processing their results
|
||||
- Event processing is atomic - all outputs are forwarded before requests
|
||||
- Response accumulation ensures sub-workflows receive complete response batches
|
||||
- Execution state is maintained for proper resumption after external requests
|
||||
- Concurrent executions are fully isolated and do not interfere with each other
|
||||
"""
|
||||
|
||||
workflow: "Workflow" = Field(description="The workflow to execute as a sub-workflow")
|
||||
|
||||
def __init__(self, workflow: "Workflow", id: str, **kwargs: Any):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to execute as a sub-workflow.
|
||||
id: Unique identifier for this executor.
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
"""
|
||||
kwargs.update({"workflow": workflow})
|
||||
super().__init__(id, **kwargs)
|
||||
|
||||
# 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
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any]]:
|
||||
"""Get the input types based on the underlying workflow's input types.
|
||||
|
||||
Returns:
|
||||
A list of input types that the underlying workflow can accept.
|
||||
"""
|
||||
return self.workflow.input_types
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any]]:
|
||||
"""Get the output types based on the underlying workflow's output types.
|
||||
|
||||
Returns:
|
||||
A list of output types that the underlying workflow can produce.
|
||||
Includes SubWorkflowRequestInfo if the sub-workflow contains RequestInfoExecutor.
|
||||
"""
|
||||
output_types = list(self.workflow.output_types)
|
||||
|
||||
# Check if the sub-workflow contains a RequestInfoExecutor
|
||||
# If so, this WorkflowExecutor can also output SubWorkflowRequestInfo messages
|
||||
for executor in self.workflow.executors.values():
|
||||
if isinstance(executor, RequestInfoExecutor):
|
||||
if SubWorkflowRequestInfo not in output_types:
|
||||
output_types.append(SubWorkflowRequestInfo)
|
||||
break
|
||||
|
||||
return output_types
|
||||
|
||||
@handler # No output_types - can send any completion data type
|
||||
async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) -> None:
|
||||
"""Execute the sub-workflow with raw input data.
|
||||
|
||||
This handler starts a new sub-workflow execution. When the sub-workflow
|
||||
needs external information, it pauses and sends a request to the parent.
|
||||
|
||||
Args:
|
||||
input_data: The input data to send to the sub-workflow.
|
||||
ctx: The workflow context from the parent.
|
||||
"""
|
||||
# Skip SubWorkflowResponse and SubWorkflowRequestInfo - they have specific handlers
|
||||
if isinstance(input_data, (SubWorkflowResponse, SubWorkflowRequestInfo)):
|
||||
logger.debug(f"WorkflowExecutor {self.id} ignoring input of type {type(input_data)}")
|
||||
return
|
||||
|
||||
# Create execution context for this sub-workflow run
|
||||
execution_id = str(uuid.uuid4())
|
||||
execution_context = ExecutionContext(
|
||||
execution_id=execution_id,
|
||||
collected_responses={},
|
||||
expected_response_count=0,
|
||||
pending_requests={},
|
||||
)
|
||||
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:
|
||||
# Run the sub-workflow and collect all events
|
||||
result = await self.workflow.run(input_data)
|
||||
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
|
||||
f"execution {execution_id} completed with {len(result)} events"
|
||||
)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if execution_id in self._execution_contexts:
|
||||
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
|
||||
# Wrap request with routing context and send to parent
|
||||
if not isinstance(event.data, RequestInfoMessage):
|
||||
raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}")
|
||||
wrapped_request = SubWorkflowRequestInfo(
|
||||
request_id=event.request_id,
|
||||
sub_workflow_id=self.id,
|
||||
data=event.data,
|
||||
)
|
||||
await ctx.send_message(wrapped_request)
|
||||
|
||||
# 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}")
|
||||
|
||||
@handler
|
||||
async def handle_response(
|
||||
self,
|
||||
response: SubWorkflowResponse,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Handle response from parent for a forwarded request.
|
||||
|
||||
This handler accumulates responses and only resumes the sub-workflow
|
||||
when all expected responses have been received for that execution.
|
||||
|
||||
Args:
|
||||
response: The response to a previous request.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Find the execution context for this request
|
||||
execution_id = self._request_to_execution.get(response.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"
|
||||
)
|
||||
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:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{response.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)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[response.request_id] = response.data
|
||||
|
||||
# Check if we have all expected responses for this execution
|
||||
if len(execution_context.collected_responses) < execution_context.expected_response_count:
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
|
||||
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
|
||||
)
|
||||
return # Wait for more responses
|
||||
|
||||
# Send all collected responses to the sub-workflow
|
||||
responses_to_send = dict(execution_context.collected_responses)
|
||||
execution_context.collected_responses.clear() # Clear for next batch
|
||||
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.send_responses(responses_to_send)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# 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
|
||||
@@ -1,8 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, handler
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, WorkflowRunState, WorkflowStatusEvent, handler
|
||||
from agent_framework._workflow._checkpoint import InMemoryCheckpointStorage
|
||||
from agent_framework._workflow._executor import Executor
|
||||
|
||||
@@ -15,8 +16,8 @@ class StartExecutor(Executor):
|
||||
|
||||
class FinishExecutor(Executor):
|
||||
@handler
|
||||
async def finish(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
await ctx.add_event(WorkflowCompletedEvent(message))
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
def build_workflow(storage: InMemoryCheckpointStorage, finish_id: str = "finish"):
|
||||
@@ -70,4 +71,4 @@ async def test_resume_succeeds_when_graph_matches() -> None:
|
||||
)
|
||||
]
|
||||
|
||||
assert any(isinstance(event, WorkflowCompletedEvent) for event in events)
|
||||
assert any(isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE for event in events)
|
||||
|
||||
@@ -12,8 +12,10 @@ from agent_framework import (
|
||||
ConcurrentBuilder,
|
||||
Executor,
|
||||
Role,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
|
||||
@@ -57,15 +59,19 @@ async def test_concurrent_default_aggregator_emits_single_user_and_assistants()
|
||||
|
||||
wf = ConcurrentBuilder().participants([e1, e2, e3]).build()
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("prompt: hello world"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(list[ChatMessage], ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed is not None
|
||||
assert isinstance(completed.data, list)
|
||||
messages: list[ChatMessage] = cast(list[ChatMessage], completed.data) # type: ignore
|
||||
assert completed
|
||||
assert output is not None
|
||||
messages: list[ChatMessage] = output
|
||||
|
||||
# Expect one user message + one assistant message per participant
|
||||
assert len(messages) == 1 + 3
|
||||
@@ -91,16 +97,21 @@ async def test_concurrent_custom_aggregator_callback_is_used() -> None:
|
||||
|
||||
wf = ConcurrentBuilder().participants([e1, e2]).with_aggregator(summarize).build()
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run_stream("prompt: custom"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed is not None
|
||||
assert completed
|
||||
assert output is not None
|
||||
# Custom aggregator returns a string payload
|
||||
assert isinstance(completed.data, str)
|
||||
assert completed.data == "One | Two"
|
||||
assert isinstance(output, str)
|
||||
assert output == "One | Two"
|
||||
|
||||
|
||||
async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
|
||||
@@ -108,7 +119,7 @@ async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
|
||||
e2 = _FakeAgentExec("agentB", "Two")
|
||||
|
||||
# Sync callback with ctx parameter (should run via asyncio.to_thread)
|
||||
def summarize_sync(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> str: # type: ignore[unused-argument]
|
||||
def summarize_sync(results: list[AgentExecutorResponse], _ctx: WorkflowContext[Any]) -> str: # type: ignore[unused-argument]
|
||||
texts: list[str] = []
|
||||
for r in results:
|
||||
msgs: list[ChatMessage] = r.agent_run_response.messages
|
||||
@@ -117,15 +128,20 @@ async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
|
||||
|
||||
wf = ConcurrentBuilder().participants([e1, e2]).with_aggregator(summarize_sync).build()
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: str | None = None
|
||||
async for ev in wf.run_stream("prompt: custom sync"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = cast(str, ev.data)
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed is not None
|
||||
assert isinstance(completed.data, str)
|
||||
assert completed.data == "One | Two"
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, str)
|
||||
assert output == "One | Two"
|
||||
|
||||
|
||||
def test_concurrent_custom_aggregator_uses_callback_name_for_id() -> None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import PrivateAttr
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
@@ -16,11 +17,13 @@ from agent_framework import (
|
||||
SequentialBuilder,
|
||||
TextContent,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflow._executor import AgentExecutorResponse, Executor
|
||||
from agent_framework._workflow._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
class _SimpleAgent(BaseAgent):
|
||||
@@ -54,19 +57,17 @@ class _CaptureFullConversation(Executor):
|
||||
"""Captures AgentExecutorResponse.full_conversation and completes the workflow."""
|
||||
|
||||
@handler
|
||||
async def capture(self, response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
async def capture(self, response: AgentExecutorResponse, ctx: WorkflowContext[Never, dict]) -> None:
|
||||
full = response.full_conversation
|
||||
# The AgentExecutor contract guarantees full_conversation is populated.
|
||||
assert full is not None
|
||||
await ctx.add_event(
|
||||
WorkflowCompletedEvent(
|
||||
data={
|
||||
"length": len(full),
|
||||
"roles": [m.role for m in full],
|
||||
"texts": [m.text for m in full],
|
||||
}
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"length": len(full),
|
||||
"roles": [m.role for m in full],
|
||||
"texts": [m.text for m in full],
|
||||
}
|
||||
await ctx.yield_output(payload)
|
||||
pass
|
||||
|
||||
|
||||
async def test_agent_executor_populates_full_conversation_non_streaming() -> None:
|
||||
@@ -78,15 +79,20 @@ async def test_agent_executor_populates_full_conversation_non_streaming() -> Non
|
||||
wf = WorkflowBuilder().set_start_executor(agent_exec).add_edge(agent_exec, capturer).build()
|
||||
|
||||
# Act: run with a simple user prompt
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: dict | None = None
|
||||
async for ev in wf.run_stream("hello world"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
# Assert: full_conversation contains [user("hello world"), assistant("agent-reply")]
|
||||
assert completed is not None
|
||||
payload = completed.data # type: ignore[assignment]
|
||||
assert completed
|
||||
assert output is not None
|
||||
payload = output
|
||||
assert isinstance(payload, dict)
|
||||
assert payload["length"] == 2
|
||||
assert payload["roles"][0] == Role.USER and "hello world" in (payload["texts"][0] or "")
|
||||
@@ -148,7 +154,7 @@ async def test_sequential_adapter_uses_full_conversation() -> None:
|
||||
|
||||
# Act
|
||||
async for ev in wf.run_stream("hello seq"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
break
|
||||
|
||||
# Assert: second agent should have seen the user prompt and A1's assistant reply
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
FunctionExecutor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
)
|
||||
@@ -28,9 +28,9 @@ class TestFunctionExecutor:
|
||||
assert len(func_exec._handlers) == 1
|
||||
assert str in func_exec._handlers
|
||||
|
||||
# Check instance handler spec was created
|
||||
assert len(func_exec._instance_handler_specs) == 1
|
||||
spec = func_exec._instance_handler_specs[0]
|
||||
# Check handler spec was created
|
||||
assert len(func_exec._handler_specs) == 1
|
||||
spec = func_exec._handler_specs[0]
|
||||
assert spec["name"] == "process_string"
|
||||
assert spec["message_type"] is str
|
||||
assert spec["output_types"] == [str]
|
||||
@@ -47,7 +47,7 @@ class TestFunctionExecutor:
|
||||
assert int in process_int._handlers
|
||||
|
||||
# Check spec
|
||||
spec = process_int._instance_handler_specs[0]
|
||||
spec = process_int._handler_specs[0]
|
||||
assert spec["message_type"] is int
|
||||
assert spec["output_types"] == [int]
|
||||
|
||||
@@ -81,7 +81,7 @@ class TestFunctionExecutor:
|
||||
assert int in simple_no_parens._handlers
|
||||
|
||||
def test_union_output_types(self):
|
||||
"""Test that union output types are properly inferred."""
|
||||
"""Test that union output types are properly inferred for both messages and workflow outputs."""
|
||||
|
||||
@executor
|
||||
async def multi_output(text: str, ctx: WorkflowContext[str | int]) -> None:
|
||||
@@ -90,29 +90,56 @@ class TestFunctionExecutor:
|
||||
else:
|
||||
await ctx.send_message(text.upper())
|
||||
|
||||
spec = multi_output._instance_handler_specs[0]
|
||||
spec = multi_output._handler_specs[0]
|
||||
assert set(spec["output_types"]) == {str, int}
|
||||
assert spec["workflow_output_types"] == [] # No workflow outputs defined
|
||||
|
||||
# Test union types for workflow outputs too
|
||||
@executor
|
||||
async def multi_workflow_output(data: str, ctx: WorkflowContext[Never, str | int | bool]) -> None:
|
||||
if data.isdigit():
|
||||
await ctx.yield_output(int(data))
|
||||
elif data.lower() in ("true", "false"):
|
||||
await ctx.yield_output(data.lower() == "true")
|
||||
else:
|
||||
await ctx.yield_output(data.upper())
|
||||
|
||||
workflow_spec = multi_workflow_output._handler_specs[0]
|
||||
assert workflow_spec["output_types"] == [] # None means no message outputs
|
||||
assert set(workflow_spec["workflow_output_types"]) == {str, int, bool}
|
||||
|
||||
def test_none_output_type(self):
|
||||
"""Test WorkflowContext[None] produces empty output types."""
|
||||
"""Test WorkflowContext produces empty output types."""
|
||||
|
||||
@executor
|
||||
async def no_output(data: Any, ctx: WorkflowContext[None]) -> None:
|
||||
async def no_output(data: Any, ctx: WorkflowContext) -> None:
|
||||
# This executor doesn't send any messages
|
||||
pass
|
||||
|
||||
spec = no_output._instance_handler_specs[0]
|
||||
spec = no_output._handler_specs[0]
|
||||
assert spec["output_types"] == []
|
||||
assert spec["workflow_output_types"] == [] # No workflow outputs defined
|
||||
|
||||
def test_any_output_type(self):
|
||||
"""Test WorkflowContext[Any] produces empty output types."""
|
||||
"""Test WorkflowContext[Any] and WorkflowContext[Any, Any] produce Any output types."""
|
||||
|
||||
@executor
|
||||
async def any_output(data: str, ctx: WorkflowContext[Any]) -> None:
|
||||
await ctx.send_message("result")
|
||||
|
||||
spec = any_output._instance_handler_specs[0]
|
||||
assert spec["output_types"] == []
|
||||
spec = any_output._handler_specs[0]
|
||||
assert spec["output_types"] == [Any]
|
||||
assert spec["workflow_output_types"] == [] # No workflow outputs defined
|
||||
|
||||
# Test both parameters as Any
|
||||
@executor
|
||||
async def any_both_output(data: str, ctx: WorkflowContext[Any, Any]) -> None:
|
||||
await ctx.send_message("message")
|
||||
await ctx.yield_output("workflow_output")
|
||||
|
||||
both_spec = any_both_output._handler_specs[0]
|
||||
assert both_spec["output_types"] == [Any]
|
||||
assert both_spec["workflow_output_types"] == [Any]
|
||||
|
||||
def test_validation_errors(self):
|
||||
"""Test various validation errors in function signatures."""
|
||||
@@ -121,13 +148,17 @@ class TestFunctionExecutor:
|
||||
async def no_params() -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="one or two parameters"):
|
||||
with pytest.raises(
|
||||
ValueError, match="must have \\(message: T\\) or \\(message: T, ctx: WorkflowContext\\[U\\]\\)"
|
||||
):
|
||||
FunctionExecutor(no_params) # type: ignore
|
||||
|
||||
async def too_many_params(data: str, ctx: WorkflowContext[str], extra: int) -> None:
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="one or two parameters"):
|
||||
with pytest.raises(
|
||||
ValueError, match="must have \\(message: T\\) or \\(message: T, ctx: WorkflowContext\\[U\\]\\)"
|
||||
):
|
||||
FunctionExecutor(too_many_params) # type: ignore
|
||||
|
||||
# Missing message type annotation
|
||||
@@ -141,22 +172,24 @@ class TestFunctionExecutor:
|
||||
async def no_ctx_type(data: str, ctx) -> None: # type: ignore
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="annotated as WorkflowContext"):
|
||||
with pytest.raises(ValueError, match="must have a WorkflowContext"):
|
||||
FunctionExecutor(no_ctx_type) # type: ignore
|
||||
|
||||
# Wrong ctx type
|
||||
async def wrong_ctx_type(data: str, ctx: str) -> None: # type: ignore
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="WorkflowContext\\[T\\]"):
|
||||
with pytest.raises(ValueError, match="must be annotated as WorkflowContext"):
|
||||
FunctionExecutor(wrong_ctx_type) # type: ignore
|
||||
|
||||
# Unparameterized WorkflowContext
|
||||
# Unparameterized WorkflowContext is now allowed
|
||||
async def unparameterized_ctx(data: str, ctx: WorkflowContext) -> None: # type: ignore
|
||||
pass
|
||||
|
||||
with pytest.raises(ValueError, match="concrete T"):
|
||||
FunctionExecutor(unparameterized_ctx) # type: ignore
|
||||
# This should now succeed since unparameterized WorkflowContext is allowed
|
||||
executor = FunctionExecutor(unparameterized_ctx)
|
||||
assert executor.output_types == [] # Unparameterized has no inferred types
|
||||
assert executor.workflow_output_types == [] # No workflow output types
|
||||
|
||||
async def test_execution_in_workflow(self):
|
||||
"""Test that FunctionExecutor works properly in a workflow."""
|
||||
@@ -167,18 +200,28 @@ class TestFunctionExecutor:
|
||||
await ctx.send_message(result)
|
||||
|
||||
@executor(id="reverse")
|
||||
async def reverse_text(text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
async def reverse_text(text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
result = text[::-1]
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
await ctx.yield_output(result)
|
||||
|
||||
# Verify type inference for both executors
|
||||
upper_spec = to_upper._handler_specs[0]
|
||||
assert upper_spec["output_types"] == [str]
|
||||
assert upper_spec["workflow_output_types"] == [] # No workflow outputs
|
||||
|
||||
reverse_spec = reverse_text._handler_specs[0]
|
||||
assert reverse_spec["output_types"] == [Any] # First parameter is Any
|
||||
assert reverse_spec["workflow_output_types"] == [str] # Second parameter is str
|
||||
|
||||
workflow = WorkflowBuilder().add_edge(to_upper, reverse_text).set_start_executor(to_upper).build()
|
||||
|
||||
# Run workflow
|
||||
events = await workflow.run("hello world")
|
||||
completed = events.get_completed_event()
|
||||
outputs = events.get_outputs()
|
||||
|
||||
assert completed is not None
|
||||
assert completed.data == "DLROW OLLEH"
|
||||
# Assert that we got the expected output
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0] == "DLROW OLLEH"
|
||||
|
||||
def test_can_handle_method(self):
|
||||
"""Test that can_handle method works with instance handlers."""
|
||||
@@ -204,12 +247,13 @@ class TestFunctionExecutor:
|
||||
await ctx.send_message(message)
|
||||
|
||||
with pytest.raises(ValueError, match="Handler for type .* already registered"):
|
||||
func_exec.register_instance_handler(
|
||||
func_exec._register_instance_handler(
|
||||
name="second",
|
||||
func=second_handler,
|
||||
message_type=str,
|
||||
ctx_annotation=WorkflowContext[str],
|
||||
output_types=[str],
|
||||
workflow_output_types=[],
|
||||
)
|
||||
|
||||
def test_complex_type_annotations(self):
|
||||
@@ -220,7 +264,7 @@ class TestFunctionExecutor:
|
||||
result = {item: len(item) for item in items}
|
||||
await ctx.send_message(result)
|
||||
|
||||
spec = process_list._instance_handler_specs[0]
|
||||
spec = process_list._handler_specs[0]
|
||||
assert spec["message_type"] == list[str]
|
||||
assert spec["output_types"] == [dict[str, int]]
|
||||
|
||||
@@ -236,7 +280,7 @@ class TestFunctionExecutor:
|
||||
assert str in process_simple._handlers
|
||||
|
||||
# Check spec - single parameter functions have no output types since they can't send messages
|
||||
spec = process_simple._instance_handler_specs[0]
|
||||
spec = process_simple._handler_specs[0]
|
||||
assert spec["message_type"] is str
|
||||
assert spec["output_types"] == []
|
||||
assert spec["ctx_annotation"] is None
|
||||
@@ -296,7 +340,7 @@ class TestFunctionExecutor:
|
||||
assert str in process_sync._handlers
|
||||
|
||||
# Check spec - sync single parameter functions have no output types
|
||||
spec = process_sync._instance_handler_specs[0]
|
||||
spec = process_sync._handler_specs[0]
|
||||
assert spec["message_type"] is str
|
||||
assert spec["output_types"] == []
|
||||
assert spec["ctx_annotation"] is None
|
||||
@@ -314,7 +358,7 @@ class TestFunctionExecutor:
|
||||
assert int in sync_with_ctx._handlers
|
||||
|
||||
# Check spec - sync functions with context can infer output types
|
||||
spec = sync_with_ctx._instance_handler_specs[0]
|
||||
spec = sync_with_ctx._handler_specs[0]
|
||||
assert spec["message_type"] is int
|
||||
assert spec["output_types"] == [int]
|
||||
|
||||
@@ -385,9 +429,18 @@ class TestFunctionExecutor:
|
||||
# In practice, the wrapper handles the async conversion
|
||||
|
||||
@executor(id="async_reverse")
|
||||
async def reverse_async(text: str, ctx: WorkflowContext[Any]):
|
||||
async def reverse_async(text: str, ctx: WorkflowContext[Any, str]):
|
||||
result = text[::-1]
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
await ctx.yield_output(result)
|
||||
|
||||
# Verify type inference for sync and async functions
|
||||
sync_spec = to_upper_sync._handler_specs[0]
|
||||
assert sync_spec["output_types"] == [str]
|
||||
assert sync_spec["workflow_output_types"] == [] # No workflow outputs
|
||||
|
||||
async_spec = reverse_async._handler_specs[0]
|
||||
assert async_spec["output_types"] == [Any] # First parameter is Any
|
||||
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")
|
||||
|
||||
@@ -23,9 +23,11 @@ from agent_framework import (
|
||||
RequestInfoEvent,
|
||||
Role,
|
||||
TextContent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent, # type: ignore # noqa: E402
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._agents import BaseAgent
|
||||
@@ -169,15 +171,20 @@ async def test_magentic_workflow_plan_review_approval_to_completion():
|
||||
req_event = ev
|
||||
assert req_event is not None
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: ChatMessage | None = None
|
||||
async for ev in wf.send_responses_streaming({
|
||||
req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
|
||||
}):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
assert completed is not None
|
||||
assert isinstance(getattr(completed, "data", None), ChatMessage)
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, ChatMessage)
|
||||
|
||||
|
||||
async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds():
|
||||
@@ -210,7 +217,7 @@ async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds()
|
||||
|
||||
# Reply APPROVE with comments (no edited text). Expect one replan and no second review round.
|
||||
saw_second_review = False
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
async for ev in wf.send_responses_streaming({
|
||||
req_event.request_id: MagenticPlanReviewReply(
|
||||
decision=MagenticPlanReviewDecision.APPROVE,
|
||||
@@ -219,11 +226,11 @@ async def test_magentic_plan_review_approve_with_comments_replans_and_proceeds()
|
||||
}):
|
||||
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
|
||||
saw_second_review = True
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
break
|
||||
|
||||
assert completed is not None
|
||||
assert completed
|
||||
assert manager.replan_count >= 1
|
||||
assert saw_second_review is False
|
||||
# Replan from FakeManager updates facts/plan to include A2 / Do Z
|
||||
@@ -245,9 +252,14 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
|
||||
if len(events) > 50:
|
||||
break
|
||||
|
||||
completed = next((e for e in events if isinstance(e, WorkflowCompletedEvent)), None)
|
||||
assert completed is not None
|
||||
data = getattr(completed, "data", None)
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None
|
||||
)
|
||||
assert idle_status is not None
|
||||
# Check that we got workflow output via WorkflowOutputEvent
|
||||
output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None)
|
||||
assert output_event is not None
|
||||
data = output_event.data
|
||||
assert isinstance(data, ChatMessage)
|
||||
assert data.role == Role.ASSISTANT
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
AgentRunResponse,
|
||||
Executor,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowEventSource,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflow._edge import SingleEdgeGroup
|
||||
@@ -32,11 +33,12 @@ class MockExecutor(Executor):
|
||||
"""A mock executor for testing purposes."""
|
||||
|
||||
@handler
|
||||
async def mock_handler(self, message: MockMessage, ctx: WorkflowContext[MockMessage]) -> None:
|
||||
async def mock_handler(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
|
||||
if message.data < 10:
|
||||
await ctx.send_message(MockMessage(data=message.data + 1))
|
||||
else:
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=message.data))
|
||||
await ctx.yield_output(message.data)
|
||||
pass
|
||||
|
||||
|
||||
def test_create_runner():
|
||||
@@ -77,18 +79,14 @@ async def test_runner_run_until_convergence():
|
||||
result: int | None = None
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
WorkflowContext(
|
||||
executor_id=executor_a.id,
|
||||
source_executor_ids=["START"],
|
||||
shared_state=shared_state,
|
||||
runner_context=ctx,
|
||||
),
|
||||
["START"], # source_executor_ids
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
)
|
||||
async for event in runner.run_until_convergence():
|
||||
assert isinstance(event, WorkflowEvent)
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
result = event.data
|
||||
assert event.origin is WorkflowEventSource.EXECUTOR
|
||||
|
||||
assert result is not None and result == 10
|
||||
|
||||
@@ -112,16 +110,13 @@ async def test_runner_run_until_convergence_not_completed():
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
WorkflowContext(
|
||||
executor_id=executor_a.id,
|
||||
source_executor_ids=["START"],
|
||||
shared_state=shared_state,
|
||||
runner_context=ctx,
|
||||
),
|
||||
["START"], # source_executor_ids
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Runner did not converge after 5 iterations."):
|
||||
async for event in runner.run_until_convergence():
|
||||
assert not isinstance(event, WorkflowCompletedEvent)
|
||||
assert not isinstance(event, WorkflowStatusEvent) or event.state != WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_runner_already_running():
|
||||
@@ -143,12 +138,9 @@ async def test_runner_already_running():
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
WorkflowContext(
|
||||
executor_id=executor_a.id,
|
||||
source_executor_ids=["START"],
|
||||
shared_state=shared_state,
|
||||
runner_context=ctx,
|
||||
),
|
||||
["START"], # source_executor_ids
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Runner is already running."):
|
||||
@@ -172,6 +164,6 @@ async def test_runner_emits_runner_completion_for_agent_response_without_targets
|
||||
)
|
||||
|
||||
events: list[WorkflowEvent] = [event async for event in runner.run_until_convergence()]
|
||||
completions = [e for e in events if isinstance(e, WorkflowCompletedEvent)]
|
||||
assert completions
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in completions)
|
||||
# The runner should complete without errors when handling AgentExecutorResponse without targets
|
||||
# No specific events are expected since there are no executors to process the message
|
||||
assert isinstance(events, list) # Just verify the runner completed without errors
|
||||
|
||||
@@ -15,8 +15,10 @@ from agent_framework import (
|
||||
Role,
|
||||
SequentialBuilder,
|
||||
TextContent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
|
||||
@@ -66,15 +68,20 @@ async def test_sequential_agents_append_to_context() -> None:
|
||||
|
||||
wf = SequentialBuilder().participants([a1, a2]).build()
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("hello sequential"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed is not None
|
||||
assert isinstance(completed.data, list)
|
||||
msgs: list[ChatMessage] = completed.data # type: ignore[assignment]
|
||||
assert completed
|
||||
assert output is not None
|
||||
assert isinstance(output, list)
|
||||
msgs: list[ChatMessage] = output
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0].role == Role.USER and "hello sequential" in msgs[0].text
|
||||
assert msgs[1].role == Role.ASSISTANT and (msgs[1].author_name == "A1" or True)
|
||||
@@ -89,14 +96,19 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
|
||||
wf = SequentialBuilder().participants([a1, summarizer]).build()
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("topic X"):
|
||||
if isinstance(ev, WorkflowCompletedEvent):
|
||||
completed = ev
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
assert completed is not None
|
||||
msgs: list[ChatMessage] = completed.data # type: ignore[assignment]
|
||||
assert completed
|
||||
assert output is not None
|
||||
msgs: list[ChatMessage] = output
|
||||
# Expect: [user, A1 reply, summary]
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0].role == Role.USER
|
||||
|
||||
@@ -17,7 +17,7 @@ from agent_framework._workflow._edge import (
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from agent_framework._workflow._executor import (
|
||||
from agent_framework._workflow._workflow_executor import (
|
||||
WorkflowExecutor,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
@@ -33,13 +35,11 @@ class SimpleSubExecutor(Executor):
|
||||
super().__init__(id="simple_sub")
|
||||
|
||||
@handler
|
||||
async def process(self, request: SimpleRequest, ctx: WorkflowContext[None]) -> None:
|
||||
async def process(self, request: SimpleRequest, ctx: WorkflowContext[Never, SimpleResponse]) -> None:
|
||||
"""Process a simple request."""
|
||||
from agent_framework import WorkflowCompletedEvent
|
||||
|
||||
# Just echo back with prefix and complete
|
||||
response = SimpleResponse(result=f"processed: {request.text}")
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=response))
|
||||
await ctx.yield_output(response)
|
||||
|
||||
|
||||
class SimpleParent(Executor):
|
||||
@@ -57,7 +57,7 @@ class SimpleParent(Executor):
|
||||
await ctx.send_message(request, target_id="sub_workflow")
|
||||
|
||||
@handler
|
||||
async def collect(self, response: SimpleResponse, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect(self, response: SimpleResponse, ctx: WorkflowContext) -> None:
|
||||
"""Collect the result."""
|
||||
self.result = response
|
||||
|
||||
@@ -72,7 +72,7 @@ async def test_simple_sub_workflow():
|
||||
super().__init__(id="dummy")
|
||||
|
||||
@handler
|
||||
async def process(self, message: object, ctx: WorkflowContext[None]) -> None:
|
||||
async def process(self, message: object, ctx: WorkflowContext) -> None:
|
||||
pass # Do nothing
|
||||
|
||||
dummy = DummyExecutor()
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
@@ -12,7 +13,6 @@ from agent_framework import (
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
@@ -54,7 +54,7 @@ class EmailValidator(Executor):
|
||||
|
||||
@handler
|
||||
async def validate_request(
|
||||
self, request: EmailValidationRequest, ctx: WorkflowContext[RequestInfoMessage | ValidationResult]
|
||||
self, request: EmailValidationRequest, ctx: WorkflowContext[RequestInfoMessage, ValidationResult]
|
||||
) -> None:
|
||||
"""Validate an email address."""
|
||||
# Extract domain and check if it's approved
|
||||
@@ -62,7 +62,7 @@ class EmailValidator(Executor):
|
||||
|
||||
if not domain:
|
||||
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
await ctx.yield_output(result)
|
||||
return
|
||||
|
||||
# Request domain check from external source
|
||||
@@ -71,7 +71,7 @@ class EmailValidator(Executor):
|
||||
|
||||
@handler
|
||||
async def handle_domain_response(
|
||||
self, response: RequestResponse[DomainCheckRequest, bool], ctx: WorkflowContext[ValidationResult]
|
||||
self, response: RequestResponse[DomainCheckRequest, bool], ctx: WorkflowContext[Never, ValidationResult]
|
||||
) -> None:
|
||||
"""Handle domain check response with correlation."""
|
||||
# Use the original email from the correlated response
|
||||
@@ -80,7 +80,7 @@ class EmailValidator(Executor):
|
||||
is_valid=response.data or False,
|
||||
reason="Domain approved" if response.data else "Domain not approved",
|
||||
)
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
class ParentOrchestrator(Executor):
|
||||
@@ -114,7 +114,7 @@ class ParentOrchestrator(Executor):
|
||||
return RequestResponse[DomainCheckRequest, bool].forward()
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect validation results."""
|
||||
self.results.append(result)
|
||||
|
||||
@@ -146,11 +146,11 @@ async def test_basic_sub_workflow() -> None:
|
||||
await ctx.send_message(request, target_id="email_workflow")
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
self.result = result
|
||||
|
||||
parent = SimpleParent()
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, id="email_workflow")
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
|
||||
main_request_info = RequestInfoExecutor(id="main_request_info")
|
||||
|
||||
main_workflow = (
|
||||
@@ -199,7 +199,7 @@ async def test_sub_workflow_with_interception():
|
||||
|
||||
# Create parent workflow with interception
|
||||
parent = ParentOrchestrator(approved_domains={"example.com", "internal.org"})
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, id="email_workflow")
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
|
||||
parent_request_info = RequestInfoExecutor(id="request_info")
|
||||
|
||||
main_workflow = (
|
||||
@@ -275,7 +275,7 @@ async def test_conditional_forwarding() -> None:
|
||||
return RequestResponse[DomainCheckRequest, bool].forward()
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
self.result = result
|
||||
|
||||
# Setup workflows
|
||||
@@ -291,7 +291,7 @@ async def test_conditional_forwarding() -> None:
|
||||
)
|
||||
|
||||
parent = ConditionalParent()
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, id="email_workflow")
|
||||
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
|
||||
parent_request_info = RequestInfoExecutor(id="request_info")
|
||||
|
||||
main_workflow = (
|
||||
@@ -358,7 +358,7 @@ async def test_workflow_scoped_interception() -> None:
|
||||
return RequestResponse[DomainCheckRequest, bool].forward()
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
self.results[result.email] = result
|
||||
|
||||
# Create two identical sub-workflows
|
||||
@@ -377,8 +377,8 @@ async def test_workflow_scoped_interception() -> None:
|
||||
workflow_b = create_validation_workflow()
|
||||
|
||||
parent = MultiWorkflowParent()
|
||||
executor_a = WorkflowExecutor(workflow_a, id="workflow_a")
|
||||
executor_b = WorkflowExecutor(workflow_b, id="workflow_b")
|
||||
executor_a = WorkflowExecutor(workflow_a, "workflow_a")
|
||||
executor_b = WorkflowExecutor(workflow_b, "workflow_b")
|
||||
parent_request_info = RequestInfoExecutor(id="request_info")
|
||||
|
||||
main_workflow = (
|
||||
@@ -407,9 +407,102 @@ async def test_workflow_scoped_interception() -> None:
|
||||
assert parent.results["user@random.com"].is_valid is True
|
||||
|
||||
|
||||
async def test_concurrent_sub_workflow_execution() -> None:
|
||||
"""Test that WorkflowExecutor can handle multiple concurrent invocations properly."""
|
||||
|
||||
class ConcurrentProcessor(Executor):
|
||||
"""Processor that sends multiple concurrent requests to the same sub-workflow."""
|
||||
|
||||
results: list[ValidationResult] = Field(default_factory=list)
|
||||
|
||||
def __init__(self, **kwargs: Any):
|
||||
super().__init__(id="concurrent_processor", **kwargs)
|
||||
|
||||
@handler
|
||||
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
"""Send multiple concurrent requests to the same sub-workflow."""
|
||||
# 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")
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect results from concurrent executions."""
|
||||
self.results.append(result)
|
||||
|
||||
# Create sub-workflow for email validation
|
||||
email_validator = EmailValidator()
|
||||
email_request_info = RequestInfoExecutor(id="email_request_info")
|
||||
|
||||
validation_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(email_validator)
|
||||
.add_edge(email_validator, email_request_info)
|
||||
.add_edge(email_request_info, email_validator)
|
||||
.build()
|
||||
)
|
||||
|
||||
# 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 SubWorkflowResponse routing
|
||||
.build()
|
||||
)
|
||||
|
||||
# Test concurrent execution with multiple emails
|
||||
emails = [
|
||||
"user1@domain1.com",
|
||||
"user2@domain2.com",
|
||||
"user3@domain3.com",
|
||||
"user4@domain4.com",
|
||||
"user5@domain5.com",
|
||||
]
|
||||
|
||||
result = await main_workflow.run(emails)
|
||||
|
||||
# Each email should generate one external request
|
||||
request_events = result.get_request_info_events()
|
||||
assert len(request_events) == len(emails)
|
||||
|
||||
# Verify each request corresponds to the correct domain
|
||||
domains_requested = {event.data.domain for event in request_events} # type: ignore[union-attr]
|
||||
expected_domains = {f"domain{i}.com" for i in range(1, 6)}
|
||||
assert domains_requested == expected_domains
|
||||
|
||||
# Send responses for all requests (approve all domains)
|
||||
responses = {event.request_id: True for event in request_events}
|
||||
await main_workflow.send_responses(responses)
|
||||
|
||||
# All results should be collected
|
||||
assert len(processor.results) == len(emails)
|
||||
|
||||
# Verify each email was processed correctly
|
||||
result_emails = {result.email for result in processor.results}
|
||||
expected_emails = set(emails)
|
||||
assert result_emails == expected_emails
|
||||
|
||||
# All should be valid since we approved all domains
|
||||
for result_obj in processor.results:
|
||||
assert result_obj.is_valid is True
|
||||
assert result_obj.reason == "Domain approved"
|
||||
|
||||
# Verify that concurrent executions were properly isolated
|
||||
# (This is implicitly tested by the fact that we got correct results for all emails)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests
|
||||
asyncio.run(test_basic_sub_workflow())
|
||||
asyncio.run(test_sub_workflow_with_interception())
|
||||
asyncio.run(test_conditional_forwarding())
|
||||
asyncio.run(test_workflow_scoped_interception())
|
||||
asyncio.run(test_concurrent_sub_workflow_execution())
|
||||
|
||||
@@ -19,7 +19,6 @@ from agent_framework import (
|
||||
validate_workflow_graph,
|
||||
)
|
||||
from agent_framework._workflow._edge import SingleEdgeGroup
|
||||
from agent_framework._workflow._validation import HandlerOutputAnnotationError
|
||||
|
||||
|
||||
class StringExecutor(Executor):
|
||||
@@ -51,8 +50,8 @@ class AnyExecutor(Executor):
|
||||
|
||||
class NoOutputTypesExecutor(Executor):
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[Any]) -> None:
|
||||
await ctx.send_message("processed")
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.send_message("processed") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class MultiTypeExecutor(Executor):
|
||||
@@ -575,58 +574,33 @@ def test_validation_enum_usage() -> None:
|
||||
|
||||
|
||||
def test_handler_ctx_missing_annotation_raises() -> None:
|
||||
class BadExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
# Validation now happens at handler registration time, not workflow build time
|
||||
with pytest.raises(ValueError) as exc:
|
||||
|
||||
start = StringExecutor(id="s")
|
||||
bad = BadExecutor(id="b")
|
||||
class BadExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
with pytest.raises(HandlerOutputAnnotationError) as exc:
|
||||
WorkflowBuilder().add_edge(start, bad).set_start_executor(start).build()
|
||||
|
||||
assert exc.value.validation_type == ValidationTypeEnum.HANDLER_OUTPUT_ANNOTATION
|
||||
assert "missing type annotation" in str(exc.value)
|
||||
|
||||
|
||||
def test_handler_ctx_unsubscripted_workflow_context_raises() -> None:
|
||||
class BadExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext) -> None: # type: ignore # missing T
|
||||
pass
|
||||
|
||||
start = StringExecutor(id="s")
|
||||
bad = BadExecutor(id="b")
|
||||
|
||||
with pytest.raises(HandlerOutputAnnotationError) as exc:
|
||||
WorkflowBuilder().add_edge(start, bad).set_start_executor(start).build()
|
||||
|
||||
assert exc.value.validation_type == ValidationTypeEnum.HANDLER_OUTPUT_ANNOTATION
|
||||
# Message should mention missing T or WorkflowContext[None]
|
||||
assert "WorkflowContext[None]" in str(exc.value) or "missing" in str(exc.value).lower()
|
||||
assert "must have a WorkflowContext" in str(exc.value)
|
||||
|
||||
|
||||
def test_handler_ctx_invalid_t_out_entries_raises() -> None:
|
||||
class BadExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[123]) -> None: # type: ignore[valid-type]
|
||||
pass
|
||||
# Validation now happens at handler registration time, not workflow build time
|
||||
with pytest.raises(ValueError) as exc:
|
||||
|
||||
start = StringExecutor(id="s")
|
||||
bad = BadExecutor(id="b")
|
||||
class BadExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[123]) -> None: # type: ignore[valid-type]
|
||||
pass
|
||||
|
||||
with pytest.raises(HandlerOutputAnnotationError) as exc:
|
||||
WorkflowBuilder().add_edge(start, bad).set_start_executor(start).build()
|
||||
|
||||
assert exc.value.validation_type == ValidationTypeEnum.HANDLER_OUTPUT_ANNOTATION
|
||||
assert "invalid entries" in str(exc.value)
|
||||
assert "invalid type entry" in str(exc.value)
|
||||
|
||||
|
||||
def test_handler_ctx_none_is_allowed() -> None:
|
||||
class NoneExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle(self, message: str, ctx: WorkflowContext) -> None:
|
||||
# does not emit
|
||||
return None
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class MockExecutor(Executor):
|
||||
"""A mock executor for testing purposes."""
|
||||
|
||||
@handler
|
||||
async def mock_handler(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
async def mock_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""A mock handler that does nothing."""
|
||||
pass
|
||||
|
||||
@@ -20,7 +20,7 @@ class ListStrTargetExecutor(Executor):
|
||||
"""A mock executor that accepts a list of strings (for fan-in targets)."""
|
||||
|
||||
@handler
|
||||
async def handle(self, message: list[str], ctx: WorkflowContext[None]) -> None: # type: ignore[type-arg]
|
||||
async def handle(self, message: list[str], ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ from agent_framework import (
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
|
||||
@@ -36,20 +38,20 @@ class IncrementExecutor(Executor):
|
||||
increment: int = 1
|
||||
|
||||
@handler
|
||||
async def mock_handler(self, message: NumberMessage, ctx: WorkflowContext[NumberMessage]) -> None:
|
||||
async def mock_handler(self, message: NumberMessage, ctx: WorkflowContext[NumberMessage, int]) -> None:
|
||||
if message.data < self.limit:
|
||||
await ctx.send_message(NumberMessage(data=message.data + self.increment))
|
||||
else:
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=message.data))
|
||||
await ctx.yield_output(message.data)
|
||||
|
||||
|
||||
class AggregatorExecutor(Executor):
|
||||
"""A mock executor that aggregates results from multiple executors."""
|
||||
|
||||
@handler
|
||||
async def mock_handler(self, messages: list[NumberMessage], ctx: WorkflowContext[Any]) -> None:
|
||||
# This mock simply returns the data incremented by 1
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=sum(msg.data for msg in messages)))
|
||||
async def mock_handler(self, messages: list[NumberMessage], ctx: WorkflowContext[Any, int]) -> None:
|
||||
# This mock simply returns the sum of the data
|
||||
await ctx.yield_output(sum(msg.data for msg in messages))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,18 +72,21 @@ class MockExecutorRequestApproval(Executor):
|
||||
|
||||
@handler
|
||||
async def mock_handler_b(
|
||||
self, message: RequestResponse[RequestInfoMessage, ApprovalMessage], ctx: WorkflowContext[NumberMessage]
|
||||
self,
|
||||
message: RequestResponse[RequestInfoMessage, 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:
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=data))
|
||||
await ctx.yield_output(data)
|
||||
else:
|
||||
await ctx.send_message(NumberMessage(data=data))
|
||||
|
||||
|
||||
async def test_workflow_run_streaming():
|
||||
async def test_workflow_run_streaming() -> None:
|
||||
"""Test the workflow run stream."""
|
||||
executor_a = IncrementExecutor(id="executor_a")
|
||||
executor_b = IncrementExecutor(id="executor_b")
|
||||
@@ -97,7 +102,7 @@ async def test_workflow_run_streaming():
|
||||
result: int | None = None
|
||||
async for event in workflow.run_stream(NumberMessage(data=0)):
|
||||
assert isinstance(event, WorkflowEvent)
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
result = event.data
|
||||
|
||||
assert result is not None and result == 10
|
||||
@@ -136,9 +141,9 @@ async def test_workflow_run():
|
||||
)
|
||||
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
completed_event = events.get_completed_event()
|
||||
assert isinstance(completed_event, WorkflowCompletedEvent)
|
||||
assert completed_event.data == 10
|
||||
assert events.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs = events.get_outputs()
|
||||
assert outputs[0] == 10
|
||||
|
||||
|
||||
async def test_workflow_run_not_completed():
|
||||
@@ -182,13 +187,18 @@ async def test_workflow_send_responses_streaming():
|
||||
|
||||
assert request_info_event is not None
|
||||
result: int | None = None
|
||||
completed = False
|
||||
async for event in workflow.send_responses_streaming({
|
||||
request_info_event.request_id: ApprovalMessage(approved=True)
|
||||
}):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
result = event.data
|
||||
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
|
||||
assert result is not None and result == 1 # The data should be incremented by 1 from the initial message
|
||||
assert (
|
||||
completed and result is not None and result == 1
|
||||
) # The data should be incremented by 1 from the initial message
|
||||
|
||||
|
||||
async def test_workflow_send_responses():
|
||||
@@ -214,9 +224,9 @@ async def test_workflow_send_responses():
|
||||
|
||||
result = await workflow.send_responses({request_info_events[0].request_id: ApprovalMessage(approved=True)})
|
||||
|
||||
completed_event = result.get_completed_event()
|
||||
assert isinstance(completed_event, WorkflowCompletedEvent)
|
||||
assert completed_event.data == 1 # The data should be incremented by 1 from the initial message
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs = result.get_outputs()
|
||||
assert outputs[0] == 1 # The data should be incremented by 1 from the initial message
|
||||
|
||||
|
||||
async def test_fan_out():
|
||||
@@ -232,11 +242,12 @@ async def test_fan_out():
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
|
||||
# executor_b will also emit a WorkflowCompletedEvent
|
||||
# executor_b will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
|
||||
assert len(events) == 7
|
||||
|
||||
completed_event = events.get_completed_event()
|
||||
assert completed_event is not None and completed_event.data == 1
|
||||
assert events.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs = events.get_outputs()
|
||||
assert outputs[0] == 1
|
||||
|
||||
|
||||
async def test_fan_out_multiple_completed_events():
|
||||
@@ -252,11 +263,12 @@ async def test_fan_out_multiple_completed_events():
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
|
||||
# executor_a and executor_b will also emit a WorkflowCompletedEvent
|
||||
# executor_b and executor_c will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
|
||||
assert len(events) == 8
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
events.get_completed_event()
|
||||
# Multiple outputs are expected from both executors
|
||||
outputs = events.get_outputs()
|
||||
assert len(outputs) == 2
|
||||
|
||||
|
||||
async def test_fan_in():
|
||||
@@ -277,18 +289,19 @@ async def test_fan_in():
|
||||
events = await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
|
||||
# aggregator will also emit a WorkflowCompletedEvent
|
||||
# aggregator will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
|
||||
assert len(events) == 9
|
||||
|
||||
completed_event = events.get_completed_event()
|
||||
assert completed_event is not None and completed_event.data == 4
|
||||
assert events.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs = events.get_outputs()
|
||||
assert outputs[0] == 4 # executor_a(0->1), both executor_b and executor_c(1->2), aggregator(2+2=4)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_executor() -> Executor:
|
||||
class SimpleExecutor(Executor):
|
||||
@handler
|
||||
async def handle_message(self, message: str, context: WorkflowContext[None]) -> None:
|
||||
async def handle_message(self, message: str, context: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
return SimpleExecutor(id="test_executor")
|
||||
@@ -442,7 +455,7 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
|
||||
# Test non-streaming run_from_checkpoint method
|
||||
result = await workflow.run_from_checkpoint(checkpoint_id)
|
||||
assert isinstance(result, list) # Should return WorkflowRunResult which extends list
|
||||
assert hasattr(result, "get_completed_event") # Should have WorkflowRunResult methods
|
||||
assert hasattr(result, "get_outputs") # Should have WorkflowRunResult methods
|
||||
|
||||
|
||||
async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executor: Executor):
|
||||
@@ -498,7 +511,7 @@ class StateTrackingExecutor(Executor):
|
||||
"""An executor that tracks state in shared state to test context reset behavior."""
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: StateTrackingMessage, ctx: WorkflowContext[Any]) -> None:
|
||||
async def handle_message(self, message: StateTrackingMessage, ctx: WorkflowContext[Any, list]) -> None:
|
||||
"""Handle the message and track it in shared state."""
|
||||
# Get existing messages from shared state
|
||||
try:
|
||||
@@ -513,8 +526,8 @@ class StateTrackingExecutor(Executor):
|
||||
# Update shared state
|
||||
await ctx.set_shared_state("processed_messages", existing_messages)
|
||||
|
||||
# Complete workflow with current shared state
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=existing_messages.copy())) # type: ignore
|
||||
# Yield output
|
||||
await ctx.yield_output(existing_messages.copy()) # type: ignore
|
||||
|
||||
|
||||
async def test_workflow_multiple_runs_no_state_collision():
|
||||
@@ -536,27 +549,27 @@ async def test_workflow_multiple_runs_no_state_collision():
|
||||
|
||||
# Run 1: Should only see messages from run 1
|
||||
result1 = await workflow.run(StateTrackingMessage(data="message1", run_id="run1"))
|
||||
completed1 = result1.get_completed_event()
|
||||
assert completed1 is not None
|
||||
assert completed1.data == ["run1:message1"]
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs1 = result1.get_outputs()
|
||||
assert outputs1[0] == ["run1:message1"]
|
||||
|
||||
# Run 2: Should only see messages from run 2, not run 1
|
||||
result2 = await workflow.run(StateTrackingMessage(data="message2", run_id="run2"))
|
||||
completed2 = result2.get_completed_event()
|
||||
assert completed2 is not None
|
||||
assert completed2.data == ["run2:message2"] # Should NOT contain run1 data
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs2 = result2.get_outputs()
|
||||
assert outputs2[0] == ["run2:message2"] # Should NOT contain run1 data
|
||||
|
||||
# Run 3: Should only see messages from run 3
|
||||
result3 = await workflow.run(StateTrackingMessage(data="message3", run_id="run3"))
|
||||
completed3 = result3.get_completed_event()
|
||||
assert completed3 is not None
|
||||
assert completed3.data == ["run3:message3"] # Should NOT contain run1 or run2 data
|
||||
assert result3.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs3 = result3.get_outputs()
|
||||
assert outputs3[0] == ["run3:message3"] # Should NOT contain run1 or run2 data
|
||||
|
||||
# Verify that each run only processed its own message
|
||||
# This confirms that the checkpointable context properly resets between runs
|
||||
assert completed1.data != completed2.data
|
||||
assert completed2.data != completed3.data
|
||||
assert completed1.data != completed3.data
|
||||
assert outputs1[0] != outputs2[0]
|
||||
assert outputs2[0] != outputs3[0]
|
||||
assert outputs1[0] != outputs3[0]
|
||||
|
||||
|
||||
async def test_comprehensive_edge_groups_workflow():
|
||||
@@ -604,17 +617,17 @@ async def test_comprehensive_edge_groups_workflow():
|
||||
# router(2->3) -> switch routes to proc_a -> proc_a(3->4) -> fanout_hub(4->5)
|
||||
# -> [parallel_1(5->8), parallel_2(5->10)] -> aggregator(8+10=18)
|
||||
events_small = await workflow.run(NumberMessage(data=2))
|
||||
completed_small = events_small.get_completed_event()
|
||||
assert completed_small is not None
|
||||
assert completed_small.data == 18 # Exact expected result: 8+10 from parallel processors
|
||||
assert events_small.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs_small = events_small.get_outputs()
|
||||
assert outputs_small[0] == 18 # Exact expected result: 8+10 from parallel processors
|
||||
|
||||
# Test with large number (should go through processor_b)
|
||||
# router(8->9) -> switch routes to proc_b -> proc_b(9->11) -> fanout_hub(11->12)
|
||||
# -> [parallel_1(12->15), parallel_2(12->17)] -> aggregator(15+17=32)
|
||||
events_large = await workflow.run(NumberMessage(data=8))
|
||||
completed_large = events_large.get_completed_event()
|
||||
assert completed_large is not None
|
||||
assert completed_large.data == 32 # Exact expected result: 15+17 from parallel processors
|
||||
assert events_large.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs_large = events_large.get_outputs()
|
||||
assert outputs_large[0] == 32 # Exact expected result: 15+17 from parallel processors
|
||||
|
||||
# The key verification is that we successfully executed a workflow using all three edge group types
|
||||
# and that both switch-case paths work (small vs large numbers)
|
||||
@@ -624,9 +637,9 @@ async def test_comprehensive_edge_groups_workflow():
|
||||
assert len(events_large) >= 6
|
||||
|
||||
# Verify different paths were taken by checking exact results
|
||||
assert completed_small.data == 18, f"Small number path should result in 18, got {completed_small.data}"
|
||||
assert completed_large.data == 32, f"Large number path should result in 32, got {completed_large.data}"
|
||||
assert completed_small.data != completed_large.data, "Different paths should produce different results"
|
||||
assert outputs_small[0] == 18, f"Small number path should result in 18, got {outputs_small[0]}"
|
||||
assert outputs_large[0] == 32, f"Large number path should result in 32, got {outputs_large[0]}"
|
||||
assert outputs_small[0] != outputs_large[0], "Different paths should produce different results"
|
||||
|
||||
# Both tests should complete successfully, proving all edge group types work
|
||||
|
||||
@@ -660,11 +673,9 @@ async def test_workflow_with_simple_cycle_and_exit_condition():
|
||||
# Test the cycle
|
||||
# Expected: exec_a(2->4) -> exec_b(4->5) -> exec_a(5->7, completes because 7 >= 6)
|
||||
events = await workflow.run(NumberMessage(data=2))
|
||||
completed_event = events.get_completed_event()
|
||||
assert completed_event is not None
|
||||
assert (
|
||||
completed_event.data is not None and completed_event.data >= 6
|
||||
) # Should complete when executor_a reaches its limit
|
||||
assert events.get_final_state() == WorkflowRunState.IDLE
|
||||
outputs = events.get_outputs()
|
||||
assert outputs[0] is not None and outputs[0] >= 6 # Should complete when executor_a reaches its limit
|
||||
|
||||
# Verify cycling occurred (should have events from both executors)
|
||||
# Check for ExecutorInvokedEvent and ExecutorCompletedEvent types that have executor_id
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
WorkflowCompletedEvent,
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
executor,
|
||||
handler,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -57,8 +62,203 @@ async def test_executor_cannot_emit_framework_lifecycle_event(caplog: "LogCaptur
|
||||
|
||||
async def test_executor_emits_normal_event() -> None:
|
||||
async with make_context() as (ctx, runner_ctx):
|
||||
await ctx.add_event(WorkflowCompletedEvent("done"))
|
||||
# Create a normal event to test event emission
|
||||
await ctx.add_event(_TestEvent())
|
||||
|
||||
events: list[WorkflowEvent] = await runner_ctx.drain_events()
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], WorkflowCompletedEvent)
|
||||
assert isinstance(events[0], _TestEvent)
|
||||
|
||||
|
||||
class _TestEvent(WorkflowEvent):
|
||||
pass
|
||||
|
||||
|
||||
async def test_workflow_context_type_annotations_no_parameter() -> None:
|
||||
# Test function-based executor
|
||||
@executor(id="func1")
|
||||
async def func1(text: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.add_event(_TestEvent())
|
||||
|
||||
wf = WorkflowBuilder().set_start_executor(func1).build()
|
||||
events = await wf.run("hello")
|
||||
test_events = [e for e in events if isinstance(e, _TestEvent)]
|
||||
assert len(test_events) == 1
|
||||
|
||||
# Test class-based executor
|
||||
class _exec1(Executor):
|
||||
@handler
|
||||
async def func1(self, text: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.add_event(_TestEvent())
|
||||
|
||||
executor1 = _exec1(id="exec1")
|
||||
|
||||
assert executor1.input_types == [str]
|
||||
assert executor1.output_types == []
|
||||
assert executor1.workflow_output_types == []
|
||||
|
||||
wf2 = WorkflowBuilder().set_start_executor(executor1).build()
|
||||
events2 = await wf2.run("hello")
|
||||
test_events2 = [e for e in events2 if isinstance(e, _TestEvent)]
|
||||
assert len(test_events2) == 1
|
||||
|
||||
|
||||
async def test_workflow_context_type_annotations_message_type_parameter() -> None:
|
||||
# Test function-based executor
|
||||
@executor(id="func1")
|
||||
async def func1(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message("world")
|
||||
|
||||
@executor(id="func2")
|
||||
async def func2(text: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.add_event(_TestEvent(data=text))
|
||||
|
||||
wf = WorkflowBuilder().add_edge(func1, func2).set_start_executor(func1).build()
|
||||
events = await wf.run("hello")
|
||||
test_events = [e for e in events if isinstance(e, _TestEvent)]
|
||||
assert len(test_events) == 1
|
||||
assert test_events[0].data == "world"
|
||||
|
||||
# Test class-based executor
|
||||
class _exec1(Executor):
|
||||
@handler
|
||||
async def func1(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message("world")
|
||||
|
||||
class _exec2(Executor):
|
||||
@handler
|
||||
async def func2(self, text: str, ctx: WorkflowContext) -> None:
|
||||
await ctx.add_event(_TestEvent(data=text))
|
||||
|
||||
executor1 = _exec1(id="exec1")
|
||||
executor2 = _exec2(id="exec2")
|
||||
|
||||
assert executor1.input_types == [str]
|
||||
assert executor1.output_types == [str]
|
||||
assert executor1.workflow_output_types == []
|
||||
assert executor2.input_types == [str]
|
||||
assert executor2.output_types == []
|
||||
assert executor2.workflow_output_types == []
|
||||
|
||||
wf2 = WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor1).build()
|
||||
events2 = await wf2.run("hello")
|
||||
test_events2 = [e for e in events2 if isinstance(e, _TestEvent)]
|
||||
assert len(test_events2) == 1
|
||||
assert test_events2[0].data == "world"
|
||||
|
||||
|
||||
async def test_workflow_context_type_annotations_message_and_output_type_parameters() -> None:
|
||||
# Test function-based executor
|
||||
@executor(id="func1")
|
||||
async def func1(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message("world")
|
||||
|
||||
@executor(id="func2")
|
||||
async def func2(text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.add_event(_TestEvent(data=text))
|
||||
await ctx.yield_output(text)
|
||||
|
||||
wf = WorkflowBuilder().add_edge(func1, func2).set_start_executor(func1).build()
|
||||
events = await wf.run("hello")
|
||||
outputs = events.get_outputs()
|
||||
assert len(outputs) == 1
|
||||
assert outputs[0] == "world"
|
||||
|
||||
# Test class-based executor
|
||||
class _exec1(Executor):
|
||||
@handler
|
||||
async def func1(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message("world")
|
||||
|
||||
class _exec2(Executor):
|
||||
@handler
|
||||
async def func2(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.add_event(_TestEvent(data=text))
|
||||
await ctx.yield_output(text)
|
||||
|
||||
executor1 = _exec1(id="exec1")
|
||||
executor2 = _exec2(id="exec2")
|
||||
|
||||
assert executor1.input_types == [str]
|
||||
assert executor1.output_types == [str]
|
||||
assert executor1.workflow_output_types == []
|
||||
assert executor2.input_types == [str]
|
||||
assert executor2.output_types == []
|
||||
assert executor2.workflow_output_types == [str]
|
||||
|
||||
wf2 = WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor1).build()
|
||||
events2 = await wf2.run("hello")
|
||||
outputs2 = events2.get_outputs()
|
||||
assert len(outputs2) == 1
|
||||
assert outputs2[0] == "world"
|
||||
|
||||
|
||||
async def test_workflow_context_type_annotations_any() -> None:
|
||||
class _exec1(Executor):
|
||||
@handler
|
||||
async def func1(self, text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
await ctx.add_event(_TestEvent())
|
||||
await ctx.send_message(123)
|
||||
|
||||
executor1 = _exec1(id="exec1")
|
||||
assert executor1.input_types == [str]
|
||||
assert executor1.output_types == [Any]
|
||||
|
||||
class _exec2(Executor):
|
||||
@handler
|
||||
async def func2(self, number: int, ctx: WorkflowContext[Any, Any]) -> None:
|
||||
await ctx.add_event(_TestEvent())
|
||||
await ctx.send_message(456)
|
||||
await ctx.yield_output(3.14)
|
||||
|
||||
executor2 = _exec2(id="exec2")
|
||||
assert executor2.input_types == [int]
|
||||
assert executor2.output_types == [Any]
|
||||
assert executor2.workflow_output_types == [Any]
|
||||
|
||||
|
||||
async def test_workflow_context_missing_annotation_error() -> None:
|
||||
"""Test that missing WorkflowContext annotation raises appropriate error."""
|
||||
import pytest
|
||||
|
||||
# Test function-based executor with missing ctx annotation
|
||||
with pytest.raises(ValueError, match="must have a WorkflowContext"):
|
||||
|
||||
@executor(id="bad_func")
|
||||
async def bad_func(text: str, ctx) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Test class-based executor with missing ctx annotation
|
||||
with pytest.raises(ValueError, match="must have a WorkflowContext"):
|
||||
|
||||
class _BadExecutor(Executor):
|
||||
@handler
|
||||
async def bad_handler(self, text: str, ctx) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
|
||||
async def test_workflow_context_invalid_type_parameter_error() -> None:
|
||||
"""Test that invalid type parameters like int values raise appropriate errors."""
|
||||
import pytest
|
||||
|
||||
# Test function-based executor with invalid type parameter (int value instead of type)
|
||||
with pytest.raises(ValueError, match="invalid type entry"):
|
||||
|
||||
@executor(id="bad_func")
|
||||
async def bad_func(text: str, ctx: WorkflowContext[123]) -> None: # type: ignore[valid-type]
|
||||
pass
|
||||
|
||||
# Test class-based executor with invalid type parameter
|
||||
with pytest.raises(ValueError, match="invalid type entry"):
|
||||
|
||||
class _BadExecutor(Executor):
|
||||
@handler
|
||||
async def bad_handler(self, text: str, ctx: WorkflowContext[456]) -> None: # type: ignore[valid-type]
|
||||
pass
|
||||
|
||||
# Test two-parameter WorkflowContext with invalid workflow output type
|
||||
with pytest.raises(ValueError, match="invalid type entry"):
|
||||
|
||||
@executor(id="bad_func2")
|
||||
async def bad_func2(text: str, ctx: WorkflowContext[str, 789]) -> None: # type: ignore[valid-type]
|
||||
pass
|
||||
|
||||
@@ -48,7 +48,7 @@ class SecondExecutor(Executor):
|
||||
self._processed_messages: list[str] = []
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Handle string messages."""
|
||||
self._processed_messages.append(message)
|
||||
|
||||
@@ -87,7 +87,7 @@ class FanInAggregator(Executor):
|
||||
self._processed_messages: list[Any] = []
|
||||
|
||||
@handler
|
||||
async def handle_aggregated_data(self, messages: list[str], ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_aggregated_data(self, messages: list[str], ctx: WorkflowContext) -> None:
|
||||
# Process aggregated messages from fan-in
|
||||
aggregated = f"aggregated: {', '.join(messages)}"
|
||||
self._processed_messages.append(aggregated)
|
||||
@@ -196,7 +196,14 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
|
||||
assert message.source_span_id is not None
|
||||
|
||||
# Test executor trace context handling
|
||||
await executor.execute("test message", workflow_ctx)
|
||||
await executor.execute(
|
||||
"test message",
|
||||
["source"], # source_executor_ids
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
trace_contexts=[{"traceparent": "00-12345678901234567890123456789012-1234567890123456-01"}],
|
||||
source_span_ids=["1234567890123456"],
|
||||
)
|
||||
|
||||
# Check that spans were created with proper attributes
|
||||
spans = span_exporter.get_finished_spans()
|
||||
@@ -372,7 +379,7 @@ async def test_workflow_error_handling_in_tracing(span_exporter: InMemorySpanExp
|
||||
super().__init__(id="failing_executor")
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext) -> None:
|
||||
raise ValueError("Test error")
|
||||
|
||||
failing_executor = FailingExecutor()
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
@@ -15,7 +16,6 @@ from agent_framework import (
|
||||
SharedState,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEventSource,
|
||||
WorkflowFailedEvent,
|
||||
@@ -32,7 +32,7 @@ class FailingExecutor(Executor):
|
||||
"""Executor that raises at runtime to test failure signaling."""
|
||||
|
||||
@handler
|
||||
async def fail(self, msg: int, ctx: WorkflowContext[None]) -> None: # pragma: no cover - invoked via workflow
|
||||
async def fail(self, msg: int, ctx: WorkflowContext) -> None: # pragma: no cover - invoked via workflow
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
@@ -57,14 +57,14 @@ async def test_executor_failed_and_workflow_failed_events_streaming():
|
||||
async def test_executor_failed_event_emitted_on_direct_execute():
|
||||
failing = FailingExecutor(id="f")
|
||||
ctx = InProcRunnerContext()
|
||||
wf_ctx: WFContext[None] = WFContext(
|
||||
executor_id=failing.id,
|
||||
source_executor_ids=["START"],
|
||||
shared_state=SharedState(),
|
||||
runner_context=ctx,
|
||||
)
|
||||
shared_state = SharedState()
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await failing.execute(0, wf_ctx)
|
||||
await failing.execute(
|
||||
0,
|
||||
["START"],
|
||||
shared_state,
|
||||
ctx,
|
||||
)
|
||||
drained = await ctx.drain_events()
|
||||
failed = [e for e in drained if isinstance(e, ExecutorFailedEvent)]
|
||||
assert failed
|
||||
@@ -98,17 +98,17 @@ class Completer(Executor):
|
||||
"""Executor that completes immediately with provided data for testing."""
|
||||
|
||||
@handler
|
||||
async def run(self, msg: str, ctx: WorkflowContext[str]) -> None: # pragma: no cover
|
||||
await ctx.add_event(WorkflowCompletedEvent(msg))
|
||||
async def run(self, msg: str, ctx: WorkflowContext[Never, str]) -> None: # pragma: no cover
|
||||
await ctx.yield_output(msg)
|
||||
|
||||
|
||||
async def test_completed_status_streaming():
|
||||
c = Completer(id="c")
|
||||
wf = WorkflowBuilder().set_start_executor(c).build()
|
||||
events = [ev async for ev in wf.run_stream("ok")] # no raise
|
||||
# Last status should be COMPLETED
|
||||
# Last status should be IDLE
|
||||
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
assert status and status[-1].state == WorkflowRunState.COMPLETED
|
||||
assert status and status[-1].state == WorkflowRunState.IDLE
|
||||
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in status)
|
||||
|
||||
|
||||
@@ -120,8 +120,12 @@ async def test_started_and_completed_event_origins():
|
||||
started = next(e for e in events if isinstance(e, WorkflowStartedEvent))
|
||||
assert started.origin is WorkflowEventSource.FRAMEWORK
|
||||
|
||||
completed = next(e for e in events if isinstance(e, WorkflowCompletedEvent))
|
||||
assert completed.origin is WorkflowEventSource.EXECUTOR
|
||||
# Check for IDLE status indicating completion
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None
|
||||
)
|
||||
assert idle_status is not None
|
||||
assert idle_status.origin is WorkflowEventSource.FRAMEWORK
|
||||
|
||||
|
||||
async def test_non_streaming_final_state_helpers():
|
||||
@@ -129,7 +133,7 @@ async def test_non_streaming_final_state_helpers():
|
||||
c = Completer(id="c")
|
||||
wf1 = WorkflowBuilder().set_start_executor(c).build()
|
||||
result1: WorkflowRunResult = await wf1.run("done")
|
||||
assert result1.get_final_state() == WorkflowRunState.COMPLETED
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Idle-with-pending-request case
|
||||
req = Requester(id="req")
|
||||
@@ -145,7 +149,7 @@ async def test_run_includes_status_events_completed():
|
||||
result: WorkflowRunResult = await wf.run("ok")
|
||||
timeline = result.status_timeline()
|
||||
assert timeline, "Expected status timeline in non-streaming run() results"
|
||||
assert timeline[-1].state == WorkflowRunState.COMPLETED
|
||||
assert timeline[-1].state == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_run_includes_status_events_idle_with_requests():
|
||||
|
||||
@@ -7,6 +7,7 @@ from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -42,14 +43,15 @@ class ReverseTextExecutor(Executor):
|
||||
"""An executor that reverses text."""
|
||||
|
||||
@handler
|
||||
async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
async def reverse_text(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
"""Execute the task by reversing the input string."""
|
||||
print(f"ReverseTextExecutor: Processing '{text}'")
|
||||
result = text[::-1]
|
||||
print(f"ReverseTextExecutor: Result '{result}'")
|
||||
|
||||
# Send the result with a workflow completion event.
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
# Yield the output and signal workflow completion.
|
||||
await ctx.yield_output(result)
|
||||
await ctx.add_event(WorkflowCompletedEvent())
|
||||
|
||||
|
||||
async def run_sequential_workflow() -> None:
|
||||
@@ -84,17 +86,17 @@ async def run_sequential_workflow() -> None:
|
||||
input_text = "hello world"
|
||||
print(f"Starting workflow with input: '{input_text}'")
|
||||
|
||||
completion_event = None
|
||||
output_event = None
|
||||
async for event in workflow.run_stream(input_text):
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
# The WorkflowCompletedEvent contains the final result.
|
||||
completion_event = event
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
# The WorkflowOutputEvent contains the final result.
|
||||
output_event = event
|
||||
|
||||
if completion_event:
|
||||
print(f"Workflow completed with result: '{completion_event.data}'")
|
||||
if output_event:
|
||||
print(f"Workflow completed with result: '{output_event.data}'")
|
||||
else:
|
||||
print("Workflow completed without a completion event")
|
||||
print("Workflow completed without an output event")
|
||||
|
||||
except Exception as e:
|
||||
current_span.record_exception(e)
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
handler,
|
||||
@@ -17,18 +18,28 @@ Step 1: Foundational patterns: Executors and edges
|
||||
What this example shows
|
||||
- Two ways to define a unit of work (an Executor node):
|
||||
1) Custom class that subclasses Executor with an async method marked by @handler.
|
||||
Signature: (text: str, ctx: WorkflowContext[str]) -> None. The typed ctx
|
||||
advertises the type this node emits via ctx.send_message(...).
|
||||
Possible handler signatures:
|
||||
- (text: str, ctx: WorkflowContext) -> None,
|
||||
- (text: str, ctx: WorkflowContext[str]) -> None, or
|
||||
- (text: str, ctx: WorkflowContext[Never, str]) -> None.
|
||||
The first parameter is the typed input to this node, the input type is str here.
|
||||
The second parameter is a WorkflowContext[T_Out, T_W_Out].
|
||||
WorkflowContext[T_Out] is used for nodes that send messages to downstream nodes with ctx.send_message(T_Out).
|
||||
WorkflowContext[T_Out, T_W_Out] is used for nodes that also yield workflow
|
||||
output with ctx.yield_output(T_W_Out).
|
||||
WorkflowContext without type parameters is equivalent to WorkflowContext[Never, Never], meaning this node
|
||||
neither sends messages to downstream nodes nor yields workflow output.
|
||||
|
||||
2) Standalone async function decorated with @executor using the same signature.
|
||||
Simple steps can use this form; a terminal step can emit a
|
||||
WorkflowCompletedEvent to end the workflow.
|
||||
Simple steps can use this form; a terminal step can yield output
|
||||
using ctx.yield_output() to provide workflow results.
|
||||
|
||||
- Fluent WorkflowBuilder API:
|
||||
add_edge(A, B) to connect nodes, set_start_executor(A), then build() -> Workflow.
|
||||
|
||||
- Running and results:
|
||||
workflow.run(initial_input) executes the graph. The last node emits a
|
||||
WorkflowCompletedEvent that carries the final result.
|
||||
workflow.run(initial_input) executes the graph. Terminal nodes yield
|
||||
outputs using ctx.yield_output(). The workflow runs until idle.
|
||||
|
||||
Prerequisites
|
||||
- No external services required.
|
||||
@@ -43,8 +54,8 @@ Prerequisites
|
||||
#
|
||||
# Handler signature contract:
|
||||
# - First parameter is the typed input to this node (here: text: str)
|
||||
# - Second parameter is a WorkflowContext[T], where T is the type of data this
|
||||
# node will emit via ctx.send_message (here: T is str)
|
||||
# - Second parameter is a WorkflowContext[T_Out], where T_Out is the type of data this
|
||||
# node will emit via ctx.send_message (here: T_Out is str)
|
||||
#
|
||||
# Within a handler you typically:
|
||||
# - Compute a result
|
||||
@@ -70,22 +81,25 @@ class UpperCase(Executor):
|
||||
# -----------------------------------------------
|
||||
#
|
||||
# For simple steps you can skip subclassing and define an async function with the
|
||||
# same signature pattern (typed input + WorkflowContext[T]) and decorate it with
|
||||
# same signature pattern (typed input + WorkflowContext[T_Out, T_W_Out]) and decorate it with
|
||||
# @executor. This creates a fully functional node that can be wired into a flow.
|
||||
|
||||
|
||||
@executor(id="reverse_text_executor")
|
||||
async def reverse_text(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Reverse the input string and signal workflow completion.
|
||||
async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Reverse the input string and yield the workflow output.
|
||||
|
||||
This node emits a terminal event using ctx.add_event(WorkflowCompletedEvent).
|
||||
The data carried by the WorkflowCompletedEvent becomes the final result of
|
||||
the workflow (returned by workflow.run(...)).
|
||||
This node yields the final output using ctx.yield_output(result).
|
||||
The workflow will complete when it becomes idle (no more work to do).
|
||||
|
||||
The WorkflowContext is parameterized with two types:
|
||||
- T_Out = Never: this node does not send messages to downstream nodes.
|
||||
- T_W_Out = str: this node yields workflow output of type str.
|
||||
"""
|
||||
result = text[::-1]
|
||||
|
||||
# Send the result with a workflow completion event.
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
# Yield the output - the workflow will complete when idle
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -100,17 +114,17 @@ async def main():
|
||||
workflow = WorkflowBuilder().add_edge(upper_case, reverse_text).set_start_executor(upper_case).build()
|
||||
|
||||
# Run the workflow by sending the initial message to the start node.
|
||||
# The run(...) call returns an event collection; its get_completed_event()
|
||||
# provides the WorkflowCompletedEvent emitted by the terminal node.
|
||||
# The run(...) call returns an event collection; its get_outputs() method
|
||||
# retrieves the outputs yielded by any terminal nodes.
|
||||
events = await workflow.run("hello world")
|
||||
print(events.get_completed_event())
|
||||
print(events.get_outputs())
|
||||
# Summarize the final run state (e.g., COMPLETED)
|
||||
print("Final state:", events.get_final_state())
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
WorkflowCompletedEvent(data=DLROW OLLEH)
|
||||
['DLROW OLLEH']
|
||||
Final state: WorkflowRunState.COMPLETED
|
||||
"""
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ This sample uses two custom executors. A Writer agent creates or edits content,
|
||||
then hands the conversation to a Reviewer agent which evaluates and finalizes the result.
|
||||
|
||||
Purpose:
|
||||
Show how to wrap chat agents created by AzureChatClient inside workflow executors. Demonstrate the @handler pattern
|
||||
with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder, and finish
|
||||
by emitting a WorkflowCompletedEvent from the terminal node.
|
||||
Show how to wrap chat agents created by AzureChatClient inside workflow executors. Demonstrate how agents
|
||||
automatically yield outputs when they complete, removing the need for explicit completion events.
|
||||
The workflow completes when it becomes idle.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient with required environment variables.
|
||||
@@ -51,14 +51,12 @@ async def main():
|
||||
# Run the workflow with the user's initial message.
|
||||
# For foundational clarity, use run (non streaming) and print the terminal event.
|
||||
events = await workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.")
|
||||
# The terminal node emits a WorkflowCompletedEvent; print its contents.
|
||||
|
||||
# Print interim-agent run events
|
||||
# Print agent run events and final outputs
|
||||
for event in events:
|
||||
if isinstance(event, AgentRunEvent):
|
||||
print(f"{event.executor_id}: {event.data}")
|
||||
|
||||
print(f"{'=' * 60}\n{events.get_completed_event()}")
|
||||
print(f"{'=' * 60}\nWorkflow Outputs: {events.get_outputs()}")
|
||||
# Summarize the final run state (e.g., COMPLETED)
|
||||
print("Final state:", events.get_final_state())
|
||||
|
||||
@@ -74,14 +72,13 @@ async def main():
|
||||
- Consider specifying "SUV" for clarity in some uses.
|
||||
- Strong, upbeat tone suitable for marketing.
|
||||
============================================================
|
||||
Workflow Completed Event:
|
||||
WorkflowCompletedEvent(data=Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
|
||||
Workflow Outputs: ['Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
|
||||
|
||||
**Feedback:**s
|
||||
**Feedback:**
|
||||
- Clear focus on affordability and enjoyment.
|
||||
- "Plug into fun" connects emotionally and highlights electric nature.
|
||||
- Consider specifying "SUV" for clarity in some uses.
|
||||
- Strong, upbeat tone suitable for marketing.)
|
||||
- Strong, upbeat tone suitable for marketing.']
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
ChatAgent,
|
||||
ChatMessage,
|
||||
Executor,
|
||||
ExecutorFailedEvent,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflow._events import WorkflowOutputEvent
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -28,9 +30,9 @@ The workflow is invoked with run_stream so you can observe events as they occur.
|
||||
Purpose:
|
||||
Show how to wrap chat agents created by AzureChatClient inside workflow executors, wire them with WorkflowBuilder,
|
||||
and consume streaming events from the workflow. Demonstrate the @handler pattern with typed inputs and typed
|
||||
WorkflowContext[T] outputs, and finish by emitting a WorkflowCompletedEvent from the terminal node while printing
|
||||
intermediate events for observability. The streaming loop also surfaces WorkflowEvent.origin so you can
|
||||
distinguish runner-generated lifecycle events from executor-generated data-plane events.
|
||||
WorkflowContext[T_Out, T_W_Out] outputs. Agents automatically yield outputs when they complete.
|
||||
The streaming loop also surfaces WorkflowEvent.origin so you can distinguish runner-generated lifecycle events
|
||||
from executor-generated data-plane events.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient with required environment variables.
|
||||
@@ -96,14 +98,14 @@ class Reviewer(Executor):
|
||||
super().__init__(agent=agent, id=id)
|
||||
|
||||
@handler
|
||||
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[str]) -> None:
|
||||
"""Review the full conversation transcript and complete with a final string.
|
||||
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Review the full conversation transcript and yield the final output.
|
||||
|
||||
This node consumes all messages so far. It uses its agent to produce the final text,
|
||||
then signals completion by adding a WorkflowCompletedEvent to the event stream.
|
||||
then yields the output. The workflow completes when it becomes idle.
|
||||
"""
|
||||
response = await self.agent.run(messages)
|
||||
await ctx.add_event(WorkflowCompletedEvent(response.text))
|
||||
await ctx.yield_output(response.text)
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -119,7 +121,7 @@ async def main():
|
||||
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
|
||||
|
||||
# Run the workflow with the user's initial message and stream events as they occur.
|
||||
# In addition to executor events and WorkflowCompletedEvent, this also surfaces run-state and errors.
|
||||
# This surfaces executor events, workflow outputs, run-state changes, and errors.
|
||||
async for event in workflow.run_stream(
|
||||
ChatMessage(role="user", text="Create a slogan for a new electric SUV that is affordable and fun to drive.")
|
||||
):
|
||||
@@ -127,8 +129,6 @@ async def main():
|
||||
prefix = f"State ({event.origin.value}): "
|
||||
if event.state == WorkflowRunState.IN_PROGRESS:
|
||||
print(prefix + "IN_PROGRESS")
|
||||
elif event.state == WorkflowRunState.COMPLETED:
|
||||
print(prefix + "COMPLETED")
|
||||
elif event.state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
|
||||
print(prefix + "IN_PROGRESS_PENDING_REQUESTS (requests in flight)")
|
||||
elif event.state == WorkflowRunState.IDLE:
|
||||
@@ -137,8 +137,8 @@ async def main():
|
||||
print(prefix + "IDLE_WITH_PENDING_REQUESTS (prompt user or UI now)")
|
||||
else:
|
||||
print(prefix + str(event.state))
|
||||
elif isinstance(event, WorkflowCompletedEvent):
|
||||
print(f"Workflow completed ({event.origin.value}): {event.data}")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print(f"Workflow output ({event.origin.value}): {event.data}")
|
||||
elif isinstance(event, ExecutorFailedEvent):
|
||||
print(
|
||||
f"Executor failed ({event.origin.value}): "
|
||||
@@ -157,9 +157,9 @@ async def main():
|
||||
ExecutorInvokeEvent (RUNNER): ExecutorInvokeEvent(executor_id=writer)
|
||||
ExecutorCompletedEvent (RUNNER): ExecutorCompletedEvent(executor_id=writer)
|
||||
ExecutorInvokeEvent (RUNNER): ExecutorInvokeEvent(executor_id=reviewer)
|
||||
Workflow completed (EXECUTOR): Drive the Future. Affordable Adventure, Electrified.
|
||||
Workflow output (EXECUTOR): Drive the Future. Affordable Adventure, Electrified.
|
||||
ExecutorCompletedEvent (RUNNER): ExecutorCompletedEvent(executor_id=reviewer)
|
||||
State (RUNNER): COMPLETED
|
||||
State (RUNNER): IDLE
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
|
||||
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowOutputEvent
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -18,7 +18,7 @@ Show how to wire chat agents directly into a WorkflowBuilder pipeline where agen
|
||||
Demonstrate:
|
||||
- Automatic streaming of agent deltas via AgentRunUpdateEvent.
|
||||
- A simple console aggregator that groups updates by executor id and prints them as they arrive.
|
||||
- A final WorkflowCompletedEvent that contains the reviewer outcome after both agents finish.
|
||||
- The workflow completes when idle and outputs are available in events.get_outputs().
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient with required environment variables.
|
||||
@@ -54,12 +54,10 @@ async def main():
|
||||
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
|
||||
|
||||
# Stream events from the workflow. We aggregate partial token updates per executor for readable output.
|
||||
completed_event: WorkflowCompletedEvent | None = None
|
||||
last_executor_id = None
|
||||
|
||||
async for event in workflow.run_stream(
|
||||
"Create a slogan for a new electric SUV that is affordable and fun to drive."
|
||||
):
|
||||
events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
|
||||
async for event in events:
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
# AgentRunUpdateEvent contains incremental text deltas from the underlying agent.
|
||||
# Print a prefix when the executor changes, then append updates on the same line.
|
||||
@@ -70,14 +68,9 @@ async def main():
|
||||
print(f"{eid}:", end=" ", flush=True)
|
||||
last_executor_id = eid
|
||||
print(event.data, end="", flush=True)
|
||||
elif isinstance(event, WorkflowCompletedEvent):
|
||||
# Terminal event with the final reviewer output.
|
||||
completed_event = event
|
||||
|
||||
# Print the final consolidated reviewer result.
|
||||
if completed_event:
|
||||
print("\n===== Final Output =====")
|
||||
print(completed_event.data)
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("===== Final Output =====")
|
||||
print(event.data)
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
@@ -7,7 +7,6 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -23,7 +22,7 @@ then hands the conversation to a Reviewer agent which evaluates and finalizes th
|
||||
Purpose:
|
||||
Show how to wrap chat agents created by AzureChatClient inside workflow executors. Demonstrate the @handler pattern
|
||||
with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder, and finish
|
||||
by emitting a WorkflowCompletedEvent from the terminal node.
|
||||
by yielding outputs from the terminal node.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient with required environment variables.
|
||||
@@ -53,7 +52,7 @@ class Writer(Executor):
|
||||
super().__init__(agent=agent, id=id)
|
||||
|
||||
@handler
|
||||
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage], str]) -> None:
|
||||
"""Generate content using the agent and forward the updated conversation.
|
||||
|
||||
Contract for this handler:
|
||||
@@ -79,7 +78,7 @@ class Reviewer(Executor):
|
||||
|
||||
This class demonstrates:
|
||||
- Consuming a typed payload produced upstream.
|
||||
- Emitting a terminal WorkflowCompletedEvent with the final text outcome.
|
||||
- Yielding the final text outcome to complete the workflow.
|
||||
"""
|
||||
|
||||
agent: ChatAgent
|
||||
@@ -94,14 +93,14 @@ class Reviewer(Executor):
|
||||
super().__init__(agent=agent, id=id)
|
||||
|
||||
@handler
|
||||
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[str]) -> None:
|
||||
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage], str]) -> None:
|
||||
"""Review the full conversation transcript and complete with a final string.
|
||||
|
||||
This node consumes all messages so far. It uses its agent to produce the final text,
|
||||
then signals completion by adding a WorkflowCompletedEvent to the event stream.
|
||||
then signals completion by yielding the output.
|
||||
"""
|
||||
response = await self.agent.run(messages)
|
||||
await ctx.add_event(WorkflowCompletedEvent(response.text))
|
||||
await ctx.yield_output(response.text)
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -118,12 +117,14 @@ async def main():
|
||||
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
|
||||
|
||||
# Run the workflow with the user's initial message.
|
||||
# For foundational clarity, use run (non streaming) and print the terminal event.
|
||||
# For foundational clarity, use run (non streaming) and print the workflow output.
|
||||
events = await workflow.run(
|
||||
ChatMessage(role="user", text="Create a slogan for a new electric SUV that is affordable and fun to drive.")
|
||||
)
|
||||
# The terminal node emits a WorkflowCompletedEvent; print its contents.
|
||||
print(events.get_completed_event())
|
||||
# The terminal node yields output; print its contents.
|
||||
outputs = events.get_outputs()
|
||||
if outputs:
|
||||
print(outputs[-1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
|
||||
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowOutputEvent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
|
||||
@@ -21,7 +21,7 @@ Show how to wire chat agents directly into a WorkflowBuilder pipeline where agen
|
||||
Demonstrate:
|
||||
- Automatic streaming of agent deltas via AgentRunUpdateEvent.
|
||||
- A simple console aggregator that groups updates by executor id and prints them as they arrive.
|
||||
- A final WorkflowCompletedEvent that contains the reviewer outcome after both agents finish.
|
||||
- The workflow completes when idle and outputs are available in events.get_outputs().
|
||||
|
||||
Prerequisites:
|
||||
- Foundry Agent Service configured, along with the required environment variables.
|
||||
@@ -69,12 +69,10 @@ async def main() -> None:
|
||||
|
||||
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
last_executor_id: str | None = None
|
||||
|
||||
async for event in workflow.run_stream(
|
||||
"Create a slogan for a new electric SUV that is affordable and fun to drive."
|
||||
):
|
||||
events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
|
||||
async for event in events:
|
||||
if isinstance(event, AgentRunUpdateEvent):
|
||||
eid = event.executor_id
|
||||
if eid != last_executor_id:
|
||||
@@ -83,13 +81,9 @@ async def main() -> None:
|
||||
print(f"{eid}:", end=" ", flush=True)
|
||||
last_executor_id = eid
|
||||
print(event.data, end="", flush=True)
|
||||
elif isinstance(event, WorkflowCompletedEvent):
|
||||
completed = event
|
||||
|
||||
if completed:
|
||||
print("\n===== Final Output =====")
|
||||
print(completed.data)
|
||||
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("\n===== Final output =====")
|
||||
print(event.data)
|
||||
finally:
|
||||
await close()
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ 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.
|
||||
forwards that response back to the Worker. The workflow completes when idle.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI account configured and accessible for OpenAIChatClient.
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ This sample demonstrates how to wrap a workflow as an agent using WorkflowAgent.
|
||||
It uses a reflection pattern where a Worker executor generates responses and a
|
||||
Reviewer executor evaluates them. If the response is not approved, the Worker
|
||||
regenerates the output based on feedback until the Reviewer approves it. Only
|
||||
approved responses are emitted to the external consumer.
|
||||
approved responses are emitted to the external consumer. The workflow completes when idle.
|
||||
|
||||
Key Concepts Demonstrated:
|
||||
- WorkflowAgent: Wraps a workflow to behave like a regular agent.
|
||||
|
||||
+31
-27
@@ -19,8 +19,8 @@ from agent_framework import (
|
||||
RequestResponse,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
@@ -87,7 +87,7 @@ class BriefPreparer(Executor):
|
||||
self._agent_id = agent_id
|
||||
|
||||
@handler
|
||||
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
|
||||
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest, str]) -> None:
|
||||
# Collapse errant whitespace so the prompt is stable between runs.
|
||||
normalized = " ".join(brief.split()).strip()
|
||||
if not normalized.endswith("."):
|
||||
@@ -133,7 +133,7 @@ class ReviewGateway(Executor):
|
||||
async def on_agent_response(
|
||||
self,
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[HumanApprovalRequest],
|
||||
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
|
||||
@@ -157,7 +157,7 @@ class ReviewGateway(Executor):
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanApprovalRequest, str],
|
||||
ctx: WorkflowContext[AgentExecutorRequest | 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.
|
||||
@@ -190,11 +190,11 @@ class FinaliseExecutor(Executor):
|
||||
"""Publishes the approved text."""
|
||||
|
||||
@handler
|
||||
async def publish(self, text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
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_state({"published_text": text})
|
||||
# Emit a workflow completion event so the runner stops cleanly.
|
||||
await ctx.add_event(WorkflowCompletedEvent(text))
|
||||
# Yield the final output so the workflow completes cleanly.
|
||||
await ctx.yield_output(text)
|
||||
|
||||
|
||||
def create_workflow(*, checkpoint_storage: FileCheckpointStorage | None = None) -> "Workflow":
|
||||
@@ -264,17 +264,17 @@ def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
print(line)
|
||||
|
||||
|
||||
def _print_events(events: list[Any]) -> tuple[WorkflowCompletedEvent | None, list[tuple[str, HumanApprovalRequest]]]:
|
||||
def _print_events(events: list[Any]) -> tuple[str | None, list[tuple[str, HumanApprovalRequest]]]:
|
||||
"""Echo workflow events to the console and collect outstanding requests."""
|
||||
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed_output: str | None = None
|
||||
requests: list[tuple[str, HumanApprovalRequest]] = []
|
||||
|
||||
for event in events:
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completed = event
|
||||
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
|
||||
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))
|
||||
@@ -284,7 +284,7 @@ def _print_events(events: list[Any]) -> tuple[WorkflowCompletedEvent | None, lis
|
||||
}:
|
||||
print(f"Workflow state: {event.state.name}")
|
||||
|
||||
return completed, requests
|
||||
return completed_output, requests
|
||||
|
||||
|
||||
def _prompt_for_responses(requests: list[tuple[str, HumanApprovalRequest]]) -> dict[str, str] | None:
|
||||
@@ -350,14 +350,14 @@ async def _consume(stream: AsyncIterable[Any]) -> list[Any]:
|
||||
return [event async for event in stream]
|
||||
|
||||
|
||||
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> WorkflowCompletedEvent | None:
|
||||
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> str | None:
|
||||
"""Run the workflow until it either finishes or pauses for human input."""
|
||||
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed_output: str | None = None
|
||||
first = True
|
||||
|
||||
while completed is None:
|
||||
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.
|
||||
@@ -369,10 +369,11 @@ async def run_interactive_session(workflow: "Workflow", initial_message: str) ->
|
||||
else:
|
||||
break
|
||||
|
||||
completed, requests = _print_events(events)
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending_responses = _prompt_for_responses(requests)
|
||||
|
||||
return completed
|
||||
return completed_output
|
||||
|
||||
|
||||
async def resume_from_checkpoint(
|
||||
@@ -391,21 +392,24 @@ async def resume_from_checkpoint(
|
||||
responses=pre_supplied,
|
||||
)
|
||||
)
|
||||
completed, requests = _print_events(events)
|
||||
if pre_supplied and not requests and completed is None:
|
||||
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 is None and pending:
|
||||
while completed_output is None and pending:
|
||||
events = await _consume(workflow.send_responses_streaming(pending))
|
||||
completed, requests = _print_events(events)
|
||||
pending = _prompt_for_responses(requests)
|
||||
completed_output, requests = _print_events(events)
|
||||
if completed_output is None:
|
||||
pending = _prompt_for_responses(requests)
|
||||
else:
|
||||
break
|
||||
|
||||
if completed:
|
||||
print(f"Workflow completed with: {completed.data}")
|
||||
if completed_output:
|
||||
print(f"Workflow completed with: {completed_output}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -427,7 +431,7 @@ 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.data}")
|
||||
print(f"Initial run completed with final copy: {completed}")
|
||||
else:
|
||||
print("Initial run paused for human input.")
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from agent_framework import (
|
||||
RequestInfoExecutor,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -49,6 +48,7 @@ What you learn:
|
||||
- How to list and inspect checkpoints programmatically.
|
||||
- How to interactively choose a checkpoint to resume from (instead of always resuming
|
||||
from the most recent or a hard-coded one) using run_stream_from_checkpoint.
|
||||
- How workflows complete by yielding outputs when idle, not via explicit completion events.
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI or Azure OpenAI available for AzureChatClient.
|
||||
@@ -115,10 +115,10 @@ class SubmitToLowerAgent(Executor):
|
||||
|
||||
|
||||
class FinalizeFromAgent(Executor):
|
||||
"""Consumes the AgentExecutorResponse and emits the terminal WorkflowCompletedEvent."""
|
||||
"""Consumes the AgentExecutorResponse and yields the final result."""
|
||||
|
||||
@handler
|
||||
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any]) -> None:
|
||||
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any, str]) -> None:
|
||||
result = response.agent_run_response.text or ""
|
||||
|
||||
# Persist executor-local state for auditability when inspecting checkpoints.
|
||||
@@ -130,8 +130,8 @@ class FinalizeFromAgent(Executor):
|
||||
"final": True,
|
||||
})
|
||||
|
||||
# Emit a terminal event so external consumers see the final value.
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
# Yield the final result so external consumers see the final value.
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
class ReverseTextExecutor(Executor):
|
||||
@@ -185,6 +185,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> "Workflow":
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
|
||||
"""Display human-friendly checkpoint metadata using framework summaries."""
|
||||
|
||||
@@ -297,7 +298,6 @@ async def main():
|
||||
Event: ExecutorInvokeEvent(executor_id=submit_lower)
|
||||
Event: ExecutorInvokeEvent(executor_id=lower_agent)
|
||||
Event: ExecutorInvokeEvent(executor_id=finalize)
|
||||
Event: WorkflowCompletedEvent(data=dlrow olleh)
|
||||
|
||||
Checkpoint summary:
|
||||
- dfc63e72-8e8d-454f-9b6d-0d740b9062e6 | label='after_initial_execution' | iter=0 | messages=1 | states=['upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
|
||||
@@ -316,7 +316,6 @@ async def main():
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=submit_lower)
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=lower_agent)
|
||||
Resumed Event: ExecutorInvokeEvent(executor_id=finalize)
|
||||
Resumed Event: WorkflowCompletedEvent(data=dlrow olleh)
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
@@ -20,6 +21,7 @@ Sample: Sub-Workflows (Basics)
|
||||
What it does:
|
||||
- Shows how a parent workflow invokes a sub-workflow via `WorkflowExecutor` and collects results.
|
||||
- Example: parent orchestrates multiple text processors that count words/characters.
|
||||
- Demonstrates how sub-workflows complete by yielding outputs when processing is done.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required.
|
||||
@@ -60,7 +62,9 @@ class TextProcessor(Executor):
|
||||
super().__init__(id="text_processor")
|
||||
|
||||
@handler
|
||||
async def process_text(self, request: TextProcessingRequest, ctx: WorkflowContext[TextProcessingResult]) -> None:
|
||||
async def process_text(
|
||||
self, request: TextProcessingRequest, ctx: WorkflowContext[Never, TextProcessingResult]
|
||||
) -> None:
|
||||
"""Process a text string and return statistics."""
|
||||
text_preview = f"'{request.text[:50]}{'...' if len(request.text) > 50 else ''}'"
|
||||
print(f"🔍 Sub-workflow processing text (Task {request.task_id}): {text_preview}")
|
||||
@@ -80,8 +84,8 @@ class TextProcessor(Executor):
|
||||
)
|
||||
|
||||
print(f"✅ Sub-workflow completed task {request.task_id}")
|
||||
# Signal completion
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
# Signal completion by yielding the result
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
# Parent workflow
|
||||
@@ -110,7 +114,7 @@ class TextProcessingOrchestrator(Executor):
|
||||
await ctx.send_message(request, target_id="text_processor_workflow")
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext) -> None:
|
||||
"""Collect results from sub-workflows."""
|
||||
print(f"📥 Collected result from {result.task_id}")
|
||||
self.results.append(result)
|
||||
@@ -173,7 +177,7 @@ async def main():
|
||||
print("=" * 60)
|
||||
|
||||
# Step 4: Run the workflow
|
||||
result = await main_workflow.run(test_texts)
|
||||
await main_workflow.run(test_texts)
|
||||
|
||||
# Step 5: Display results
|
||||
print("\n📊 Processing Results:")
|
||||
|
||||
+18
-16
@@ -4,11 +4,12 @@ import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
RequestInfoExecutor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -138,6 +139,7 @@ class ResourceRequester(Executor):
|
||||
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(
|
||||
@@ -164,7 +166,7 @@ class ResourceRequester(Executor):
|
||||
async def handle_resource_response(
|
||||
self,
|
||||
response: RequestResponse[ResourceRequest, ResourceResponse],
|
||||
ctx: WorkflowContext[None],
|
||||
ctx: WorkflowContext[Never, RequestFinished],
|
||||
) -> None:
|
||||
"""Handle resource allocation response."""
|
||||
if response.data:
|
||||
@@ -174,12 +176,12 @@ class ResourceRequester(Executor):
|
||||
f"from {response.data.source}"
|
||||
)
|
||||
if self._collect_results():
|
||||
# Emit completion event and send RequestFinished to the parent workflow.
|
||||
await ctx.add_event(WorkflowCompletedEvent(RequestFinished()))
|
||||
# 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[None]
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[Never, RequestFinished]
|
||||
) -> None:
|
||||
"""Handle policy check response."""
|
||||
if response.data:
|
||||
@@ -189,8 +191,8 @@ class ResourceRequester(Executor):
|
||||
f"{response.data.approved} - {response.data.reason}"
|
||||
)
|
||||
if self._collect_results():
|
||||
# Emit completion event and send RequestFinished to the parent workflow.
|
||||
await ctx.add_event(WorkflowCompletedEvent(RequestFinished()))
|
||||
# Yield completion result to the parent workflow.
|
||||
await ctx.yield_output(RequestFinished())
|
||||
|
||||
def _collect_results(self) -> bool:
|
||||
"""Collect and summarize results."""
|
||||
@@ -213,7 +215,7 @@ class ResourceCache(Executor):
|
||||
|
||||
@intercepts_request
|
||||
async def check_cache(
|
||||
self, request: ResourceRequest, ctx: WorkflowContext[None]
|
||||
self, request: ResourceRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[ResourceRequest, ResourceResponse]:
|
||||
"""Intercept RESOURCE requests and check cache first."""
|
||||
print(f"🏪 CACHE interceptor checking: {request.amount} {request.resource_type}")
|
||||
@@ -234,7 +236,7 @@ class ResourceCache(Executor):
|
||||
|
||||
@handler
|
||||
async def collect_result(
|
||||
self, response: RequestResponse[ResourceRequest, ResourceResponse], ctx: WorkflowContext[None]
|
||||
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
|
||||
@@ -263,7 +265,7 @@ class PolicyEngine(Executor):
|
||||
|
||||
@intercepts_request
|
||||
async def check_policy(
|
||||
self, request: PolicyCheckRequest, ctx: WorkflowContext[None]
|
||||
self, request: PolicyCheckRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[PolicyCheckRequest, PolicyResponse]:
|
||||
"""Intercept POLICY requests and apply rules."""
|
||||
print(f"🛡️ POLICY interceptor checking: {request.amount} {request.resource_type}, policy={request.policy_type}")
|
||||
@@ -286,7 +288,7 @@ class PolicyEngine(Executor):
|
||||
|
||||
@handler
|
||||
async def collect_policy_result(
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[None]
|
||||
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext
|
||||
) -> None:
|
||||
"""Collect policy results from external requests that were forwarded."""
|
||||
if response.data:
|
||||
@@ -299,15 +301,15 @@ class Coordinator(Executor):
|
||||
super().__init__(id="coordinator")
|
||||
|
||||
@handler
|
||||
async def start(self, requests: list[dict[str, Any]], ctx: WorkflowContext[object]) -> None:
|
||||
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")
|
||||
|
||||
@handler
|
||||
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext) -> None:
|
||||
"""Handle sub-workflow completion.
|
||||
|
||||
It comes from the sub-workflow emitted WorkflowCompletionEvent's data field.
|
||||
It comes from the sub-workflow yielded output.
|
||||
"""
|
||||
print("🎯 Main workflow received completion.")
|
||||
|
||||
@@ -377,10 +379,10 @@ async def main() -> None:
|
||||
|
||||
# 8. Run the workflow
|
||||
print("🎬 Running workflow...")
|
||||
result = await main_workflow.run(test_requests)
|
||||
events = await main_workflow.run(test_requests)
|
||||
|
||||
# 9. Handle any external requests that couldn't be intercepted
|
||||
request_events = result.get_request_info_events()
|
||||
request_events = events.get_request_info_events()
|
||||
if request_events:
|
||||
print(f"\n🌐 Handling {len(request_events)} external request(s)...")
|
||||
|
||||
|
||||
+10
-9
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
@@ -10,7 +9,6 @@ from agent_framework import (
|
||||
RequestInfoMessage,
|
||||
RequestResponse,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowExecutor,
|
||||
handler,
|
||||
@@ -43,6 +41,7 @@ Key concepts demonstrated:
|
||||
- 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`).
|
||||
@@ -101,7 +100,9 @@ class EmailValidator(Executor):
|
||||
|
||||
@handler
|
||||
async def validate_request(
|
||||
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest | ValidationResult]
|
||||
self,
|
||||
request: EmailValidationRequest,
|
||||
ctx: WorkflowContext[DomainCheckRequest | ValidationResult, ValidationResult],
|
||||
) -> None:
|
||||
"""Validate an email address."""
|
||||
print(f"🔍 Sub-workflow validating email: {request.email}")
|
||||
@@ -112,7 +113,7 @@ class EmailValidator(Executor):
|
||||
if not domain:
|
||||
print(f"❌ Invalid email format: {request.email}")
|
||||
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
await ctx.yield_output(result)
|
||||
return
|
||||
|
||||
print(f"🌐 Sub-workflow requesting domain check for: {domain}")
|
||||
@@ -126,7 +127,7 @@ class EmailValidator(Executor):
|
||||
async def handle_domain_response(
|
||||
self,
|
||||
response: RequestResponse[DomainCheckRequest, bool],
|
||||
ctx: WorkflowContext[ValidationResult],
|
||||
ctx: WorkflowContext[ValidationResult, ValidationResult],
|
||||
) -> None:
|
||||
"""Handle domain check response from RequestInfo with correlation."""
|
||||
approved = bool(response.data)
|
||||
@@ -151,7 +152,7 @@ class EmailValidator(Executor):
|
||||
reason="Domain approved" if approved else "Domain not approved",
|
||||
)
|
||||
print(f"✅ Sub-workflow completing validation for: {email}")
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=result))
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
# 3. Implement the parent workflow with request interception
|
||||
@@ -181,7 +182,7 @@ class SmartEmailOrchestrator(Executor):
|
||||
|
||||
@intercepts_request
|
||||
async def check_domain(
|
||||
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
|
||||
self, request: DomainCheckRequest, ctx: WorkflowContext
|
||||
) -> RequestResponse[DomainCheckRequest, bool]:
|
||||
"""Intercept domain check requests from sub-workflows."""
|
||||
print(f"🔍 Parent intercepting domain check for: {request.domain}")
|
||||
@@ -192,8 +193,8 @@ class SmartEmailOrchestrator(Executor):
|
||||
return RequestResponse[DomainCheckRequest, bool].forward()
|
||||
|
||||
@handler
|
||||
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
|
||||
"""Collect validation results. It comes from the sub-workflow emitted WorkflowCompletionEvent's data field."""
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,8 @@ import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import ( # Core chat primitives used to build requests
|
||||
AgentExecutor, # Wraps an LLM agent that can be invoked inside a workflow
|
||||
AgentExecutorRequest, # Input message bundle for an AgentExecutor
|
||||
@@ -11,7 +13,6 @@ from agent_framework import ( # Core chat primitives used to build requests
|
||||
ChatMessage,
|
||||
Role,
|
||||
WorkflowBuilder, # Fluent builder for wiring executors and edges
|
||||
WorkflowCompletedEvent, # Event we emit at the end to signal completion
|
||||
WorkflowContext, # Per-run context and event bus
|
||||
executor, # Decorator to declare a Python function as a workflow executor
|
||||
)
|
||||
@@ -41,15 +42,16 @@ and have the Azure OpenAI environment variables set as documented in the getting
|
||||
High level flow:
|
||||
1) spam_detection_agent reads an email and returns DetectionResult.
|
||||
2) If not spam, we transform the detection output into a user message for email_assistant_agent, then finish by
|
||||
sending the drafted reply.
|
||||
3) If spam, we short circuit to a spam handler that emits a completion event.
|
||||
yielding the drafted reply as workflow output.
|
||||
3) If spam, we short circuit to a spam handler that yields a spam notice as workflow output.
|
||||
|
||||
Output:
|
||||
- The final WorkflowCompletedEvent is printed to stdout, either with a drafted reply or a spam notice.
|
||||
- The final workflow output is printed to stdout, either with a drafted reply or a spam notice.
|
||||
|
||||
Notes:
|
||||
- Conditions read the agent response text and validate it into DetectionResult for robust routing.
|
||||
- Executors are small and single purpose to keep control flow easy to follow.
|
||||
- The workflow completes when it becomes idle, not via explicit completion events.
|
||||
"""
|
||||
|
||||
|
||||
@@ -96,18 +98,18 @@ def get_condition(expected_result: bool):
|
||||
|
||||
|
||||
@executor(id="send_email")
|
||||
async def handle_email_response(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
# Downstream of the email assistant. Parse a validated EmailResponse and emit a completion event.
|
||||
async def handle_email_response(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Downstream of the email assistant. Parse a validated EmailResponse and yield the workflow output.
|
||||
email_response = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent:\n{email_response.response}"))
|
||||
await ctx.yield_output(f"Email sent:\n{email_response.response}")
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam_classifier_response(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
# Spam path. Confirm the DetectionResult and finish with the reason. Guard against accidental non spam input.
|
||||
async def handle_spam_classifier_response(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Spam path. Confirm the DetectionResult and yield the workflow output. Guard against accidental non spam input.
|
||||
detection = DetectionResult.model_validate_json(response.agent_run_response.text)
|
||||
if detection.is_spam:
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
|
||||
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
|
||||
else:
|
||||
# This indicates the routing predicate and executor contract are out of sync.
|
||||
raise RuntimeError("This executor should only handle spam messages.")
|
||||
@@ -184,11 +186,12 @@ async def main() -> None:
|
||||
email = email_file.read()
|
||||
|
||||
# Execute the workflow. Since the start is an AgentExecutor, pass an AgentExecutorRequest.
|
||||
# run_stream yields events as they occur. We watch for the terminal WorkflowCompletedEvent and print it.
|
||||
# The workflow completes when it becomes idle (no more work to do).
|
||||
request = AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email)], should_respond=True)
|
||||
async for event in workflow.run_stream(request):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
print(f"{event}")
|
||||
events = await workflow.run(request)
|
||||
outputs = events.get_outputs()
|
||||
if outputs:
|
||||
print(f"Workflow output: {outputs[0]}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
@@ -214,7 +217,7 @@ async def main() -> None:
|
||||
(555) 123-4567
|
||||
----------------------------------------
|
||||
|
||||
WorkflowCompletedEvent(data=Email sent:
|
||||
Workflow output: Email sent:
|
||||
Hi Alex,
|
||||
|
||||
Thank you for the follow-up and for summarizing the action items from this morning's meeting. The points you listed accurately reflect our discussion, and I don't have any additional items to add at this time.
|
||||
@@ -224,7 +227,7 @@ async def main() -> None:
|
||||
Thank you again for outlining the next steps.
|
||||
|
||||
Best regards,
|
||||
Sarah)
|
||||
Sarah
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
|
||||
+19
-16
@@ -8,6 +8,8 @@ from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
AgentExecutorRequest,
|
||||
@@ -15,9 +17,9 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
executor,
|
||||
)
|
||||
from agent_framework.azure import AzureChatClient
|
||||
@@ -30,7 +32,7 @@ Sample: Multi-Selection Edge Group for email triage and response.
|
||||
The workflow stores an email,
|
||||
classifies it as NotSpam, Spam, or Uncertain, and then routes to one or more branches.
|
||||
Non-spam emails are drafted into replies, long ones are also summarized, spam is blocked, and uncertain cases are
|
||||
flagged. Each path ends with simulated database persistence.
|
||||
flagged. Each path ends with simulated database persistence. The workflow completes when it becomes idle.
|
||||
|
||||
Purpose:
|
||||
Demonstrate how to use a multi-selection edge group to fan out from one executor to multiple possible targets.
|
||||
@@ -123,9 +125,9 @@ async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowConte
|
||||
|
||||
|
||||
@executor(id="finalize_and_send")
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
|
||||
await ctx.yield_output(f"Email sent: {parsed.response}")
|
||||
|
||||
|
||||
@executor(id="summarize_email")
|
||||
@@ -155,28 +157,26 @@ async def merge_summary(response: AgentExecutorResponse, ctx: WorkflowContext[An
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
if analysis.spam_decision == "Spam":
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {analysis.reason}"))
|
||||
await ctx.yield_output(f"Email marked as spam: {analysis.reason}")
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Spam messages.")
|
||||
|
||||
|
||||
@executor(id="handle_uncertain")
|
||||
async def handle_uncertain(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_uncertain(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
if analysis.spam_decision == "Uncertain":
|
||||
email: Email | None = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
|
||||
await ctx.add_event(
|
||||
WorkflowCompletedEvent(
|
||||
f"Email marked as uncertain: {analysis.reason}. Email content: {getattr(email, 'email_content', '')}"
|
||||
)
|
||||
await ctx.yield_output(
|
||||
f"Email marked as uncertain: {analysis.reason}. Email content: {getattr(email, 'email_content', '')}"
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Uncertain messages.")
|
||||
|
||||
|
||||
@executor(id="database_access")
|
||||
async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Simulate DB writes for email and analysis (and summary if present)
|
||||
await asyncio.sleep(0.05)
|
||||
await ctx.add_event(DatabaseEvent(f"Email {analysis.email_id} saved to database."))
|
||||
@@ -263,14 +263,18 @@ async def main() -> None:
|
||||
print("Unable to find resource file, using default text.")
|
||||
email = "Hello team, here are the updates for this week..."
|
||||
|
||||
# Print outputs and database events from streaming
|
||||
async for event in workflow.run_stream(email):
|
||||
if isinstance(event, (WorkflowCompletedEvent, DatabaseEvent)):
|
||||
if isinstance(event, DatabaseEvent):
|
||||
print(f"{event}")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print(f"Workflow output: {event.data}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
WorkflowCompletedEvent(data=Email sent: Hi Alex,
|
||||
DatabaseEvent(data=Email 32021432-2d4e-4c54-b04c-f81b4120340c saved to database.)
|
||||
Workflow output: Email sent: Hi Alex,
|
||||
|
||||
Thank you for summarizing the action items from this morning's meeting.
|
||||
I have noted the three tasks and will begin working on them right away.
|
||||
@@ -281,8 +285,7 @@ async def main() -> None:
|
||||
If anything else comes up, please let me know.
|
||||
|
||||
Best regards,
|
||||
Sarah)
|
||||
DatabaseEvent(data=Email 32021432-2d4e-4c54-b04c-f81b4120340c saved to database.)
|
||||
Sarah
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
handler,
|
||||
)
|
||||
|
||||
@@ -19,8 +21,8 @@ the second reverses the text and completes the workflow. The run_stream loop pri
|
||||
|
||||
Purpose:
|
||||
Show how to define explicit Executor classes with @handler methods, wire them in order with
|
||||
WorkflowBuilder, and consume streaming events. Demonstrate typed WorkflowContext[T] for outputs,
|
||||
ctx.send_message to pass intermediate values, and ctx.add_event to signal completion with a WorkflowCompletedEvent.
|
||||
WorkflowBuilder, and consume streaming events. Demonstrate typed WorkflowContext[T_Out, T_W_Out] for outputs,
|
||||
ctx.send_message to pass intermediate values, and ctx.yield_output to provide workflow outputs.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required.
|
||||
@@ -44,21 +46,21 @@ class UpperCaseExecutor(Executor):
|
||||
|
||||
|
||||
class ReverseTextExecutor(Executor):
|
||||
"""Reverses the incoming string and completes the workflow.
|
||||
"""Reverses the incoming string and yields workflow output.
|
||||
|
||||
Concepts:
|
||||
- Use ctx.add_event to publish a WorkflowCompletedEvent when the terminal result is ready.
|
||||
- Use ctx.yield_output to provide workflow outputs when the terminal result is ready.
|
||||
- The terminal node does not forward messages further.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
"""Reverse the input string and emit a completion event."""
|
||||
async def reverse_text(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Reverse the input string and yield the workflow output."""
|
||||
result = text[::-1]
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
async def main():
|
||||
async def main() -> None:
|
||||
"""Build a two step sequential workflow and run it with streaming to observe events."""
|
||||
# Step 1: Create executor instances.
|
||||
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
|
||||
@@ -74,15 +76,15 @@ async def main():
|
||||
)
|
||||
|
||||
# Step 3: Stream events for a single input.
|
||||
# The stream will include executor invoke and completion events, plus the final WorkflowCompletedEvent.
|
||||
completion_event = None
|
||||
# The stream will include executor invoke and completion events, plus workflow outputs.
|
||||
outputs: list[str] = []
|
||||
async for event in workflow.run_stream("hello world"):
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion_event = event
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(cast(str, event.data))
|
||||
|
||||
if completion_event:
|
||||
print(f"Workflow completed with result: {completion_event.data}")
|
||||
if outputs:
|
||||
print(f"Workflow outputs: {outputs}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, executor
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, WorkflowOutputEvent, executor
|
||||
|
||||
"""
|
||||
Sample: Foundational sequential workflow with streaming using function-style executors.
|
||||
|
||||
Two lightweight steps run in order. The first converts text to uppercase.
|
||||
The second reverses the text and completes the workflow. Events are printed as they arrive from run_stream.
|
||||
The second reverses the text and yields the workflow output. Events are printed as they arrive from run_stream.
|
||||
|
||||
Purpose:
|
||||
Show how to declare executors with the @executor decorator, connect them with WorkflowBuilder,
|
||||
pass intermediate values using ctx.send_message, and signal completion with ctx.add_event by emitting a
|
||||
WorkflowCompletedEvent. Demonstrate how streaming exposes ExecutorInvokedEvent and WorkflowCompletedEvent
|
||||
for observability.
|
||||
pass intermediate values using ctx.send_message, and yield final output using ctx.yield_output().
|
||||
Demonstrate how streaming exposes ExecutorInvokedEvent and ExecutorCompletedEvent for observability.
|
||||
|
||||
Prerequisites:
|
||||
- No external services required.
|
||||
@@ -37,17 +38,17 @@ async def to_upper_case(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
|
||||
|
||||
@executor(id="reverse_text_executor")
|
||||
async def reverse_text(text: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Reverse the input and complete the workflow with the final result.
|
||||
async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Reverse the input and yield the workflow output.
|
||||
|
||||
Concepts:
|
||||
- Terminal nodes publish a WorkflowCompletedEvent using ctx.add_event.
|
||||
- No further messages are forwarded after completion.
|
||||
- Terminal nodes yield output using ctx.yield_output().
|
||||
- The workflow completes when it becomes idle (no more work to do).
|
||||
"""
|
||||
result = text[::-1]
|
||||
|
||||
# Emit the terminal event that carries the final output for this run.
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
# Yield the final output for this workflow run.
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -57,17 +58,11 @@ async def main():
|
||||
workflow = WorkflowBuilder().add_edge(to_upper_case, reverse_text).set_start_executor(to_upper_case).build()
|
||||
|
||||
# Step 3: Run the workflow and stream events in real time.
|
||||
completion_event = None
|
||||
async for event in workflow.run_stream("hello world"):
|
||||
# You will see executor invoke and completion events, and then the final WorkflowCompletedEvent.
|
||||
# You will see executor invoke and completion events as the workflow progresses.
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
# The WorkflowCompletedEvent contains the final result.
|
||||
completion_event = event
|
||||
|
||||
# Print the final result after the streaming loop concludes.
|
||||
if completion_event:
|
||||
print(f"Workflow completed with result: {completion_event.data}")
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
print(f"Workflow completed with result: {event.data}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
@@ -75,8 +70,8 @@ async def main():
|
||||
Event: ExecutorInvokedEvent(executor_id=upper_case_executor)
|
||||
Event: ExecutorCompletedEvent(executor_id=upper_case_executor)
|
||||
Event: ExecutorInvokedEvent(executor_id=reverse_text_executor)
|
||||
Event: WorkflowCompletedEvent(data=DLROW OLLEH)
|
||||
Event: ExecutorCompletedEvent(executor_id=reverse_text_executor)
|
||||
Event: WorkflowOutputEvent(data='DLROW OLLEH', source_executor_id=reverse_text_executor)
|
||||
Workflow completed with result: DLROW OLLEH
|
||||
"""
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ from agent_framework import (
|
||||
ExecutorCompletedEvent,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.azure import AzureChatClient
|
||||
@@ -25,6 +25,7 @@ Sample: Simple Loop (with an Agent Judge)
|
||||
What it does:
|
||||
- Guesser performs a binary search; judge is an agent that returns ABOVE/BELOW/MATCHED.
|
||||
- Demonstrates feedback loops in workflows with agent steps.
|
||||
- The workflow completes when the correct number is guessed.
|
||||
|
||||
Prerequisites:
|
||||
- Azure AI/ Azure OpenAI for `AzureChatClient` agent.
|
||||
@@ -55,14 +56,14 @@ class GuessNumberExecutor(Executor):
|
||||
self._upper = bound[1]
|
||||
|
||||
@handler
|
||||
async def guess_number(self, feedback: NumberSignal, ctx: WorkflowContext[int]) -> None:
|
||||
async def guess_number(self, feedback: NumberSignal, ctx: WorkflowContext[int, str]) -> None:
|
||||
"""Execute the task by guessing a number."""
|
||||
if feedback == NumberSignal.INIT:
|
||||
self._guess = (self._lower + self._upper) // 2
|
||||
await ctx.send_message(self._guess)
|
||||
elif feedback == NumberSignal.MATCHED:
|
||||
# The previous guess was correct.
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Guessed the number: {self._guess}"))
|
||||
await ctx.yield_output(f"Guessed the number: {self._guess}")
|
||||
elif feedback == NumberSignal.ABOVE:
|
||||
# The previous guess was too low.
|
||||
# Update the lower bound to the previous guess.
|
||||
@@ -150,6 +151,8 @@ async def main():
|
||||
async for event in workflow.run_stream(NumberSignal.INIT):
|
||||
if isinstance(event, ExecutorCompletedEvent) and event.executor_id == guess_number_executor.id:
|
||||
iterations += 1
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print(f"Final result: {event.data}")
|
||||
print(f"Event: {event}")
|
||||
|
||||
# This is essentially a binary search, so the number of iterations should be logarithmic.
|
||||
|
||||
@@ -6,6 +6,8 @@ from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import ( # Core chat primitives used to form LLM requests
|
||||
AgentExecutor, # Wraps an agent so it can run inside a workflow
|
||||
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
|
||||
@@ -15,7 +17,6 @@ from agent_framework import ( # Core chat primitives used to form LLM requests
|
||||
Default, # Default branch when no cases match
|
||||
Role,
|
||||
WorkflowBuilder, # Fluent builder for assembling the graph
|
||||
WorkflowCompletedEvent, # Terminal event for successful completion
|
||||
WorkflowContext, # Per-run context and event bus
|
||||
executor, # Decorator to turn a function into a workflow executor
|
||||
)
|
||||
@@ -36,6 +37,7 @@ Demonstrate deterministic one of N routing with switch-case edges. Show how to:
|
||||
- Validate agent JSON with Pydantic models for robust parsing.
|
||||
- Keep executor responsibilities narrow. Transform model output to a typed DetectionResult, then route based
|
||||
on that type.
|
||||
- Use ctx.yield_output() to provide workflow results - the workflow completes when idle with no pending work.
|
||||
|
||||
Prerequisites:
|
||||
- Familiarity with WorkflowBuilder, executors, edges, and events.
|
||||
@@ -124,30 +126,28 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon
|
||||
|
||||
|
||||
@executor(id="finalize_and_send")
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
# Terminal step for the drafting branch. Emit a completion event with the reply.
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Terminal step for the drafting branch. Yield the email response as output.
|
||||
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
|
||||
await ctx.yield_output(f"Email sent: {parsed.response}")
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Spam path terminal. Include the detector's rationale.
|
||||
if detection.spam_decision == "Spam":
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
|
||||
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Spam messages.")
|
||||
|
||||
|
||||
@executor(id="handle_uncertain")
|
||||
async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
|
||||
async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Uncertain path terminal. Surface the original content to aid human review.
|
||||
if detection.spam_decision == "Uncertain":
|
||||
email: Email | None = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
|
||||
await ctx.add_event(
|
||||
WorkflowCompletedEvent(
|
||||
f"Email marked as uncertain: {detection.reason}. Email content: {getattr(email, 'email_content', '')}"
|
||||
)
|
||||
await ctx.yield_output(
|
||||
f"Email marked as uncertain: {detection.reason}. Email content: {getattr(email, 'email_content', '')}"
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle Uncertain messages.")
|
||||
@@ -215,10 +215,12 @@ async def main():
|
||||
"Let me know if you'd like more details."
|
||||
)
|
||||
|
||||
# Run and print the terminal event for whichever branch completes.
|
||||
async for event in workflow.run_stream(email):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
print(f"{event}")
|
||||
# Run and print the outputs from whichever branch completes.
|
||||
events = await workflow.run(email)
|
||||
outputs = events.get_outputs()
|
||||
if outputs:
|
||||
for output in outputs:
|
||||
print(f"Workflow output: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+15
-15
@@ -15,8 +15,8 @@ from agent_framework import (
|
||||
RequestResponse, # Correlates a human response with the original request
|
||||
Role, # Enum of chat roles (user, assistant, system)
|
||||
WorkflowBuilder, # Fluent builder for assembling the graph
|
||||
WorkflowCompletedEvent, # Terminal event used to finish the workflow
|
||||
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
|
||||
@@ -30,8 +30,7 @@ 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.
|
||||
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
|
||||
@@ -132,7 +131,7 @@ class TurnManager(Executor):
|
||||
async def on_human_feedback(
|
||||
self,
|
||||
feedback: RequestResponse[HumanFeedbackRequest, str],
|
||||
ctx: WorkflowContext[AgentExecutorRequest | WorkflowCompletedEvent],
|
||||
ctx: WorkflowContext[AgentExecutorRequest, str],
|
||||
) -> None:
|
||||
"""Continue the game or finish based on human feedback.
|
||||
|
||||
@@ -144,7 +143,7 @@ class TurnManager(Executor):
|
||||
last_guess = getattr(feedback.original_request, "guess", None)
|
||||
|
||||
if reply == "correct":
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Guessed correctly: {last_guess}"))
|
||||
await ctx.yield_output(f"Guessed correctly: {last_guess}")
|
||||
return
|
||||
|
||||
# Provide feedback to the agent to try again.
|
||||
@@ -195,7 +194,8 @@ async def main() -> None:
|
||||
|
||||
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
|
||||
pending_responses: dict[str, str] | None = None
|
||||
completed: WorkflowCompletedEvent | None = None
|
||||
completed = False
|
||||
workflow_output: str | None = None
|
||||
|
||||
# User guidance printing:
|
||||
# If you want to instruct users up front, print a short banner before the loop.
|
||||
@@ -219,15 +219,16 @@ async def main() -> None:
|
||||
events = [event async for event in stream]
|
||||
pending_responses = None
|
||||
|
||||
# Collect human requests and the terminal completion if present.
|
||||
# Collect human requests, workflow outputs, and check for completion.
|
||||
requests: list[tuple[str, str]] = [] # (request_id, prompt)
|
||||
for event in events:
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completed = event
|
||||
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanFeedbackRequest):
|
||||
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanFeedbackRequest):
|
||||
# RequestInfoEvent for our HumanFeedbackRequest.
|
||||
requests.append((event.request_id, event.data.prompt))
|
||||
# Other events are ignored for brevity.
|
||||
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(
|
||||
@@ -258,9 +259,8 @@ async def main() -> None:
|
||||
responses[req_id] = answer
|
||||
pending_responses = responses
|
||||
|
||||
# Show final result.
|
||||
print(completed)
|
||||
|
||||
# Show final result from workflow output captured during streaming.
|
||||
print(f"Workflow output: {workflow_output}")
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
@@ -272,7 +272,7 @@ async def main() -> None:
|
||||
Enter higher/lower/correct/exit: lower
|
||||
HITL> The agent guessed: 9. Type one of: higher (your number is higher than this guess), lower (your number is lower than this guess), correct, or exit.
|
||||
Enter higher/lower/correct/exit: correct
|
||||
WorkflowCompletedEvent(data=Guessed correctly: 9)
|
||||
Workflow output: Guessed correctly: 9
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, get_logger, handler
|
||||
|
||||
@@ -57,8 +56,8 @@ class StartExecutor(Executor):
|
||||
|
||||
class EndExecutor(Executor):
|
||||
@handler # type: ignore[misc]
|
||||
async def handle_final(self, message: str, ctx: WorkflowContext[Any]) -> None:
|
||||
# Sink executor. The framework emits WorkflowCompletedEvent automatically after this handler returns.
|
||||
async def handle_final(self, message: str, ctx: WorkflowContext) -> None:
|
||||
# Sink executor. The workflow completes when idle with no pending work.
|
||||
print(f"Final result: {message}")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, ConcurrentBuilder, WorkflowCompletedEvent
|
||||
from agent_framework import ChatMessage, ConcurrentBuilder
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -12,13 +12,13 @@ Sample: Concurrent fan-out/fan-in (agent-only API) with default aggregator
|
||||
|
||||
Build a high-level concurrent workflow using ConcurrentBuilder and three domain agents.
|
||||
The default dispatcher fans out the same user prompt to all agents in parallel.
|
||||
The default aggregator fans in their results and emits a WorkflowCompletedEvent whose
|
||||
data is a list[ChatMessage] representing the concatenated conversations from all agents.
|
||||
The default aggregator fans in their results and yields output containing
|
||||
a list[ChatMessage] representing the concatenated conversations from all agents.
|
||||
|
||||
Demonstrates:
|
||||
- Minimal wiring with ConcurrentBuilder().participants([...]).build()
|
||||
- Fan-out to multiple agents, fan-in aggregation of final ChatMessages
|
||||
- Streaming of AgentRunEvent for simple progress visibility
|
||||
- Workflow completion when idle with no pending work
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI access configured for AzureChatClient (use az login + env vars)
|
||||
@@ -58,18 +58,17 @@ async def main() -> None:
|
||||
# Participants are either Agents (type of AgentProtocol) or Executors
|
||||
workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build()
|
||||
|
||||
# 3) Run with a single prompt, stream progress, and pretty-print the final combined messages
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
# 3) Run with a single prompt and pretty-print the final combined messages
|
||||
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if completion:
|
||||
if outputs:
|
||||
print("===== Final Aggregated Conversation (messages) =====")
|
||||
messages: list[ChatMessage] | Any = completion.data
|
||||
for i, msg in enumerate(messages, start=1):
|
||||
name = msg.author_name if msg.author_name else "user"
|
||||
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
|
||||
for output in outputs:
|
||||
messages: list[ChatMessage] | Any = output
|
||||
for i, msg in enumerate(messages, start=1):
|
||||
name = msg.author_name if msg.author_name else "user"
|
||||
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
+5
-7
@@ -10,7 +10,6 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
ConcurrentBuilder,
|
||||
Executor,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -30,6 +29,7 @@ Demonstrates:
|
||||
- A @handler that converts AgentExecutorRequest -> AgentExecutorResponse
|
||||
- ConcurrentBuilder().participants([...]) to build fan-out/fan-in
|
||||
- Default aggregator returning list[ChatMessage] (one user + one assistant per agent)
|
||||
- Workflow completion when all participants become idle
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient (az login + required env vars)
|
||||
@@ -105,14 +105,12 @@ async def main() -> None:
|
||||
|
||||
workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build()
|
||||
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if completion:
|
||||
if outputs:
|
||||
print("===== Final Aggregated Conversation (messages) =====")
|
||||
messages: list[ChatMessage] | Any = completion.data
|
||||
messages: list[ChatMessage] | Any = outputs[0] # Get the first (and typically only) output
|
||||
for i, msg in enumerate(messages, start=1):
|
||||
name = msg.author_name if msg.author_name else "user"
|
||||
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
|
||||
|
||||
+8
-9
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, ConcurrentBuilder, Role, WorkflowCompletedEvent
|
||||
from agent_framework import ChatMessage, ConcurrentBuilder, Role
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -14,12 +14,13 @@ Build a concurrent workflow with ConcurrentBuilder that fans out one prompt to
|
||||
multiple domain agents and fans in their responses. Override the default
|
||||
aggregator with a custom async callback that uses AzureChatClient.get_response()
|
||||
to synthesize a concise, consolidated summary from the experts' outputs.
|
||||
The workflow completes when all participants become idle.
|
||||
|
||||
Demonstrates:
|
||||
- ConcurrentBuilder().participants([...]).with_custom_aggregator(callback)
|
||||
- Fan-out to agents and fan-in at an aggregator
|
||||
- Aggregation implemented via an LLM call (chat_client.get_response)
|
||||
- WorkflowCompletedEvent carrying the synthesized summary string
|
||||
- Workflow output yielded with the synthesized summary string
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient (az login + required env vars)
|
||||
@@ -82,20 +83,18 @@ async def main() -> None:
|
||||
# Each participant becomes a parallel branch (fan-out) from an internal dispatcher.
|
||||
# - with_aggregator(...) overrides the default aggregator:
|
||||
# • Default aggregator -> returns list[ChatMessage] (one user + one assistant per agent)
|
||||
# • Custom callback -> return value becomes WorkflowCompletedEvent.data (string here)
|
||||
# • Custom callback -> return value becomes workflow output (string here)
|
||||
# The callback can be sync or async; it receives list[AgentExecutorResponse].
|
||||
workflow = (
|
||||
ConcurrentBuilder().participants([researcher, marketer, legal]).with_aggregator(summarize_results).build()
|
||||
)
|
||||
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if completion:
|
||||
if outputs:
|
||||
print("===== Final Consolidated Output =====")
|
||||
print(completion.data)
|
||||
print(outputs[0]) # Get the first (and typically only) output
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
@@ -13,7 +13,7 @@ from agent_framework import (
|
||||
MagenticCallbackMode,
|
||||
MagenticFinalResultEvent,
|
||||
MagenticOrchestratorMessageEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
|
||||
|
||||
@@ -38,7 +38,7 @@ The workflow is configured with:
|
||||
|
||||
When run, the script builds the workflow, submits a task about estimating the
|
||||
energy efficiency and CO2 emissions of several ML models, streams intermediate
|
||||
events, and prints the final answer.
|
||||
events, and prints the final answer. The workflow completes when idle.
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
|
||||
@@ -132,17 +132,14 @@ async def main() -> None:
|
||||
print("\nStarting workflow execution...")
|
||||
|
||||
try:
|
||||
completion_event = None
|
||||
output: str | None = None
|
||||
async for event in workflow.run_stream(task):
|
||||
print(f"Event: {event}")
|
||||
print(event)
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
output = str(event.data)
|
||||
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion_event = event
|
||||
|
||||
if completion_event is not None:
|
||||
data = getattr(completion_event, "data", None)
|
||||
preview = getattr(data, "text", None) or (str(data) if data is not None else "")
|
||||
print(f"Workflow completed with result:\n\n{preview}")
|
||||
if output is not None:
|
||||
print(f"Workflow completed with result:\n\n{output}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Workflow execution failed: {e}")
|
||||
|
||||
+51
-40
@@ -18,7 +18,7 @@ from agent_framework import (
|
||||
MagenticPlanReviewReply,
|
||||
MagenticPlanReviewRequest,
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowOutputEvent,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
|
||||
|
||||
@@ -41,6 +41,7 @@ Key behaviors demonstrated:
|
||||
replies with PlanReviewReply (here we auto-approve, but you can edit/collect input)
|
||||
- Callbacks: on_agent_stream (incremental chunks), on_agent_response (final messages),
|
||||
on_result (final answer), and on_exception
|
||||
- Workflow completion when idle
|
||||
|
||||
Prerequisites:
|
||||
- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
|
||||
@@ -73,6 +74,9 @@ async def main() -> None:
|
||||
print(f"Exception occurred: {exception}")
|
||||
logger.exception("Workflow exception", exc_info=exception)
|
||||
|
||||
last_stream_agent_id: str | None = None
|
||||
stream_line_open: bool = False
|
||||
|
||||
# Unified callback
|
||||
async def on_event(event: MagenticCallbackEvent) -> None:
|
||||
nonlocal last_stream_agent_id, stream_line_open
|
||||
@@ -105,9 +109,6 @@ async def main() -> None:
|
||||
|
||||
print("\nBuilding Magentic Workflow...")
|
||||
|
||||
last_stream_agent_id: str | None = None
|
||||
stream_line_open: bool = False
|
||||
|
||||
workflow = (
|
||||
MagenticBuilder()
|
||||
.participants(researcher=researcher_agent, coder=coder_agent)
|
||||
@@ -136,51 +137,61 @@ async def main() -> None:
|
||||
print("\nStarting workflow execution...")
|
||||
|
||||
try:
|
||||
completion_event: WorkflowCompletedEvent | None = None
|
||||
pending_request: RequestInfoEvent | None = None
|
||||
pending_responses: dict[str, MagenticPlanReviewReply] | None = None
|
||||
completed = False
|
||||
workflow_output: str | None = None
|
||||
|
||||
while True:
|
||||
# Phase 1: run until either completion or a HIL request
|
||||
if pending_request is None:
|
||||
async for event in workflow.run_stream(task):
|
||||
print(f"Event: {event}")
|
||||
while not completed:
|
||||
# Use streaming for both initial run and response sending
|
||||
if pending_responses is not None:
|
||||
stream = workflow.send_responses_streaming(pending_responses)
|
||||
else:
|
||||
stream = workflow.run_stream(task)
|
||||
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion_event = event
|
||||
# Collect events from the stream
|
||||
events = [event async for event in stream]
|
||||
pending_responses = None
|
||||
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
pending_request = event
|
||||
review_req = cast(MagenticPlanReviewRequest, event.data)
|
||||
if review_req.plan_text:
|
||||
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
|
||||
# Process events to find request info events, outputs, and completion status
|
||||
for event in events:
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
pending_request = event
|
||||
review_req = cast(MagenticPlanReviewRequest, event.data)
|
||||
if review_req.plan_text:
|
||||
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
# Capture workflow output during streaming
|
||||
workflow_output = str(event.data)
|
||||
completed = True
|
||||
|
||||
# Break if completed
|
||||
if completion_event is not None:
|
||||
data = getattr(completion_event, "data", None)
|
||||
preview = getattr(data, "text", None) or (str(data) if data is not None else "")
|
||||
print(f"Workflow completed with result:\n\n{preview}")
|
||||
|
||||
# Phase 2: respond to the pending plan review (HIL) request
|
||||
# Handle pending plan review request
|
||||
if pending_request is not None:
|
||||
# For demo purposes we approve as-is. Replace this with UI input
|
||||
# to collect a human decision/comments/edited plan.
|
||||
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
|
||||
# Get human input for plan review decision
|
||||
print("Plan review options:")
|
||||
print("1. approve - Approve the plan as-is")
|
||||
print("2. revise - Request revision of the plan")
|
||||
print("3. exit - Exit the workflow")
|
||||
|
||||
async for event in workflow.send_responses_streaming({pending_request.request_id: reply}):
|
||||
print(f"Event: {event}")
|
||||
while True:
|
||||
choice = input("Enter your choice (approve/revise/exit): ").strip().lower() # noqa: ASYNC250
|
||||
if choice in ["approve", "1"]:
|
||||
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
|
||||
break
|
||||
if choice in ["revise", "2"]:
|
||||
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.REVISE)
|
||||
break
|
||||
if choice in ["exit", "3"]:
|
||||
print("Exiting workflow...")
|
||||
return
|
||||
print("Invalid choice. Please enter 'approve', 'revise', or 'exit'.")
|
||||
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion_event = event
|
||||
pending_responses = {pending_request.request_id: reply}
|
||||
pending_request = None
|
||||
|
||||
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
|
||||
# Another review cycle requested; keep pending
|
||||
pending_request = event
|
||||
review_req = cast(MagenticPlanReviewRequest, event.data)
|
||||
if review_req.plan_text:
|
||||
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
|
||||
else:
|
||||
# Clear pending if no immediate new request
|
||||
pending_request = None
|
||||
# Show final result from captured workflow output
|
||||
if workflow_output:
|
||||
print(f"Workflow completed with result:\n\n{workflow_output}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Workflow execution failed: {e}")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
|
||||
from agent_framework import ChatMessage, Role, SequentialBuilder, WorkflowCompletedEvent
|
||||
from agent_framework import ChatMessage, Role, SequentialBuilder, WorkflowOutputEvent
|
||||
from agent_framework.azure import AzureChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -12,8 +12,8 @@ Sample: Sequential workflow (agent-focused API) with shared conversation context
|
||||
|
||||
Build a high-level sequential workflow using SequentialBuilder and two domain agents.
|
||||
The shared conversation (list[ChatMessage]) flows through each participant. Each agent
|
||||
appends its assistant message to the context. The final WorkflowCompletedEvent includes
|
||||
the final conversation list.
|
||||
appends its assistant message to the context. The workflow outputs the final conversation
|
||||
list when complete.
|
||||
|
||||
Note on internal adapters:
|
||||
- Sequential orchestration includes small adapter nodes for input normalization
|
||||
@@ -44,16 +44,15 @@ async def main() -> None:
|
||||
# 2) Build sequential workflow: writer -> reviewer
|
||||
workflow = SequentialBuilder().participants([writer, reviewer]).build()
|
||||
|
||||
# 3) Run and print final conversation
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
# 3) Run and collect outputs
|
||||
outputs: list[list[ChatMessage]] = []
|
||||
async for event in workflow.run_stream("Write a tagline for a budget-friendly eBike."):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
outputs.append(cast(list[ChatMessage], event.data))
|
||||
|
||||
if completion:
|
||||
if outputs:
|
||||
print("===== Final Conversation =====")
|
||||
messages: list[ChatMessage] | Any = completion.data
|
||||
for i, msg in enumerate(messages, start=1):
|
||||
for i, msg in enumerate(outputs[-1], start=1):
|
||||
name = msg.author_name or ("assistant" if msg.role == Role.ASSISTANT else "user")
|
||||
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
|
||||
|
||||
|
||||
+11
-11
@@ -3,12 +3,13 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
Executor,
|
||||
Role,
|
||||
SequentialBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
@@ -20,8 +21,8 @@ Sample: Sequential workflow mixing agents and a custom summarizer executor
|
||||
|
||||
This demonstrates how SequentialBuilder chains participants with a shared
|
||||
conversation context (list[ChatMessage]). An agent produces content; a custom
|
||||
executor appends a compact summary to the conversation. The final WorkflowCompletedEvent
|
||||
contains the complete conversation.
|
||||
executor appends a compact summary to the conversation. The workflow completes
|
||||
when idle, and the final output contains the complete conversation.
|
||||
|
||||
Custom executor contract:
|
||||
- Provide at least one @handler accepting list[ChatMessage] and a WorkflowContext[list[ChatMessage]]
|
||||
@@ -42,11 +43,12 @@ class Summarizer(Executor):
|
||||
"""Simple summarizer: consumes full conversation and appends an assistant summary."""
|
||||
|
||||
@handler
|
||||
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[Never, list[ChatMessage]]) -> None:
|
||||
users = sum(1 for m in conversation if m.role == Role.USER)
|
||||
assistants = sum(1 for m in conversation if m.role == Role.ASSISTANT)
|
||||
summary = ChatMessage(role=Role.ASSISTANT, text=f"Summary -> users:{users} assistants:{assistants}")
|
||||
await ctx.send_message(list(conversation) + [summary])
|
||||
final_conversation = list(conversation) + [summary]
|
||||
await ctx.yield_output(final_conversation)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -62,14 +64,12 @@ async def main() -> None:
|
||||
workflow = SequentialBuilder().participants([content, summarizer]).build()
|
||||
|
||||
# 3) Run and print final conversation
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
async for event in workflow.run_stream("Explain the benefits of budget eBikes for commuters."):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
events = await workflow.run("Explain the benefits of budget eBikes for commuters.")
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if completion:
|
||||
if outputs:
|
||||
print("===== Final Conversation =====")
|
||||
messages: list[ChatMessage] | Any = completion.data
|
||||
messages: list[ChatMessage] | Any = outputs[0]
|
||||
for i, msg in enumerate(messages, start=1):
|
||||
name = msg.author_name or ("assistant" if msg.role == Role.ASSISTANT else "user")
|
||||
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import ( # Core chat primitives to build LLM requests
|
||||
AgentExecutor, # Wraps an LLM agent for use inside a workflow
|
||||
@@ -13,8 +14,8 @@ from agent_framework import ( # Core chat primitives to build LLM requests
|
||||
Executor, # Base class for custom Python executors
|
||||
Role, # Enum of chat roles (user, assistant, system)
|
||||
WorkflowBuilder, # Fluent builder for wiring the workflow graph
|
||||
WorkflowCompletedEvent, # Terminal event carrying the final result
|
||||
WorkflowContext, # Per run context and event bus
|
||||
WorkflowOutputEvent, # Event emitted when workflow yields output
|
||||
handler, # Decorator to mark an Executor method as invokable
|
||||
)
|
||||
from agent_framework.azure import AzureChatClient # Client wrapper for Azure OpenAI chat models
|
||||
@@ -75,7 +76,7 @@ class AggregateInsights(Executor):
|
||||
self._expert_ids = expert_ids
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Map responses to text by executor id for a simple, predictable demo.
|
||||
by_id: dict[str, str] = {}
|
||||
for r in results:
|
||||
@@ -101,7 +102,7 @@ class AggregateInsights(Executor):
|
||||
f"Legal/Compliance Notes:\n{aggregated.legal}\n"
|
||||
)
|
||||
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=consolidated))
|
||||
await ctx.yield_output(consolidated)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -151,17 +152,13 @@ async def main() -> None:
|
||||
)
|
||||
|
||||
# 3) Run with a single prompt and print progress plus the final consolidated output
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
|
||||
if isinstance(event, AgentRunEvent):
|
||||
# Show which agent ran and what step completed for lightweight observability.
|
||||
print(event)
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
|
||||
if completion:
|
||||
print("===== Final Aggregated Output =====")
|
||||
print(completion.data)
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("===== Final Aggregated Output =====")
|
||||
print(event.data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+10
-13
@@ -5,14 +5,15 @@ import asyncio
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
Executor, # Base class for custom workflow steps
|
||||
WorkflowBuilder, # Fluent graph builder for executors and edges
|
||||
WorkflowCompletedEvent, # Terminal event that carries final output
|
||||
WorkflowBuilder, # Fluent builder for executors and edges
|
||||
WorkflowContext, # Per run context with shared state and messaging
|
||||
WorkflowOutputEvent, # Event emitted when workflow yields output
|
||||
WorkflowViz, # Utility to visualize a workflow graph
|
||||
handler, # Decorator to expose an Executor method as a step
|
||||
)
|
||||
@@ -246,12 +247,12 @@ class Reduce(Executor):
|
||||
|
||||
|
||||
class CompletionExecutor(Executor):
|
||||
"""Joins all reducer outputs and emits the final completion event."""
|
||||
"""Joins all reducer outputs and yields the final output."""
|
||||
|
||||
@handler
|
||||
async def complete(self, data: list[ReduceCompleted], ctx: WorkflowContext[Any]) -> None:
|
||||
"""Collect reducer output file paths and publish a terminal event."""
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=[result.file_path for result in data]))
|
||||
async def complete(self, data: list[ReduceCompleted], ctx: WorkflowContext[Never, list[str]]) -> None:
|
||||
"""Collect reducer output file paths and yield final output."""
|
||||
await ctx.yield_output([result.file_path for result in data])
|
||||
|
||||
|
||||
async def main():
|
||||
@@ -303,14 +304,10 @@ async def main():
|
||||
raw_text = await f.read()
|
||||
|
||||
# Step 4: Run the workflow with the raw text as input.
|
||||
completion_event = None
|
||||
async for event in workflow.run_stream(raw_text):
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion_event = event
|
||||
|
||||
if completion_event:
|
||||
print(f"Completion Event: {completion_event}")
|
||||
if isinstance(event, WorkflowOutputEvent):
|
||||
print(f"Final Output: {event.data}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+17
-14
@@ -6,13 +6,14 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
ChatMessage,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
executor,
|
||||
)
|
||||
@@ -31,7 +32,7 @@ Show how to:
|
||||
- Use shared state to decouple large payloads from messages and pass around lightweight references.
|
||||
- Enforce structured agent outputs with Pydantic models via response_format for robust parsing.
|
||||
- Route using conditional edges based on a typed intermediate DetectionResult.
|
||||
- Compose agent backed executors with function style executors and print a terminal WorkflowCompletedEvent.
|
||||
- Compose agent backed executors with function style executors and yield the final output when the workflow completes.
|
||||
|
||||
Prerequisites:
|
||||
- Azure OpenAI configured for AzureChatClient with required environment variables.
|
||||
@@ -139,17 +140,17 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon
|
||||
|
||||
|
||||
@executor(id="finalize_and_send")
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
|
||||
"""Validate the drafted reply and complete the workflow with a terminal event."""
|
||||
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Validate the drafted reply and yield the final output."""
|
||||
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
|
||||
await ctx.yield_output(f"Email sent: {parsed.response}")
|
||||
|
||||
|
||||
@executor(id="handle_spam")
|
||||
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
|
||||
"""Emit a completion event describing why the email was marked as spam."""
|
||||
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Yield output describing why the email was marked as spam."""
|
||||
if detection.is_spam:
|
||||
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
|
||||
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
|
||||
else:
|
||||
raise RuntimeError("This executor should only handle spam messages.")
|
||||
|
||||
@@ -206,18 +207,20 @@ async def main() -> None:
|
||||
print("Unable to find resource file, using default text.")
|
||||
email = "You are a WINNER! Click here for a free lottery offer!!!"
|
||||
|
||||
# Run and print the terminal result. Streaming surfaces intermediate execution events as well.
|
||||
async for event in workflow.run_stream(email):
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
print(event)
|
||||
# Run and print the final result. Streaming surfaces intermediate execution events as well.
|
||||
events = await workflow.run(email)
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if outputs:
|
||||
print(f"Final result: {outputs[0]}")
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
WorkflowCompletedEvent(data=Email marked as spam: This email exhibits several common spam and scam characteristics:
|
||||
Final result: Email marked as spam: This email exhibits several common spam and scam characteristics:
|
||||
unrealistic claims of large cash winnings, urgent time pressure, requests for sensitive personal and financial
|
||||
information, and a demand for a processing fee. The sender impersonates a generic lottery commission, and the
|
||||
message contains a suspicious link. All these are typical of phishing and lottery scam emails.)
|
||||
message contains a suspicious link. All these are typical of phishing and lottery scam emails.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+8
-11
@@ -2,7 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutor,
|
||||
@@ -13,8 +14,8 @@ from agent_framework import (
|
||||
Executor,
|
||||
Role,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowViz,
|
||||
handler,
|
||||
)
|
||||
@@ -71,7 +72,7 @@ class AggregateInsights(Executor):
|
||||
self._expert_ids = expert_ids
|
||||
|
||||
@handler
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
|
||||
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
|
||||
# Map responses to text by executor id for a simple, predictable demo.
|
||||
by_id: dict[str, str] = {}
|
||||
for r in results:
|
||||
@@ -97,7 +98,7 @@ class AggregateInsights(Executor):
|
||||
f"Legal/Compliance Notes:\n{aggregated.legal}\n"
|
||||
)
|
||||
|
||||
await ctx.add_event(WorkflowCompletedEvent(data=consolidated))
|
||||
await ctx.yield_output(consolidated)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
@@ -165,17 +166,13 @@ async def main() -> None:
|
||||
print("Tip: Install 'viz' extra to export workflow visualization: pip install agent-framework[viz]")
|
||||
|
||||
# 3) Run with a single prompt
|
||||
completion: WorkflowCompletedEvent | None = None
|
||||
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
|
||||
if isinstance(event, AgentRunEvent):
|
||||
# Show which agent ran and what step completed.
|
||||
print(event)
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
completion = event
|
||||
|
||||
if completion:
|
||||
print("===== Final Aggregated Output =====")
|
||||
print(completion.data)
|
||||
elif isinstance(event, WorkflowOutputEvent):
|
||||
print("===== Final Aggregated Output =====")
|
||||
print(event.data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user