Python: Improve the workflow getting started samples (#570)

* Wip: samples

* wip - samples

* Updates to workflow getting started samples

* Checkpointing enhancements

* Cleanup

* PR feedback

* Updates

* Sample updates

* Updates

* Revamp samples, improve doc strings and code comments

* Cleanup unused comment

* Formatting cleanup

* wip

* Further work on samples. Allow agent to be specified as edge.

* Cleanup

* Typing cleanup

* Sample updates

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2025-09-06 04:16:25 +09:00
committed by GitHub
Unverified
parent cd0587c5f6
commit 518fd447fd
46 changed files with 4130 additions and 1683 deletions
@@ -777,11 +777,15 @@ class AgentExecutorResponse:
Attributes:
executor_id: The ID of the executor that generated the response.
response: The agent run response containing the messages generated by the agent.
agent_run_response: The underlying agent run response (unaltered from client).
full_conversation: The full conversation context (prior inputs + all assistant/tool outputs) that
should be used when chaining to another AgentExecutor. This prevents downstream agents losing
user prompts while keeping the emitted AgentRunEvent text faithful to the raw agent output.
"""
executor_id: str
agent_run_response: AgentRunResponse
full_conversation: list[ChatMessage] | None = None
class AgentExecutor(Executor):
@@ -800,21 +804,75 @@ class AgentExecutor(Executor):
Args:
agent: The agent to be wrapped by this executor.
agent_thread: The thread to use for running the agent. If None, a new thread will be created.
streaming: Whether to enable streaming for the agent. If enabled, the executor will emit
AgentRunStreamingEvent updates instead of a single AgentRunEvent.
streaming: Enable streaming (emits incremental AgentRunUpdateEvent events) vs single response.
id: A unique identifier for the executor. If None, a new UUID will be generated.
"""
super().__init__(id or agent.id)
# Prefer provided id; else use agent.name if present; else generate deterministic prefix
if id is not None:
exec_id = id
else:
agent_name = agent.name
exec_id = str(agent_name) if agent_name else f"executor_{uuid.uuid4()}"
super().__init__(exec_id)
self._agent = agent
self._agent_thread = agent_thread or self._agent.get_new_thread()
self._streaming = streaming
self._cache: list[ChatMessage] = []
async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Execute the underlying agent, emit events, and enqueue response.
Terminal detection & WorkflowCompletedEvent emission are handled centrally in Runner.
This method only produces AgentRunEvent/AgentRunUpdateEvent plus enqueues an
AgentExecutorResponse message for routing.
"""
if self._streaming:
updates: list[AgentRunResponseUpdate] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
):
# Skip empty updates (no textual or structural content)
if not update:
continue
contents = getattr(update, "contents", None)
text_val = getattr(update, "text", "")
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
continue
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
response = AgentRunResponse.from_agent_run_response_updates(updates)
else:
response = await self._agent.run(
self._cache,
thread=self._agent_thread,
)
await ctx.add_event(AgentRunEvent(self.id, response))
full_conversation: list[ChatMessage] | None = None
if self._cache:
# Construct conversation snapshot = inputs (cache) + agent outputs (agent_run_response.messages).
# Do not mutate response.messages so AgentRunEvent remains clean.
full_conversation = list(self._cache) + list(response.messages)
agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation)
await ctx.send_message(agent_response)
self._cache.clear()
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Run the agent executor with the given request."""
self._cache.extend(request.messages)
"""Handle an AgentExecutorRequest (canonical input).
This is the standard path: extend cache with provided messages; if should_respond
run the agent and emit an AgentExecutorResponse downstream.
"""
self._cache.extend(request.messages)
if request.should_respond:
if self._streaming:
updates: list[AgentRunResponseUpdate] = []
@@ -822,6 +880,18 @@ class AgentExecutor(Executor):
self._cache,
thread=self._agent_thread,
):
if not update:
continue
contents = getattr(update, "contents", None)
text_val = getattr(update, "text", "")
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
continue
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
response = AgentRunResponse.from_agent_run_response_updates(updates)
@@ -832,8 +902,37 @@ class AgentExecutor(Executor):
)
await ctx.add_event(AgentRunEvent(self.id, response))
await ctx.send_message(AgentExecutorResponse(self.id, response))
self._cache.clear()
@handler
async def from_response(self, prior: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Enable seamless chaining: accept a prior AgentExecutorResponse as input.
Strategy: treat the prior response's messages as the conversation state and
immediately run the agent to produce a new response.
"""
# Replace cache with full conversation if available, else fall back to agent_run_response messages.
if prior.full_conversation is not None:
self._cache = list(prior.full_conversation)
else:
self._cache = list(prior.agent_run_response.messages)
await self._run_agent_and_emit(ctx)
@handler
async def from_str(self, text: str, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
"""Accept a raw user prompt string and run the agent (one-shot)."""
self._cache = [ChatMessage(role="user", text=text)] # type: ignore[arg-type]
await self._run_agent_and_emit(ctx)
@handler
async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorResponse]) -> None: # type: ignore[name-defined]
"""Accept a single ChatMessage as input."""
self._cache = [message]
await self._run_agent_and_emit(ctx)
@handler
async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorResponse]) -> None: # type: ignore[name-defined]
"""Accept a list of ChatMessage objects as conversation context."""
self._cache = list(messages)
await self._run_agent_and_emit(ctx)
# endregion: Agent Executor
@@ -10,8 +10,6 @@ This module provides:
with proper type validation and handler registration.
"""
from __future__ import annotations
import asyncio
import inspect
from collections.abc import Awaitable, Callable
@@ -3,7 +3,7 @@
import asyncio
import logging
from collections import defaultdict
from collections.abc import AsyncIterable, Sequence
from collections.abc import AsyncGenerator, Sequence
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import WorkflowEvent
from ._events import WorkflowCompletedEvent, WorkflowEvent
from ._executor import Executor
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
@@ -74,19 +74,17 @@ class Runner:
if max_iterations is not None:
self._max_iterations = max_iterations
async def run_until_convergence(self) -> AsyncIterable[WorkflowEvent]:
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
"""Run the workflow until no more messages are sent."""
if self._running:
raise RuntimeError("Runner is already running.")
self._running = True
try:
# Process any events from initial execution before checkpointing
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
logger.info("Processing events from initial execution")
events = await self._ctx.drain_events()
for event in events:
logger.info(f"Yielding initial event: {event}")
logger.info("Yielding pre-loop events")
for event in await self._ctx.drain_events():
yield event
# Create first checkpoint if there are messages from initial execution
@@ -102,22 +100,33 @@ class Runner:
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
await self._run_iteration()
# Run iteration concurrently with live event streaming: we poll
# for new events while the iteration coroutine progresses.
iteration_task = asyncio.create_task(self._run_iteration())
while not iteration_task.done():
try:
# Wait briefly for any new event; timeout allows progress checks
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
yield event
except asyncio.TimeoutError:
# Periodically continue to let iteration advance
continue
# Propagate errors from iteration
await iteration_task
self._iteration += 1
# Drain any straggler events emitted at tail end
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
# Update context with current iteration state immediately
await self._update_context_with_shared_state()
logger.info(f"Completed superstep {self._iteration}")
# Process events first before any checkpointing
if await self._ctx.has_events():
logger.info("Processing events before checkpointing")
events = await self._ctx.drain_events()
for event in events:
logger.debug(f"Yielding event: {event}")
yield event
# Create checkpoint after each superstep iteration
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
@@ -142,7 +151,7 @@ class Runner:
from ._executor import SubWorkflowRequestInfo
# Handle SubWorkflowRequestInfo messages - only process those not already targeted
sub_workflow_messages = []
sub_workflow_messages: list[Message] = []
for msg in messages:
# Skip messages sent directly to RequestInfoExecutor - they are already forwarded
if self._is_message_to_request_info_executor(msg):
@@ -152,14 +161,15 @@ class Runner:
sub_workflow_messages.append(msg)
for message in sub_workflow_messages:
sub_request = message.data
# message.data is guaranteed to be SubWorkflowRequestInfo via filtering above
sub_request = message.data # type: ignore[assignment]
# Find executor that can intercept the wrapped type
interceptor_found = False
for executor in self._executors.values():
if hasattr(executor, "_request_interceptors") and executor.id != message.source_id:
# Check if any registered interceptor can handle this request type
for registered_type in executor._request_interceptors:
interceptors = getattr(executor, "_request_interceptors", [])
if interceptors and executor.id != message.source_id:
for registered_type in interceptors: # type: ignore[assignment]
# Check type matching - handle both type and string cases
matched = False
if (
@@ -234,7 +244,7 @@ class Runner:
# since they were handled specially
from ._executor import SubWorkflowRequestInfo
non_sub_workflow_messages = []
non_sub_workflow_messages: list[Message] = []
for msg in messages:
# Keep messages sent directly to RequestInfoExecutor (forwarded messages)
if self._is_message_to_request_info_executor(msg):
@@ -251,8 +261,43 @@ class Runner:
for message in non_sub_workflow_messages:
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
if not tasks:
# No outgoing edges. If this is an AgentExecutorResponse, treat it as an
# intentional terminal emission and emit a WorkflowCompletedEvent here.
# (Previously this relied on the executor to emit, but AgentExecutor only
# sends an AgentExecutorResponse message; centralized completion keeps the
# contract consistent with other executors.)
try: # Local import to avoid circular dependencies at module import time.
from ._executor import AgentExecutorResponse # type: ignore
if isinstance(message.data, AgentExecutorResponse):
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
await self._ctx.add_event(WorkflowCompletedEvent(final_text))
continue # Terminal handled
except Exception as exc: # pragma: no cover - defensive
logger.debug("Suppressed exception during terminal message type check: %s", exc)
# Otherwise keep prior behavior (emit warning for unexpected undelivered message).
logger.warning(
f"Message {message} could not be delivered (no outgoing edges). "
"Add a downstream executor or remove the send if this is unexpected."
)
continue
results = await asyncio.gather(*tasks)
if not any(results):
# Outgoing edges exist but none accepted the message. If this is an
# AgentExecutorResponse, treat as natural terminal and emit completion.
try:
from ._executor import AgentExecutorResponse # type: ignore
if isinstance(message.data, AgentExecutorResponse):
# Emit a single completion event with final text (best-effort extraction)
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
await self._ctx.add_event(WorkflowCompletedEvent(final_text))
continue
except Exception as exc: # pragma: no cover
logger.debug("Terminal completion emission failed: %s", exc)
logger.warning(
f"Message {message} could not be delivered. "
"This may be due to type incompatibility or no matching targets."
@@ -389,7 +434,8 @@ class Runner:
"""
parsed: defaultdict[str, list[EdgeRunner]] = defaultdict(list)
for runner in edge_runners:
for source_executor_id in runner._edge_group.source_executor_ids:
# Accessing protected attribute (_edge_group) intentionally for internal wiring.
for source_executor_id in runner._edge_group.source_executor_ids: # type: ignore[attr-defined]
parsed[source_executor_id].append(runner)
return parsed
@@ -1,14 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import importlib
import logging
import uuid
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
from dataclasses import dataclass, fields, is_dataclass
from typing import Any, Protocol, TypedDict, TypeVar, cast, runtime_checkable
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._const import DEFAULT_MAX_ITERATIONS
from ._events import WorkflowEvent
from ._events import AgentRunUpdateEvent, WorkflowEvent
from ._shared_state import SharedState
logger = logging.getLogger(__name__)
@@ -49,6 +51,176 @@ class CheckpointState(TypedDict):
max_iterations: int
# Checkpoint serialization helpers
_PYDANTIC_MARKER = "__af_pydantic_model__"
_DATACLASS_MARKER = "__af_dataclass__"
# Guards to prevent runaway recursion while encoding arbitrary user data
_MAX_ENCODE_DEPTH = 100
_CYCLE_SENTINEL = "<cycle>"
def _is_pydantic_model(obj: object) -> bool:
"""Best-effort check for Pydantic models (e.g., AFBaseModel).
We avoid hard dependencies by duck-typing on model_dump/model_validate.
"""
try:
obj_type: type[Any] = type(obj)
return hasattr(obj, "model_dump") and hasattr(obj_type, "model_validate")
except Exception:
return False
def _encode_checkpoint_value(value: Any) -> Any:
"""Recursively encode values into JSON-serializable structures.
- Pydantic models -> { _PYDANTIC_MARKER: "module:Class", value: model_dump(mode="json") }
- dataclass instances -> { _DATACLASS_MARKER: "module:Class", value: {field: encoded} }
- dict -> encode keys as str and values recursively
- list/tuple/set -> list of encoded items
- other -> returned as-is if already JSON-serializable
Includes cycle and depth protection to avoid infinite recursion.
"""
def _enc(v: Any, stack: set[int], depth: int) -> Any:
# Depth guard
if depth > _MAX_ENCODE_DEPTH:
logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}")
return "<max_depth>"
# Pydantic (AFBaseModel) handling
if _is_pydantic_model(v):
cls = cast(type[Any], type(v)) # type: ignore
try:
return {
_PYDANTIC_MARKER: f"{cls.__module__}:{cls.__name__}",
"value": v.model_dump(mode="json"),
}
except Exception as exc: # best-effort fallback
logger.debug("Pydantic model_dump failed for %s: %s", cls, exc)
return str(v)
# Dataclasses (instances only)
if is_dataclass(v) and not isinstance(v, type):
oid = id(v)
if oid in stack:
logger.debug("Cycle detected while encoding dataclass instance")
return _CYCLE_SENTINEL
stack.add(oid)
try:
# type(v) already narrows sufficiently; cast was redundant
dc_cls: type[Any] = type(v)
field_values: dict[str, Any] = {}
for f in fields(v): # type: ignore[arg-type]
field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
return {
_DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
"value": field_values,
}
finally:
stack.remove(oid)
# Collections
if isinstance(v, dict):
v_dict = cast("dict[object, object]", v)
oid = id(v_dict)
if oid in stack:
logger.debug("Cycle detected while encoding dict")
return _CYCLE_SENTINEL
stack.add(oid)
try:
json_dict: dict[str, Any] = {}
for k_any, val_any in v_dict.items(): # type: ignore[assignment]
k_str: str = str(k_any)
json_dict[k_str] = _enc(val_any, stack, depth + 1)
return json_dict
finally:
stack.remove(oid)
if isinstance(v, (list, tuple, set)):
iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v)
oid = id(iterable_v)
if oid in stack:
logger.debug("Cycle detected while encoding iterable")
return _CYCLE_SENTINEL
stack.add(oid)
try:
seq: list[object] = list(iterable_v)
encoded_list: list[Any] = []
for item in seq:
encoded_list.append(_enc(item, stack, depth + 1))
return encoded_list
finally:
stack.remove(oid)
# Primitives (or unknown objects): ensure JSON-serializable
if isinstance(v, (str, int, float, bool)) or v is None:
return v
# Fallback: stringify unknown objects to avoid JSON serialization errors
try:
return str(v)
except Exception:
return f"<{type(v).__name__}>"
return _enc(value, set(), 0)
def _decode_checkpoint_value(value: Any) -> Any:
"""Recursively decode values previously encoded by _encode_checkpoint_value."""
if isinstance(value, dict):
value_dict = cast(dict[str, Any], value) # encoded form always uses string keys
# Pydantic marker handling
if _PYDANTIC_MARKER in value_dict and "value" in value_dict:
type_key: str | None = value_dict.get(_PYDANTIC_MARKER) # type: ignore[assignment]
raw: Any = value_dict.get("value")
if isinstance(type_key, str):
try:
module_name, class_name = type_key.split(":", 1)
module = importlib.import_module(module_name)
cls: Any = getattr(module, class_name)
if hasattr(cls, "model_validate"):
return cls.model_validate(raw)
except Exception as exc:
logger.debug(
"Failed to decode pydantic model %s: %s; returning raw value",
type_key,
exc,
)
# Dataclass marker handling
if _DATACLASS_MARKER in value_dict and "value" in value_dict:
type_key_dc: str | None = value_dict.get(_DATACLASS_MARKER) # type: ignore[assignment]
raw_dc: Any = value_dict.get("value")
if isinstance(type_key_dc, str):
try:
module_name, class_name = type_key_dc.split(":", 1)
module = importlib.import_module(module_name)
cls_dc: Any = getattr(module, class_name)
decoded_raw = _decode_checkpoint_value(raw_dc)
if isinstance(decoded_raw, dict):
return cls_dc(**decoded_raw)
except Exception as exc:
logger.debug(
"Failed to decode dataclass %s: %s; returning raw value",
type_key_dc,
exc,
)
# Fallback to decoded raw value
return _decode_checkpoint_value(raw_dc)
# Regular dict: decode recursively
decoded: dict[str, Any] = {}
for k_any, v_any in value_dict.items():
decoded[k_any] = _decode_checkpoint_value(v_any)
return decoded
if isinstance(value, list):
# After isinstance check, treat value as list[Any] for decoding
value_list: list[Any] = value # type: ignore[assignment]
return [_decode_checkpoint_value(v_any) for v_any in value_list]
return value
@runtime_checkable
class RunnerContext(Protocol):
"""Protocol for the execution context used by the runner.
@@ -105,6 +277,10 @@ class RunnerContext(Protocol):
"""
...
async def next_event(self) -> WorkflowEvent: # pragma: no cover - interface only
"""Wait for and return the next event emitted by the workflow run."""
...
async def set_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Set the state for a specific executor.
@@ -185,7 +361,8 @@ class InProcRunnerContext:
checkpoint_storage: Optional storage to enable checkpointing.
"""
self._messages: defaultdict[str, list[Message]] = defaultdict(list)
self._events: list[WorkflowEvent] = []
# Event queue for immediate streaming of events (e.g., AgentRunUpdateEvent)
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
@@ -207,15 +384,54 @@ class InProcRunnerContext:
return bool(self._messages)
async def add_event(self, event: WorkflowEvent) -> None:
self._events.append(event)
"""Add an event to the context immediately.
Events are enqueued so runners can stream them in real time instead of
waiting for superstep boundaries.
"""
# Filter out empty AgentRunUpdateEvent updates to avoid emitting None/empty chunks
try:
if isinstance(event, AgentRunUpdateEvent):
update = getattr(event, "data", None)
# Skip if no update payload
if not update:
return
# Robust emptiness check: allow either top-level text or any text-bearing content
text_val = getattr(update, "text", None)
contents = getattr(update, "contents", None)
has_text_content = False
if contents:
for c in contents:
if getattr(c, "text", None):
has_text_content = True
break
if not (text_val or has_text_content):
return
except Exception as exc: # pragma: no cover - defensive logging path
# Best-effort filtering only; never block event delivery on filtering errors
logger.debug("Error while filtering event %r: %s", event, exc, exc_info=True)
await self._event_queue.put(event)
async def drain_events(self) -> list[WorkflowEvent]:
events = self._events.copy()
self._events.clear()
"""Drain all currently queued events without blocking for new ones."""
events: list[WorkflowEvent] = []
while True:
try:
events.append(self._event_queue.get_nowait())
except asyncio.QueueEmpty: # type: ignore[attr-defined]
break
return events
async def has_events(self) -> bool:
return bool(self._events)
return not self._event_queue.empty()
async def next_event(self) -> WorkflowEvent:
"""Wait for and return the next event.
Used by the runner to interleave event emission with ongoing iteration work.
"""
return await self._event_queue.get()
async def set_state(self, executor_id: str, state: dict[str, Any]) -> None:
self._executor_states[executor_id] = state
@@ -229,9 +445,10 @@ class InProcRunnerContext:
def set_workflow_id(self, workflow_id: str) -> None:
self._workflow_id = workflow_id
def reset_for_new_run(self, workflow_shared_state: "SharedState | None" = None) -> None:
def reset_for_new_run(self, workflow_shared_state: SharedState | None = None) -> None:
self._messages.clear()
self._events.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._shared_state.clear()
self._executor_states.clear()
self._iteration_count = 0
@@ -285,7 +502,7 @@ class InProcRunnerContext:
for source_id, message_list in self._messages.items():
serializable_messages[source_id] = [
{
"data": msg.data,
"data": _encode_checkpoint_value(msg.data),
"source_id": msg.source_id,
"target_id": msg.target_id,
"trace_contexts": msg.trace_contexts,
@@ -295,8 +512,8 @@ class InProcRunnerContext:
]
return {
"messages": serializable_messages,
"shared_state": self._shared_state,
"executor_states": self._executor_states,
"shared_state": _encode_checkpoint_value(self._shared_state),
"executor_states": _encode_checkpoint_value(self._executor_states),
"iteration_count": self._iteration_count,
"max_iterations": self._max_iterations,
}
@@ -307,7 +524,7 @@ class InProcRunnerContext:
for source_id, message_list in messages_data.items():
self._messages[source_id] = [
Message(
data=msg.get("data"),
data=_decode_checkpoint_value(msg.get("data")),
source_id=msg.get("source_id", ""),
target_id=msg.get("target_id"),
trace_contexts=msg.get("trace_contexts"),
@@ -315,7 +532,28 @@ class InProcRunnerContext:
)
for msg in message_list
]
self._shared_state = state.get("shared_state", {})
self._executor_states = state.get("executor_states", {})
# Restore shared_state
decoded_shared_raw = _decode_checkpoint_value(state.get("shared_state", {}))
if isinstance(decoded_shared_raw, dict):
self._shared_state = cast(dict[str, Any], decoded_shared_raw)
else: # fallback to empty dict if corrupted
self._shared_state = {}
# Restore executor_states ensuring value types are dicts
decoded_exec_raw = _decode_checkpoint_value(state.get("executor_states", {}))
if isinstance(decoded_exec_raw, dict):
typed_exec: dict[str, dict[str, Any]] = {}
for k_raw, v_raw in decoded_exec_raw.items(): # type: ignore[assignment]
if isinstance(k_raw, str) and isinstance(v_raw, dict):
# Filter inner dict to string keys only (best-effort)
inner: dict[str, Any] = {}
for inner_k, inner_v in v_raw.items(): # type: ignore[assignment]
if isinstance(inner_k, str):
inner[inner_k] = inner_v
typed_exec[k_raw] = inner
self._executor_states = typed_exec
else:
self._executor_states = {}
self._iteration_count = state.get("iteration_count", 0)
self._max_iterations = state.get("max_iterations", 100)
@@ -167,6 +167,23 @@ class WorkflowGraphValidator:
if start_executor_id not in self._executors:
raise GraphConnectivityError(f"Start executor '{start_executor_id}' is not present in the workflow graph")
# Additional presence verification:
# A start executor that is only injected via the builder (present in the executors map)
# but not referenced by any edge while other executors ARE referenced indicates a
# configuration error: the chosen start node is effectively disconnected / unknown to the
# defined graph topology. For single-node workflows (no edges) we allow the start executor
# to stand alone (handled above when we inject it into the map). We perform this refined
# check only when there is at least one edge group defined.
if self._edges: # Only evaluate when the workflow defines edges
edge_executor_ids: set[str] = set()
for _e in self._edges:
edge_executor_ids.add(_e.source_id)
edge_executor_ids.add(_e.target_id)
if start_executor_id not in edge_executor_ids:
raise GraphConnectivityError(
f"Start executor '{start_executor_id}' is not present in the workflow graph"
)
# Run all checks
self._validate_edge_duplication()
self._validate_handler_output_annotations()
@@ -5,11 +5,13 @@ import logging
import sys
import uuid
from collections.abc import AsyncIterable, Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any
from typing import Any
from agent_framework import AgentProtocol
from agent_framework._pydantic import AFBaseModel
from pydantic import Field
from ._agent import WorkflowAgent
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS
from ._edge import (
@@ -24,7 +26,7 @@ from ._edge import (
SwitchCaseEdgeGroupDefault,
)
from ._events import RequestInfoEvent, WorkflowCompletedEvent, WorkflowEvent
from ._executor import Executor, RequestInfoExecutor
from ._executor import AgentExecutor, Executor, RequestInfoExecutor
from ._runner import Runner
from ._runner_context import CheckpointState, InProcRunnerContext, RunnerContext
from ._shared_state import SharedState
@@ -39,9 +41,6 @@ else:
logger = logging.getLogger(__name__)
if TYPE_CHECKING: # Avoid runtime import cycles; enables proper type checking of as_agent return type
from ._agent import WorkflowAgent
class WorkflowRunResult(list[WorkflowEvent]):
"""A list of events generated during the workflow execution in non-streaming mode."""
@@ -368,8 +367,48 @@ class Workflow(AFBaseModel):
Returns:
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
"""
events = [event async for event in self.run_stream(message)]
return WorkflowRunResult(events)
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
from ._events import AgentRunEvent, AgentRunUpdateEvent # Local import to avoid cycles
raw_events = [event async for event in self.run_stream(message)]
# Coalesce streaming update events into a single AgentRunEvent per executor sequence.
coalesced: list[WorkflowEvent] = [] # type: ignore[name-defined]
pending_updates: list[AgentRunResponseUpdate] = []
pending_executor: str | None = None
def _flush_pending() -> None:
nonlocal pending_updates, pending_executor
if pending_executor is None or not pending_updates:
return
# Aggregate updates into a final AgentRunResponse using existing helper
aggregated = AgentRunResponse.from_agent_run_response_updates(pending_updates)
coalesced.append(AgentRunEvent(pending_executor, aggregated))
pending_updates = []
pending_executor = None
for ev in raw_events:
if isinstance(ev, AgentRunUpdateEvent):
# Start new grouping or continue existing if same executor
if pending_executor is None:
pending_executor = ev.executor_id
if ev.executor_id != pending_executor:
# Different executor encountered; flush previous first
_flush_pending()
pending_executor = ev.executor_id
if ev.data is not None:
pending_updates.append(ev.data)
# Do NOT append update event itself (non-streaming contract)
continue
# Flush before adding any non-update event
_flush_pending()
coalesced.append(ev)
# Flush any trailing updates
_flush_pending()
return WorkflowRunResult(coalesced)
async def run_from_checkpoint(
self,
@@ -423,7 +462,7 @@ class Workflow(AFBaseModel):
raise ValueError(f"Executor with ID {executor_id} not found.")
return self.executors[executor_id]
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
def _find_request_info_executor(self) -> RequestInfoExecutor | None:
"""Find the RequestInfoExecutor instance in this workflow.
Returns:
@@ -537,7 +576,7 @@ class Workflow(AFBaseModel):
)
)
def as_agent(self, name: str | None = None) -> "WorkflowAgent":
def as_agent(self, name: str | None = None) -> WorkflowAgent:
"""Create a WorkflowAgent that wraps this workflow.
Args:
@@ -568,18 +607,57 @@ class WorkflowBuilder:
self._start_executor: Executor | str | None = None
self._checkpoint_storage: CheckpointStorage | None = None
self._max_iterations: int = max_iterations
# Maps underlying AgentProtocol object id -> wrapped Executor so we reuse the same wrapper
# across set_start_executor / add_edge calls. Without this, unnamed agents (which receive
# random UUID based executor ids) end up wrapped multiple times, giving different ids for
# the start node vs edge nodes and triggering a GraphConnectivityError during validation.
self._agent_wrappers: dict[int, Executor] = {}
# Agents auto-wrapped by builder now always stream incremental updates.
def _add_executor(self, executor: Executor) -> str:
"""Add an executor to the map and return its ID."""
self._executors[executor.id] = executor
return executor.id
def _maybe_wrap_agent(self, candidate: Executor | AgentProtocol) -> Executor:
"""If the provided object implements AgentProtocol, wrap it in an AgentExecutor.
This allows fluent builder APIs to directly accept agents instead of
requiring callers to manually instantiate AgentExecutor.
"""
try: # Local import to avoid hard dependency at import time
from agent_framework import AgentProtocol # type: ignore
except Exception: # pragma: no cover - defensive
AgentProtocol = object # type: ignore
if isinstance(candidate, Executor): # Already an executor
return candidate
if isinstance(candidate, AgentProtocol): # type: ignore[arg-type]
# Reuse existing wrapper for the same agent instance if present
existing = self._agent_wrappers.get(id(candidate))
if existing is not None:
return existing
# Use agent name if available and unique among current executors
name = getattr(candidate, "name", None)
proposed_id: str | None = None
if name:
proposed_id = str(name)
if proposed_id in self._executors:
proposed_id = f"{proposed_id}-{uuid.uuid4().hex[:8]}"
wrapper = AgentExecutor(candidate, id=proposed_id, streaming=True)
self._agent_wrappers[id(candidate)] = wrapper
return wrapper
raise TypeError(
f"WorkflowBuilder expected an Executor or AgentProtocol instance; got {type(candidate).__name__}."
)
def add_edge(
self,
source: Executor,
target: Executor,
source: Executor | AgentProtocol,
target: Executor | AgentProtocol,
condition: Callable[[Any], bool] | None = None,
) -> "Self":
) -> Self:
"""Add a directed edge between two executors.
The output types of the source and the input types of the target must be compatible.
@@ -591,12 +669,18 @@ class WorkflowBuilder:
should be traversed based on the message type.
"""
# TODO(@taochen): Support executor factories for lazy initialization
source_id = self._add_executor(source)
target_id = self._add_executor(target)
source_exec = self._maybe_wrap_agent(source)
target_exec = self._maybe_wrap_agent(target)
source_id = self._add_executor(source_exec)
target_id = self._add_executor(target_exec)
self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition))
return self
def add_fan_out_edges(self, source: Executor, targets: Sequence[Executor]) -> "Self":
def add_fan_out_edges(
self,
source: Executor | AgentProtocol,
targets: Sequence[Executor | AgentProtocol],
) -> Self:
"""Add multiple edges to the workflow where messages from the source will be sent to all target.
The output types of the source and the input types of the targets must be compatible.
@@ -605,13 +689,19 @@ class WorkflowBuilder:
source: The source executor of the edges.
targets: A list of target executors for the edges.
"""
source_id = self._add_executor(source)
target_ids = [self._add_executor(target) for target in targets]
source_exec = self._maybe_wrap_agent(source)
target_execs = [self._maybe_wrap_agent(t) for t in targets]
source_id = self._add_executor(source_exec)
target_ids = [self._add_executor(t) for t in target_execs]
self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids))
return self
def add_switch_case_edge_group(self, source: Executor, cases: Sequence[Case | Default]) -> "Self":
def add_switch_case_edge_group(
self,
source: Executor | AgentProtocol,
cases: Sequence[Case | Default],
) -> Self:
"""Add an edge group that represents a switch-case statement.
The output types of the source and the input types of the targets must be compatible.
@@ -629,10 +719,13 @@ class WorkflowBuilder:
source: The source executor of the edges.
cases: A list of case objects that determine the target executor for each message.
"""
source_id = self._add_executor(source)
source_exec = self._maybe_wrap_agent(source)
source_id = self._add_executor(source_exec)
# Convert case data types to internal types that only uses target_id.
internal_cases: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
for case in cases:
# Allow case targets to be agents
case.target = self._maybe_wrap_agent(case.target) # type: ignore[attr-defined]
self._add_executor(case.target)
if isinstance(case, Default):
internal_cases.append(SwitchCaseEdgeGroupDefault(target_id=case.target.id))
@@ -644,10 +737,10 @@ class WorkflowBuilder:
def add_multi_selection_edge_group(
self,
source: Executor,
targets: Sequence[Executor],
source: Executor | AgentProtocol,
targets: Sequence[Executor | AgentProtocol],
selection_func: Callable[[Any, list[str]], list[str]],
) -> "Self":
) -> Self:
"""Add an edge group that represents a multi-selection execution model.
The output types of the source and the input types of the targets must be compatible.
@@ -662,13 +755,19 @@ class WorkflowBuilder:
targets: A list of target executors for the edges.
selection_func: A function that selects target executors for messages.
"""
source_id = self._add_executor(source)
target_ids = [self._add_executor(target) for target in targets]
source_exec = self._maybe_wrap_agent(source)
target_execs = [self._maybe_wrap_agent(t) for t in targets]
source_id = self._add_executor(source_exec)
target_ids = [self._add_executor(t) for t in target_execs]
self._edge_groups.append(FanOutEdgeGroup(source_id, target_ids, selection_func))
return self
def add_fan_in_edges(self, sources: Sequence[Executor], target: Executor) -> "Self":
def add_fan_in_edges(
self,
sources: Sequence[Executor | AgentProtocol],
target: Executor | AgentProtocol,
) -> Self:
"""Add multiple edges from sources to a single target executor.
The edges will be grouped together for synchronized processing, meaning
@@ -702,13 +801,15 @@ class WorkflowBuilder:
sources: A list of source executors for the edges.
target: The target executor for the edges.
"""
source_ids = [self._add_executor(source) for source in sources]
target_id = self._add_executor(target)
source_execs = [self._maybe_wrap_agent(s) for s in sources]
target_exec = self._maybe_wrap_agent(target)
source_ids = [self._add_executor(s) for s in source_execs]
target_id = self._add_executor(target_exec)
self._edge_groups.append(FanInEdgeGroup(source_ids, target_id))
return self
def add_chain(self, executors: Sequence[Executor]) -> "Self":
def add_chain(self, executors: Sequence[Executor | AgentProtocol]) -> Self:
"""Add a chain of executors to the workflow.
The output of each executor in the chain will be sent to the next executor in the chain.
@@ -719,20 +820,30 @@ class WorkflowBuilder:
Args:
executors: A list of executors to be added to the chain.
"""
for i in range(len(executors) - 1):
self.add_edge(executors[i], executors[i + 1])
# Wrap each candidate first to ensure stable IDs before adding edges
wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors]
for i in range(len(wrapped) - 1):
self.add_edge(wrapped[i], wrapped[i + 1])
return self
def set_start_executor(self, executor: Executor | str) -> "Self":
def set_start_executor(self, executor: Executor | AgentProtocol | str) -> Self:
"""Set the starting executor for the workflow.
Args:
executor: The starting executor, which can be an Executor instance or its ID.
"""
self._start_executor = executor
if isinstance(executor, str):
self._start_executor = executor
else:
wrapped = self._maybe_wrap_agent(executor) # type: ignore[arg-type]
self._start_executor = wrapped
# Ensure the start executor is present in the executor map so validation succeeds
# even if no edges are added yet, or before edges wrap the same agent again.
if wrapped.id not in self._executors:
self._executors[wrapped.id] = wrapped
return self
def set_max_iterations(self, max_iterations: int) -> "Self":
def set_max_iterations(self, max_iterations: int) -> Self:
"""Set the maximum number of iterations for the workflow.
Args:
@@ -741,7 +852,9 @@ class WorkflowBuilder:
self._max_iterations = max_iterations
return self
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "Self":
# Removed explicit set_agent_streaming() API; agents always stream updates.
def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> Self:
"""Enable checkpointing with the specified storage.
Args:
@@ -318,7 +318,7 @@ def test_logging_for_missing_input_types(caplog: Any) -> None:
class NoInputTypesExecutor(Executor):
# Handler without type annotation for input parameter
async def handle_message(self, message: Any, ctx: WorkflowContext) -> None:
async def handle_message(self, message: Any, ctx: WorkflowContext[Any]) -> None:
await ctx.send_message("processed")
def _discover_handlers(self) -> None:
@@ -581,7 +581,7 @@ def test_handler_ctx_missing_annotation_raises() -> None:
def test_handler_ctx_unsubscripted_workflow_context_raises() -> None:
class BadExecutor(Executor):
@handler
async def handle(self, message: str, ctx: WorkflowContext) -> None: # missing T
async def handle(self, message: str, ctx: WorkflowContext) -> None: # type: ignore # missing T
pass
start = StringExecutor(id="s")
@@ -4,7 +4,43 @@ from dataclasses import dataclass
from typing import Any
import pytest
from agent_framework.workflow import Executor, WorkflowBuilder, WorkflowContext, handler
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, AgentThread, BaseAgent, ChatMessage, Role
from agent_framework.workflow import AgentExecutor, Executor, WorkflowBuilder, WorkflowContext, handler
class DummyAgent(BaseAgent):
async def run(self, messages=None, *, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
norm: list[ChatMessage] = []
if messages:
for m in messages: # type: ignore[iteration-over-optional]
if isinstance(m, ChatMessage):
norm.append(m)
elif isinstance(m, str):
norm.append(ChatMessage(role=Role.USER, text=m))
return AgentRunResponse(messages=norm)
async def run_stream(self, messages=None, *, thread: AgentThread | None = None, **kwargs): # type: ignore[override]
# Minimal async generator
yield AgentRunResponseUpdate()
def test_builder_accepts_agents_directly():
agent1 = DummyAgent(id="agent1", name="writer")
agent2 = DummyAgent(id="agent2", name="reviewer")
wf = WorkflowBuilder().set_start_executor(agent1).add_edge(agent1, agent2).build()
# Confirm auto-wrapped executors use agent names as IDs
assert wf.start_executor_id == "writer"
assert any(isinstance(e, AgentExecutor) and e.id in {"writer", "reviewer"} for e in wf.executors.values())
def test_builder_agents_always_stream():
agent = DummyAgent(id="agentX", name="streamer")
wf = WorkflowBuilder().set_start_executor(agent).build()
exec_obj = wf.get_start_executor()
assert isinstance(exec_obj, AgentExecutor)
assert getattr(exec_obj, "_streaming", False) is True
@dataclass
@@ -2,6 +2,12 @@
## Installation
To install the base `agent_framework.workflow` package, please run:
```bash
pip install agent-framework-workflow
```
You can install the workflow package with visualization dependency:
```bash
@@ -9,3 +15,104 @@ pip install agent-framework-workflow[viz]
```
To export visualization images you also need to [install GraphViz](https://graphviz.org/download/).
## Samples Overview
## Foundational Concepts - Start Here
Begin with the `foundational` folder in order. These three samples introduce the core ideas of executors, edges, agents in workflows, and streaming.
| Sample | File | Concepts |
|--------|------|----------|
| Executors and Edges | [foundational/step1_executors_and_edges.py](./foundational/step1_executors_and_edges.py) | Minimal workflow with basic executors and edges |
| Agents in a Workflow | [foundational/step2_agents_in_a_workflow.py](./foundational/step2_agents_in_a_workflow.py) | Introduces `AgentExecutor`; calling agents inside a workflow |
| Streaming | [foundational/step3_streaming.py](./foundational/step3_streaming.py) | Extends workflows with event streaming |
Once comfortable with these, explore the rest of the samples.
---
## Samples Overview (by directory)
### agents
| Sample | File | Concepts |
|---|---|---|
| Azure Chat Agents Streaming | [agents/azure_chat_agents_streaming.py](./agents/azure_chat_agents_streaming.py) | Directly adds Azure agents as edges and handling streaming events |
| Custom Agent Executors | [agents/custom_agent_executors.py](./agents/custom_agent_executors.py) | Create executors to handle agent run methods |
| Foundry Chat Agents Streaming | [agents/foundry_chat_agents_streaming.py](./agents/foundry_chat_agents_streaming.py) | Directly adds Foundry agents as edges and handling streaming events |
| Workflow as Agent | [ai_agent/workflow_as_agent.py](./agents/workflow_as_agent.py) | Wrap a workflow so it can behave like an agent |
| Workflow as Agent + HITL | [ai_agent/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability |
### checkpoint
| Sample | File | Concepts |
|---|---|---|
| Checkpoint & Resume | [checkpoint/checkpoint_with_resume.py](./checkpoint/checkpoint_with_resume.py) | Create checkpoints, inspect them, and resume execution |
### conditional_edges
| Sample | File | Concepts |
|---|---|---|
| Edge Condition | [conditional_edges/edge_condition.py](./conditional_edges/edge_condition.py) | Conditional routing based on agent classification |
| Switch-Case Edge Group | [conditional_edges/switch_case_edge_group.py](./conditional_edges/switch_case_edge_group.py) | Switch-case branching using classifier outputs |
| Multi-Selection Edge Group | [conditional_edges/multi_selection_edge_group.py](./conditional_edges/multi_selection_edge_group.py) | Select one or many targets dynamically (subset fan-out) |
### fan_out_fan_in
| Sample | File | Concepts |
|---|---|---|
| Concurrent (Fan-out/Fan-in) | [fan_out_fan_in/fan_out_fan_in_edges.py](./fan_out_fan_in/fan_out_fan_in_edges.py) | Dispatch to multiple executors and aggregate results |
| Map-Reduce with Visualization | [fan_out_fan_in/map_reduce_and_visualization.py](./fan_out_fan_in/map_reduce_and_visualization.py) | Fan-out/fan-in pattern with GraphViz/diagram export |
### human_in_the_loop
| Sample | File | Concepts |
|---|---|---|
| Human-In-The-Loop (Guessing Game) | [human_in_the_loop/guessing_game_with_human_input.py](./human_in_the_loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human |
### loop
| Sample | File | Concepts |
|---|---|---|
| Simple Loop | [loop/simple_loop.py](./loop/simple_loop.py) | Feedback loop where an agent judges ABOVE/BELOW/MATCHED |
### orchestration
| Sample | File | Concepts |
|---|---|---|
| Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming |
| Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution |
### sequential
| Sample | File | Concepts |
|---|---|---|
| Sequential Executors | [sequential/sequential_executors.py](./sequential/sequential_executors.py) | Sequential workflow with explicit executor setup |
| Sequential (Streaming) | [sequential/sequential_streaming.py](./sequential/sequential_streaming.py) | Stream events from a simple sequential run |
### shared_states
| Sample | File | Concepts |
|---|---|---|
| Shared States | [shared_states/shared_states_with_agents.py](./shared_states/shared_states_with_agents.py) | Store in shared state once and later reuse across agents |
### sub_workflow
| Sample | File | Concepts |
|---|---|---|
| Sub-Workflow (Basics) | [sub_workflow/sub_workflow_basics.py](./sub_workflow/sub_workflow.py) | Wrap a workflow as an executor and orchestrate sub-workflows |
| Sub-Workflow: Request Interception | [sub_workflow/sub_workflow_request_interception.py](./sub_workflow/sub_workflow_request_interception.py) | Intercept/forward requests with decorators and request handling |
| Sub-Workflow: Parallel Requests | [sub_workflow/sub_workflow_parallel_requests.py](./sub_workflow/sub_workflow_parallel_requests.py) | Multi-type interception and external forwarding patterns |
### tracing
| Sample | File | Concepts |
|---|---|---|
| Tracing (Basics) | [tracing/tracing_basics.py](./tracing/tracing_basics.py) | Use basic tracing for workflow telemetry |
### visualization
| Sample | File | Concepts |
|---|---|---|
| Concurrent with Visualization | [visualization/concurrent_with_visualization.py](./visualization/concurrent_with_visualization.py) | Fan-out/fan-in workflow with diagram export |
Notes
- Agentbased samples use provider SDKs (Azure/OpenAI, etc.). Ensure credentials are configured, or adapt agents accordingly.
### Environment Variables
- **AzureChatClient**: Set Azure OpenAI environment variables as documented [here](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/chat_client/README.md#environment-variables).
These variables are required for samples that construct `AzureChatClient`
- **OpenAI** (used in orchestration samples):
- [OpenAIChatClient env vars](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/agents/openai_chat_client/README.md)
- [OpenAIResponsesClient env vars](https://github.com/microsoft/agent-framework/blob/main/python/samples/getting_started/agents/openai_responses_client/README.md)
@@ -0,0 +1,95 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
from azure.identity import AzureCliCredential
"""
Sample: Agents in a workflow with streaming
A Writer agent generates content, then a Reviewer agent critiques it.
The workflow uses streaming so you can observe incremental AgentRunUpdateEvent chunks as each agent produces tokens.
Purpose:
Show how to wire chat agents directly into a WorkflowBuilder pipeline where agents are auto wrapped as executors.
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.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, edges, events, and streaming runs.
"""
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
chat_client = AzureChatClient(credential=AzureCliCredential())
# Define two domain specific chat agents. The builder will wrap these as executors.
writer_agent = chat_client.create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer_agent",
)
reviewer_agent = chat_client.create_agent(
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
"Provide the feedback in the most concise manner possible."
),
name="reviewer_agent",
)
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
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."
):
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.
eid = event.executor_id
if eid != last_executor_id:
if last_executor_id is not None:
print()
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)
"""
Sample Output:
writer_agent: Charge Up Your Journey. Fun, Affordable, Electric.
reviewer_agent: Clear message, but consider highlighting SUV specific benefits (space, versatility) for stronger
impact. Try more vivid language to evoke excitement. Example: "Big on Space. Big on Fun. Electric for Everyone."
===== Final Output =====
Clear message, but consider highlighting SUV specific benefits (space, versatility) for stronger impact. Try more
vivid language to evoke excitement. Example: "Big on Space. Big on Fun. Electric for Everyone."
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,123 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import ChatAgent, ChatMessage
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import Executor, WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, handler
from azure.identity import AzureCliCredential
"""
Step 2: Agents in a Workflow non-streaming
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.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming or non streaming runs.
"""
class Writer(Executor):
"""Custom executor that owns a domain specific agent responsible for generating content.
This class demonstrates:
- Attaching a ChatAgent to an Executor so it participates as a node in a workflow.
- Using a @handler method to accept a typed input and forward a typed output via ctx.send_message.
"""
agent: ChatAgent
def __init__(self, chat_client: AzureChatClient, id: str = "writer"):
# Create a domain specific agent using your configured AzureChatClient.
agent = chat_client.create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
# Associate the agent with this executor node. The base Executor stores it on self.agent.
super().__init__(agent=agent, id=id)
@handler
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None:
"""Generate content using the agent and forward the updated conversation.
Contract for this handler:
- message is the inbound user ChatMessage.
- ctx is a WorkflowContext that expects a list[ChatMessage] to be sent downstream.
Pattern shown here:
1) Seed the conversation with the inbound message.
2) Run the attached agent to produce assistant messages.
3) Forward the cumulative messages to the next executor with ctx.send_message.
"""
# Start the conversation with the incoming user message.
messages: list[ChatMessage] = [message]
# Run the agent and extend the conversation with the agent's messages.
response = await self.agent.run(messages)
messages.extend(response.messages)
# Forward the accumulated messages to the next executor in the workflow.
await ctx.send_message(messages)
class Reviewer(Executor):
"""Custom executor that owns a review agent and completes the workflow.
This class demonstrates:
- Consuming a typed payload produced upstream.
- Emitting a terminal WorkflowCompletedEvent with the final text outcome.
"""
agent: ChatAgent
def __init__(self, chat_client: AzureChatClient, id: str = "reviewer"):
# Create a domain specific agent that evaluates and refines content.
agent = chat_client.create_agent(
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer."
),
)
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.
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.
"""
response = await self.agent.run(messages)
await ctx.add_event(WorkflowCompletedEvent(response.text))
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
chat_client = AzureChatClient(credential=AzureCliCredential())
# Instantiate the two agent backed executors.
writer = Writer(chat_client)
reviewer = Reviewer(chat_client)
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
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.
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())
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,172 @@
# Copyright (c) Microsoft. All rights reserved.
# import asyncio
# from agent_framework.foundry import FoundryChatClient
# from agent_framework.workflow import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
# from azure.identity.aio import AzureCliCredential
# """
# Sample: Agents in a workflow with streaming
# A Writer agent generates content, then a Reviewer agent critiques it.
# The workflow uses streaming so you can observe incremental AgentRunUpdateEvent chunks as each agent produces tokens.
# Purpose:
# Show how to wire chat agents directly into a WorkflowBuilder pipeline where agents are auto wrapped as executors.
# 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.
# Prerequisites:
# - Foundry Agent Service configured, along with the required environment variables.
# - Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
# - Basic familiarity with WorkflowBuilder, edges, events, and streaming runs.
# """
# async def main():
# """Build and run a simple two node agent workflow: Writer then Reviewer."""
# # Create the Foundry chat client.
# async with (
# AzureCliCredential() as credential,
# FoundryChatClient(async_credential=credential).create_agent(
# name="Writer",
# instructions=(
# "You are an excellent content writer.You create new content and edit contents based on the feedback."
# ),
# ) as writer_agent,
# FoundryChatClient(async_credential=credential).create_agent(
# name="Reviewer",
# instructions=(
# "You are an excellent content reviewer."
# "Provide actionable feedback to the writer about the provided content."
# "Provide the feedback in the most concise manner possible."
# ),
# ) as reviewer_agent,
# ):
# # Build the workflow using the fluent builder.
# # Set the start node and connect an edge from writer to reviewer.
# 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."
# ):
# 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.
# eid = event.executor_id
# if eid != last_executor_id:
# if last_executor_id is not None:
# print()
# 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)
# """
# Sample Output:
# writer_agent: Charge Up Your Journey. Fun, Affordable, Electric.
# reviewer_agent: Clear message, but consider highlighting SUV specific benefits
# (space, versatility) for stronger impact. Try more vivid language to evoke
# excitement. Example: "Big on Space. Big on Fun. Electric for Everyone."
# ===== Final Output =====
# Clear message, but consider highlighting SUV specific benefits (space, versatility)
# for stronger impact. Try more vivid language to evoke excitement. Example:
# "Big on Space. Big on Fun. Electric for Everyone."
# """
# if __name__ == "__main__":
# asyncio.run(main())
import asyncio
from contextlib import AsyncExitStack
from typing import Any
from collections.abc import Awaitable, Callable
from agent_framework.foundry import FoundryChatClient
from agent_framework.workflow import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
from azure.identity.aio import AzureCliCredential
async def create_foundry_agent() -> tuple[Callable[..., Awaitable[Any]], Callable[[], Awaitable[None]]]:
"""Helper method to create a Foundry agent factory and a close function.
This makes sure the async context managers are properly handled.
"""
stack = AsyncExitStack()
cred = await stack.enter_async_context(AzureCliCredential())
client = await stack.enter_async_context(FoundryChatClient(async_credential=cred))
async def agent(**kwargs: Any) -> Any:
return await stack.enter_async_context(client.create_agent(**kwargs))
async def close() -> None:
await stack.aclose()
return agent, close
async def main() -> None:
agent, close = await create_foundry_agent()
try:
writer = await agent(
name="Writer",
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
reviewer = await agent(
name="Reviewer",
instructions=(
"You are an excellent content reviewer. "
"Provide actionable feedback to the writer about the provided content. "
"Provide the feedback in the most concise manner possible."
),
)
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."
):
if isinstance(event, AgentRunUpdateEvent):
eid = event.executor_id
if eid != last_executor_id:
if last_executor_id is not None:
print()
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)
finally:
await close()
if __name__ == "__main__":
asyncio.run(main())
@@ -20,16 +20,41 @@ from agent_framework.workflow import (
WorkflowContext,
handler,
)
from step_10a_workflow_agent_reflection_pattern import ReviewRequest, ReviewResponse, Worker
from samples.getting_started.workflow.agents.workflow_as_agent_reflection_pattern import (
ReviewRequest,
ReviewResponse,
Worker,
)
"""
Sample: Workflow Agent with Human-in-the-Loop
Purpose:
This sample demonstrates how to build a workflow agent that escalates uncertain
decisions to a human manager. A Worker generates results, while a Reviewer
evaluates them. When the Reviewer is not confident, it escalates the decision
to a human via RequestInfoExecutor, receives the human response, and then
forwards that response back to the Worker.
Prerequisites:
- OpenAI account configured and accessible for OpenAIChatClient.
- Familiarity with WorkflowBuilder, Executor, and WorkflowContext from agent_framework.
- Understanding of request-response message handling (RequestInfoMessage, RequestResponse).
- (Optional) Review of reflection and escalation patterns, such as those in
workflow_as_agent_reflection.py.
"""
@dataclass
class HumanReviewRequest(RequestInfoMessage):
"""A request message type for escalation to a human reviewer."""
agent_request: ReviewRequest | None = None
class ReviewerWithHumanInTheLoop(Executor):
"""An executor that raises to human manager for review when not confident."""
"""Executor that always escalates reviews to a human manager."""
def __init__(self, worker_id: str, request_info_id: str) -> None:
super().__init__()
@@ -38,14 +63,13 @@ class ReviewerWithHumanInTheLoop(Executor):
@handler
async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse | HumanReviewRequest]) -> None:
print(f"🔍 Reviewer: Evaluating response for request {request.request_id[:8]}...")
# In this simplified example, we always escalate to a human manager.
# See workflow_as_agent_reflection.py for an implementation
# using an automated agent to make the review decision.
print(f"Reviewer: Evaluating response for request {request.request_id[:8]}...")
print("Reviewer: Escalating to human manager...")
# NOTE: for simplicity, we always escalate to human manager.
# See step_10a_workflow_agent_reflection_pattern.py for implementation
# using an chat client.
print("🔍 Reviewer: Escalate to human manager")
# Send to human manager
# Forward the request to a human manager by sending a HumanReviewRequest.
await ctx.send_message(
HumanReviewRequest(agent_request=request),
target_id=self._request_info_id,
@@ -55,79 +79,71 @@ class ReviewerWithHumanInTheLoop(Executor):
async def accept_human_review(
self, response: RequestResponse[HumanReviewRequest, ReviewResponse], ctx: WorkflowContext[ReviewResponse]
) -> None:
# Accept the human review response and forward it back to the Worker.
human_response = response.data
assert isinstance(human_response, ReviewResponse)
print(f"🔍 Reviewer: Accepting human review for request {human_response.request_id[:8]}...")
print(f"🔍 Reviewer: Human feedback: {human_response.feedback}")
print(f"🔍 Reviewer: Human approved: {human_response.approved}")
print("🔍 Reviewer: Forwarding human review back to worker...")
print(f"Reviewer: Accepting human review for request {human_response.request_id[:8]}...")
print(f"Reviewer: Human feedback: {human_response.feedback}")
print(f"Reviewer: Human approved: {human_response.approved}")
print("Reviewer: Forwarding human review back to worker...")
await ctx.send_message(human_response, target_id=self._worker_id)
async def main() -> None:
print("🚀 Starting Workflow Agent with Human-in-the-Loop Demo")
print("Starting Workflow Agent with Human-in-the-Loop Demo")
print("=" * 50)
# Create executors.
print("📝 Creating chat client and executors...")
# Create executors for the workflow.
print("Creating chat client and executors...")
mini_chat_client = OpenAIChatClient(ai_model_id="gpt-4.1-nano")
worker = Worker(chat_client=mini_chat_client)
request_info_executor = RequestInfoExecutor()
reviewer = ReviewerWithHumanInTheLoop(worker_id=worker.id, request_info_id=request_info_executor.id)
print("🏗️ Building workflow with Worker ↔ Reviewer cycle...")
# Create the workflow agent with an underlying reflection workflow.
print("Building workflow with Worker ↔ Reviewer cycle...")
# Build a workflow with bidirectional communication between Worker and Reviewer,
# and escalation paths for human review.
agent = (
WorkflowBuilder()
.add_edge(worker, reviewer) # <--- This edge allows the worker to send requests to the reviewer
.add_edge(reviewer, worker) # <--- This edge allows the reviewer to send feedback back to the worker
.add_edge(
reviewer, request_info_executor
) # <--- This edge allows the reviewer to send human input requests through the request info executor
.add_edge(
request_info_executor, reviewer
) # <--- This edge allows the human input to be forwarded back to the reviewer
.add_edge(worker, reviewer) # Worker sends requests to Reviewer
.add_edge(reviewer, worker) # Reviewer sends feedback to Worker
.add_edge(reviewer, request_info_executor) # Reviewer requests human input
.add_edge(request_info_executor, reviewer) # Human input forwarded back to Reviewer
.set_start_executor(worker)
.build()
.as_agent() # Convert the workflow to an agent.
.as_agent() # Convert workflow into an agent interface
)
print("🎯 Running workflow agent with user query...")
print("Running workflow agent with user query...")
print("Query: 'Write code for parallel reading 1 million files on disk and write to a sorted output file.'")
print("-" * 50)
# NOTE: you can also run the workflow directly, i.e., without the as_agent().
# Then, you will need to handle RequestInfoEvent and send response to the workflow
# using send_response().
# Run the agent.
# Run the agent with an initial query.
response = await agent.run(
"Write code for parallel reading 1 million Files on disk and write to a sorted output file."
)
#
# Find human review function call.
# TODO(ekzhu): update this to FunctionApprovalRequestContent
# monitor: https://github.com/microsoft/agent-framework/issues/285
# Locate the human review function call in the response messages.
human_review_function_call: FunctionCallContent | None = None
for message in response.messages:
for content in message.contents:
if isinstance(content, FunctionCallContent) and content.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME:
human_review_function_call = content
# Handle human review if needed.
# Handle the human review if required.
if human_review_function_call:
# Use WorkflowAgent.RequestInfoFunctionArgs to parse the request.
# Parse the human review request arguments.
if isinstance(human_review_function_call.arguments, str):
request = WorkflowAgent.RequestInfoFunctionArgs.model_validate_json(human_review_function_call.arguments)
else:
request = WorkflowAgent.RequestInfoFunctionArgs.model_validate(human_review_function_call.arguments)
# Mock a human approval.
# Mock a human response approval for demonstration purposes.
human_response = ReviewResponse(
request_id=request.data["agent_request"]["request_id"], feedback="Approved", approved=True
)
# Create the function call result to be sent back.
# TODO(ekzhu): update this to FunctionApprovalResponseContent
# monitor: https://github.com/microsoft/agent-framework/issues/285
# Create the function call result object to send back to the agent.
human_review_function_result = FunctionResultContent(
call_id=human_review_function_call.call_id,
result=human_response,
@@ -137,9 +153,9 @@ async def main() -> None:
print(f"📤 Agent Response: {response.messages[-1].text}")
print("=" * 50)
print("Workflow completed!")
print("Workflow completed!")
if __name__ == "__main__":
print("🎬 Initializing Workflow as Agent Sample...")
print("Initializing Workflow as Agent Sample...")
asyncio.run(main())
@@ -0,0 +1,221 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from uuid import uuid4
from agent_framework import AgentRunResponseUpdate, ChatClientProtocol, ChatMessage, Contents, Role
from agent_framework.openai import OpenAIChatClient
from agent_framework.workflow import AgentRunUpdateEvent, Executor, WorkflowBuilder, WorkflowContext, handler
from pydantic import BaseModel
"""
Sample: Workflow as Agent with Reflection and Retry Pattern
Purpose:
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.
Key Concepts Demonstrated:
- WorkflowAgent: Wraps a workflow to behave like a regular agent.
- Cyclic workflow design (Worker ↔ Reviewer) for iterative improvement.
- AgentRunUpdateEvent: Mechanism for emitting approved responses externally.
- Structured output parsing for review feedback using Pydantic.
- State management for pending requests and retry logic.
Prerequisites:
- OpenAI account configured and accessible for OpenAIChatClient.
- Familiarity with WorkflowBuilder, Executor, WorkflowContext, and event handling.
- Understanding of how agent messages are generated, reviewed, and re-submitted.
"""
@dataclass
class ReviewRequest:
"""Structured request passed from Worker to Reviewer for evaluation."""
request_id: str
user_messages: list[ChatMessage]
agent_messages: list[ChatMessage]
@dataclass
class ReviewResponse:
"""Structured response from Reviewer back to Worker."""
request_id: str
feedback: str
approved: bool
class Reviewer(Executor):
"""Executor that reviews agent responses and provides structured feedback."""
def __init__(self, chat_client: ChatClientProtocol) -> None:
super().__init__()
self._chat_client = chat_client
@handler
async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse]) -> None:
print(f"Reviewer: Evaluating response for request {request.request_id[:8]}...")
# Define structured schema for the LLM to return.
class _Response(BaseModel):
feedback: str
approved: bool
# Construct review instructions and context.
messages = [
ChatMessage(
role=Role.SYSTEM,
text=(
"You are a reviewer for an AI agent. Provide feedback on the "
"exchange between a user and the agent. Indicate approval only if:\n"
"- Relevance: response addresses the query\n"
"- Accuracy: information is correct\n"
"- Clarity: response is easy to understand\n"
"- Completeness: response covers all aspects\n"
"Do not approve until all criteria are satisfied."
),
)
]
# Add conversation history.
messages.extend(request.user_messages)
messages.extend(request.agent_messages)
# Add explicit review instruction.
messages.append(ChatMessage(role=Role.USER, text="Please review the agent's responses."))
print("Reviewer: Sending review request to LLM...")
response = await self._chat_client.get_response(messages=messages, response_format=_Response)
parsed = _Response.model_validate_json(response.messages[-1].text)
print(f"Reviewer: Review complete - Approved: {parsed.approved}")
print(f"Reviewer: Feedback: {parsed.feedback}")
# Send structured review result to Worker.
await ctx.send_message(
ReviewResponse(request_id=request.request_id, feedback=parsed.feedback, approved=parsed.approved)
)
class Worker(Executor):
"""Executor that generates responses and incorporates feedback when necessary."""
def __init__(self, chat_client: ChatClientProtocol) -> None:
super().__init__()
self._chat_client = chat_client
self._pending_requests: dict[str, tuple[ReviewRequest, list[ChatMessage]]] = {}
@handler
async def handle_user_messages(self, user_messages: list[ChatMessage], ctx: WorkflowContext[ReviewRequest]) -> None:
print("Worker: Received user messages, generating response...")
# Initialize chat with system prompt.
messages = [ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant.")]
messages.extend(user_messages)
print("Worker: Calling LLM to generate response...")
response = await self._chat_client.get_response(messages=messages)
print(f"Worker: Response generated: {response.messages[-1].text}")
# Add agent messages to context.
messages.extend(response.messages)
# Create review request and send to Reviewer.
request = ReviewRequest(request_id=str(uuid4()), user_messages=user_messages, agent_messages=response.messages)
print(f"Worker: Sending response for review (ID: {request.request_id[:8]})")
await ctx.send_message(request)
# Track request for possible retry.
self._pending_requests[request.request_id] = (request, messages)
@handler
async def handle_review_response(self, review: ReviewResponse, ctx: WorkflowContext[ReviewRequest]) -> None:
print(f"Worker: Received review for request {review.request_id[:8]} - Approved: {review.approved}")
if review.request_id not in self._pending_requests:
raise ValueError(f"Unknown request ID in review: {review.request_id}")
request, messages = self._pending_requests.pop(review.request_id)
if review.approved:
print("Worker: Response approved. Emitting to external consumer...")
contents: list[Contents] = []
for message in request.agent_messages:
contents.extend(message.contents)
# Emit approved result to external consumer via AgentRunUpdateEvent.
await ctx.add_event(
AgentRunUpdateEvent(self.id, data=AgentRunResponseUpdate(contents=contents, role=Role.ASSISTANT))
)
return
print(f"Worker: Response not approved. Feedback: {review.feedback}")
print("Worker: Regenerating response with feedback...")
# Incorporate review feedback.
messages.append(ChatMessage(role=Role.SYSTEM, text=review.feedback))
messages.append(
ChatMessage(role=Role.SYSTEM, text="Please incorporate the feedback and regenerate the response.")
)
messages.extend(request.user_messages)
# Retry with updated prompt.
response = await self._chat_client.get_response(messages=messages)
print(f"Worker: New response generated: {response.messages[-1].text}")
messages.extend(response.messages)
# Send updated request for re-review.
new_request = ReviewRequest(
request_id=review.request_id, user_messages=request.user_messages, agent_messages=response.messages
)
await ctx.send_message(new_request)
# Track new request for further evaluation.
self._pending_requests[new_request.request_id] = (new_request, messages)
async def main() -> None:
print("Starting Workflow Agent Demo")
print("=" * 50)
# Initialize chat clients and executors.
print("Creating chat client and executors...")
mini_chat_client = OpenAIChatClient(ai_model_id="gpt-4.1-nano")
chat_client = OpenAIChatClient(ai_model_id="gpt-4.1")
reviewer = Reviewer(chat_client=chat_client)
worker = Worker(chat_client=mini_chat_client)
print("Building workflow with Worker ↔ Reviewer cycle...")
agent = (
WorkflowBuilder()
.add_edge(worker, reviewer) # Worker sends responses to Reviewer
.add_edge(reviewer, worker) # Reviewer provides feedback to Worker
.set_start_executor(worker)
.build()
.as_agent() # Wrap workflow as an agent
)
print("Running workflow agent with user query...")
print("Query: 'Write code for parallel reading 1 million files on disk and write to a sorted output file.'")
print("-" * 50)
# Run agent in streaming mode to observe incremental updates.
async for event in agent.run_stream(
"Write code for parallel reading 1 million files on disk and write to a sorted output file."
):
print(f"Agent Response: {event}")
print("=" * 50)
print("Workflow completed!")
if __name__ == "__main__":
print("Initializing Workflow as Agent Sample...")
asyncio.run(main())
@@ -0,0 +1,310 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from pathlib import Path
from typing import Any
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
FileCheckpointStorage,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
from azure.identity import AzureCliCredential
"""
Sample: Checkpointing and Resuming a Workflow (with an Agent stage)
Purpose:
This sample shows how to enable checkpointing at superstep boundaries, persist both
executor-local state and shared workflow state, and then resume execution from a specific
checkpoint. The workflow demonstrates a simple text-processing pipeline that includes
an LLM-backed AgentExecutor stage.
Pipeline:
1) UpperCaseExecutor converts input to uppercase and records state.
2) ReverseTextExecutor reverses the string.
3) SubmitToLowerAgent prepares an AgentExecutorRequest for the lowercasing agent.
4) lower_agent (AgentExecutor) converts text to lowercase via Azure OpenAI.
5) FinalizeFromAgent emits a WorkflowCompletedEvent with the final result.
What you learn:
- How to persist executor state using ctx.get_state and ctx.set_state.
- How to persist shared workflow state using ctx.set_shared_state for cross-executor visibility.
- How to configure FileCheckpointStorage and call with_checkpointing on WorkflowBuilder.
- 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.
Prerequisites:
- Azure AI or Azure OpenAI available for AzureChatClient.
- Authentication with azure-identity via AzureCliCredential. Run az login locally.
- Filesystem access for writing JSON checkpoint files in a temp directory.
"""
# Define the temporary directory for storing checkpoints.
# These files allow the workflow to be resumed later.
DIR = os.path.dirname(__file__)
TEMP_DIR = os.path.join(DIR, "tmp", "checkpoints")
os.makedirs(TEMP_DIR, exist_ok=True)
class UpperCaseExecutor(Executor):
"""Uppercases the input text and persists both local and shared state."""
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
result = text.upper()
print(f"UpperCaseExecutor: '{text}' -> '{result}'")
# Persist executor-local state so it is captured in checkpoints
# and available after resume for observability or logic.
prev = await ctx.get_state() or {}
count = int(prev.get("count", 0)) + 1
await ctx.set_state({
"count": count,
"last_input": text,
"last_output": result,
})
# Write to shared_state so downstream executors and any resumed runs can read it.
await ctx.set_shared_state("original_input", text)
await ctx.set_shared_state("upper_output", result)
# Send transformed text to the next executor.
await ctx.send_message(result)
class SubmitToLowerAgent(Executor):
"""Builds an AgentExecutorRequest to send to the lowercasing agent while keeping shared-state visibility."""
def __init__(self, agent_id: str, id: str | None = None):
super().__init__(id=id)
self._agent_id = agent_id
@handler
async def submit(self, text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Demonstrate reading shared_state written by UpperCaseExecutor.
# Shared state survives across checkpoints and is visible to all executors.
orig = await ctx.get_shared_state("original_input")
upper = await ctx.get_shared_state("upper_output")
print(f"LowerAgent (shared_state): original_input='{orig}', upper_output='{upper}'")
# Build a minimal, deterministic prompt for the AgentExecutor.
prompt = f"Convert the following text to lowercase. Return ONLY the transformed text.\n\nText: {text}"
# Send to the AgentExecutor. should_respond=True instructs the agent to produce a reply.
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True),
target_id=self._agent_id,
)
class FinalizeFromAgent(Executor):
"""Consumes the AgentExecutorResponse and emits the terminal WorkflowCompletedEvent."""
@handler
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any]) -> None:
result = response.agent_run_response.text or ""
# Persist executor-local state for auditability when inspecting checkpoints.
prev = await ctx.get_state() or {}
count = int(prev.get("count", 0)) + 1
await ctx.set_state({
"count": count,
"last_output": result,
"final": True,
})
# Emit a terminal event so external consumers see the final value.
await ctx.add_event(WorkflowCompletedEvent(result))
class ReverseTextExecutor(Executor):
"""Reverses the input text and persists local state."""
def __init__(self, id: str):
"""Initialize the executor with an ID."""
super().__init__(id=id)
@handler
async def reverse_text(self, text: str, ctx: WorkflowContext[str]) -> None:
result = text[::-1]
print(f"ReverseTextExecutor: '{text}' -> '{result}'")
# Persist executor-local state so checkpoint inspection can reveal progress.
prev = await ctx.get_state() or {}
count = int(prev.get("count", 0)) + 1
await ctx.set_state({
"count": count,
"last_input": text,
"last_output": result,
})
# Forward the reversed string to the next stage.
await ctx.send_message(result)
async def main():
# Clear existing checkpoints in this sample directory for a clean run.
checkpoint_dir = Path(TEMP_DIR)
for file in checkpoint_dir.glob("*.json"):
file.unlink()
# Instantiate the pipeline executors.
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
# Configure the agent stage that lowercases the text.
chat_client = AzureChatClient(credential=AzureCliCredential())
lower_agent = AgentExecutor(
chat_client.create_agent(
instructions=("You transform text to lowercase. Reply with ONLY the transformed text.")
),
id="lower_agent",
)
# Bridge to the agent and terminalization stage.
submit_lower = SubmitToLowerAgent(agent_id=lower_agent.id, id="submit_lower")
finalize = FinalizeFromAgent(id="finalize")
# Backing store for checkpoints written by with_checkpointing.
checkpoint_storage = FileCheckpointStorage(storage_path=TEMP_DIR)
# Build the workflow with checkpointing enabled.
workflow = (
WorkflowBuilder(max_iterations=5)
.add_edge(upper_case_executor, reverse_text_executor) # Uppercase -> Reverse
.add_edge(reverse_text_executor, submit_lower) # Reverse -> Build Agent request
.add_edge(submit_lower, lower_agent) # Submit to AgentExecutor
.add_edge(lower_agent, finalize) # Agent output -> Finalize
.set_start_executor(upper_case_executor) # Entry point
.with_checkpointing(checkpoint_storage=checkpoint_storage) # Enable persistence
.build()
)
# Run the full workflow once and observe events as they stream.
print("Running workflow with initial message...")
async for event in workflow.run_stream(message="hello world"):
print(f"Event: {event}")
# Inspect checkpoints written during the run.
all_checkpoints = await checkpoint_storage.list_checkpoints()
if not all_checkpoints:
print("No checkpoints found!")
return
# All checkpoints created by this run share the same workflow_id.
workflow_id = all_checkpoints[0].workflow_id
# Dump a quick summary including shared_state keys to illustrate what persisted.
print("\nCheckpoint summary:")
for cp in sorted(all_checkpoints, key=lambda c: c.timestamp):
msg_count = sum(len(v) for v in cp.messages.values())
state_keys = sorted(list(cp.executor_states.keys())) if hasattr(cp, "executor_states") else []
orig = cp.shared_state.get("original_input") if hasattr(cp, "shared_state") else None
upper = cp.shared_state.get("upper_output") if hasattr(cp, "shared_state") else None
print(
f"- {cp.checkpoint_id} | "
f"iter={cp.iteration_count} | messages={msg_count} | states={state_keys} | "
f"shared_state: original_input='{orig}', upper_output='{upper}'"
)
# Offer an interactive selection of checkpoints to resume from.
sorted_cps = sorted([cp for cp in all_checkpoints if cp.workflow_id == workflow_id], key=lambda c: c.timestamp)
print("\nAvailable checkpoints to resume from:")
for idx, cp in enumerate(sorted_cps):
msg_count = sum(len(v) for v in cp.messages.values())
print(f" [{idx}] id={cp.checkpoint_id} iter={cp.iteration_count} messages={msg_count}")
user_input = input(
"\nEnter checkpoint index (or paste checkpoint id) to resume from, or press Enter to skip resume: "
).strip()
if not user_input:
print("No checkpoint selected. Exiting without resuming.")
return
chosen_cp_id: str | None = None
# Try as index first
if user_input.isdigit():
idx = int(user_input)
if 0 <= idx < len(sorted_cps):
chosen_cp_id = sorted_cps[idx].checkpoint_id
# Fall back to direct id match
if chosen_cp_id is None:
for cp in sorted_cps:
if cp.checkpoint_id.startswith(user_input): # allow prefix match for convenience
chosen_cp_id = cp.checkpoint_id
break
if chosen_cp_id is None:
print("Input did not match any checkpoint. Exiting without resuming.")
return
# You can reuse the same workflow graph definition and resume from a prior checkpoint.
# This second workflow instance does not enable checkpointing to show that resumption
# reads from stored state but need not write new checkpoints.
new_workflow = (
WorkflowBuilder(max_iterations=5)
.add_edge(upper_case_executor, reverse_text_executor)
.add_edge(reverse_text_executor, submit_lower)
.add_edge(submit_lower, lower_agent)
.add_edge(lower_agent, finalize)
.set_start_executor(upper_case_executor)
.build()
)
print(f"\nResuming from checkpoint: {chosen_cp_id}")
async for event in new_workflow.run_stream_from_checkpoint(chosen_cp_id, checkpoint_storage=checkpoint_storage):
print(f"Resumed Event: {event}")
"""
Sample Output:
Running workflow with initial message...
UpperCaseExecutor: 'hello world' -> 'HELLO WORLD'
Event: ExecutorInvokeEvent(executor_id=upper_case_executor)
Event: ExecutorCompletedEvent(executor_id=upper_case_executor)
ReverseTextExecutor: 'HELLO WORLD' -> 'DLROW OLLEH'
Event: ExecutorInvokeEvent(executor_id=reverse_text_executor)
Event: ExecutorCompletedEvent(executor_id=reverse_text_executor)
LowerAgent (shared_state): original_input='hello world', upper_output='HELLO WORLD'
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'
- a78c345a-e5d9-45ba-82c0-cb725452d91b | label='superstep_1' | iter=1 | messages=1 | states=['reverse_text_executor', 'upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
- 637c1dbd-a525-4404-9583-da03980537a2 | label='superstep_2' | iter=2 | messages=0 | states=['finalize', 'lower_agent', 'reverse_text_executor', 'submit_lower', 'upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
Available checkpoints to resume from:
[0] id=dfc63e72-... iter=0 messages=1 label='after_initial_execution'
[1] id=a78c345a-... iter=1 messages=1 label='superstep_1'
[2] id=637c1dbd-... iter=2 messages=0 label='superstep_2'
Enter checkpoint index (or paste checkpoint id) to resume from, or press Enter to skip resume: 1
Resuming from checkpoint: a78c345a-e5d9-45ba-82c0-cb725452d91b
LowerAgent (shared_state): original_input='hello world', upper_output='HELLO WORLD'
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
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,233 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Any
from agent_framework import ChatMessage, Role # Core chat primitives used to build requests
from agent_framework.azure import AzureChatClient # Thin client wrapper for Azure OpenAI chat models
from agent_framework.workflow import (
AgentExecutor, # Wraps an LLM agent that can be invoked inside a workflow
AgentExecutorRequest, # Input message bundle for an AgentExecutor
AgentExecutorResponse, # Output from an AgentExecutor
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
)
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from pydantic import BaseModel # Structured outputs for safer parsing
"""
Sample: Conditional routing with structured outputs
What this sample is:
- A minimal decision workflow that classifies an inbound email as spam or not spam, then routes to the
appropriate handler.
Purpose:
- Show how to attach boolean edge conditions that inspect an AgentExecutorResponse.
- Demonstrate using Pydantic models as response_format so the agent returns JSON we can validate and parse.
- Illustrate how to transform one agent's structured result into a new AgentExecutorRequest for a downstream agent.
Prerequisites:
- You understand the basics of WorkflowBuilder, executors, and events in this framework.
- You know the concept of edge conditions and how they gate routes using a predicate function.
- Azure OpenAI access is configured for AzureChatClient. You should be logged in with Azure CLI (AzureCliCredential)
and have the Azure OpenAI environment variables set as documented in the getting started chat client README.
- The sample email resource file exists at workflow/resources/email.txt.
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.
Output:
- The final WorkflowCompletedEvent 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.
"""
class DetectionResult(BaseModel):
"""Represents the result of spam detection."""
# is_spam drives the routing decision taken by edge conditions
is_spam: bool
# Human readable rationale from the detector
reason: str
# The agent must include the original email so downstream agents can operate without reloading content
email_content: str
class EmailResponse(BaseModel):
"""Represents the response from the email assistant."""
# The drafted reply that a user could copy or send
response: str
def get_condition(expected_result: bool):
"""Create a condition callable that routes based on DetectionResult.is_spam."""
# The returned function will be used as an edge predicate.
# It receives whatever the upstream executor produced.
def condition(message: Any) -> bool:
# Defensive guard. If a non AgentExecutorResponse appears, let the edge pass to avoid dead ends.
if not isinstance(message, AgentExecutorResponse):
return True
try:
# Prefer parsing a structured DetectionResult from the agent JSON text.
# Using model_validate_json ensures type safety and raises if the shape is wrong.
detection = DetectionResult.model_validate_json(message.agent_run_response.text)
# Route only when the spam flag matches the expected path.
return detection.is_spam == expected_result
except Exception:
# Fail closed on parse errors so we do not accidentally route to the wrong path.
# Returning False prevents this edge from activating.
return False
return condition
@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.
email_response = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(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.
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}"))
else:
# This indicates the routing predicate and executor contract are out of sync.
raise RuntimeError("This executor should only handle spam messages.")
@executor(id="to_email_assistant_request")
async def to_email_assistant_request(
response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest]
) -> None:
"""Transform detection result into an AgentExecutorRequest for the email assistant.
Extracts DetectionResult.email_content and forwards it as a user message.
"""
# Bridge executor. Converts a structured DetectionResult into a ChatMessage and forwards it as a new request.
detection = DetectionResult.model_validate_json(response.agent_run_response.text)
user_msg = ChatMessage(Role.USER, text=detection.email_content)
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
async def main() -> None:
# Create agents
# AzureCliCredential uses your current az login. This avoids embedding secrets in code.
chat_client = AzureChatClient(credential=AzureCliCredential())
# Agent 1. Classifies spam and returns a DetectionResult object.
# response_format enforces that the LLM returns parsable JSON for the Pydantic model.
spam_detection_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Always return JSON with fields is_spam (bool), reason (string), and email_content (string). "
"Include the original email content in email_content."
),
response_format=DetectionResult,
),
id="spam_detection_agent",
)
# Agent 2. Drafts a professional reply. Also uses structured JSON output for reliability.
email_assistant_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an email assistant that helps users draft professional responses to emails. "
"Your input may be a JSON object that includes 'email_content'; base your reply on that content. "
"Return JSON with a single field 'response' containing the drafted reply."
),
response_format=EmailResponse,
),
id="email_assistant_agent",
)
# Build the workflow graph.
# Start at the spam detector.
# If not spam, hop to a transformer that creates a new AgentExecutorRequest,
# then call the email assistant, then finalize.
# If spam, go directly to the spam handler and finalize.
workflow = (
WorkflowBuilder()
.set_start_executor(spam_detection_agent)
# Not spam path: transform response -> request for assistant -> assistant -> send email
.add_edge(spam_detection_agent, to_email_assistant_request, condition=get_condition(False))
.add_edge(to_email_assistant_request, email_assistant_agent)
.add_edge(email_assistant_agent, handle_email_response)
# Spam path: send to spam handler
.add_edge(spam_detection_agent, handle_spam_classifier_response, condition=get_condition(True))
.build()
)
# Read Email content from the sample resource file.
# This keeps the sample deterministic since the model sees the same email every run.
email_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "workflow", "resources", "email.txt"
)
with open(email_path) as email_file: # noqa: ASYNC230
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.
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}")
"""
Sample Output:
Processing email:
Subject: Team Meeting Follow-up - Action Items
Hi Sarah,
I wanted to follow up on our team meeting this morning and share the action items we discussed:
1. Update the project timeline by Friday
2. Schedule client presentation for next week
3. Review the budget allocation for Q4
Please let me know if you have any questions or if I missed anything from our discussion.
Best regards,
Alex Johnson
Project Manager
Tech Solutions Inc.
alex.johnson@techsolutions.com
(555) 123-4567
----------------------------------------
WorkflowCompletedEvent(data=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.
I will update the project timeline by Friday, begin scheduling the client presentation for next week, and start reviewing the Q4 budget allocation. If any questions or issues arise, I'll reach out.
Thank you again for outlining the next steps.
Best regards,
Sarah)
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,284 @@
# Copyright (c) Microsoft. All rights reserved.
"""Step 06b — Multi-Selection Edge Group sample."""
import asyncio
import os
from dataclasses import dataclass
from typing import Literal
from uuid import uuid4
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowEvent,
executor,
)
from azure.identity import AzureCliCredential
from pydantic import BaseModel
"""
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.
Purpose:
Demonstrate how to use a multi-selection edge group to fan out from one executor to multiple possible targets.
Show how to:
- Implement a selection function that chooses one or more downstream branches based on analysis.
- Share state across branches so different executors can read the same email content.
- Validate agent outputs with Pydantic models for robust structured data exchange.
- Merge results from multiple branches (e.g., a summary) back into a typed state.
- Apply conditional persistence logic (short vs long emails).
Prerequisites:
- Familiarity with WorkflowBuilder, executors, edges, and events.
- Understanding of multi-selection edge groups and how their selection function maps to target ids.
- Experience with shared state in workflows for persisting and reusing objects.
"""
EMAIL_STATE_PREFIX = "email:"
CURRENT_EMAIL_ID_KEY = "current_email_id"
LONG_EMAIL_THRESHOLD = 100
class AnalysisResultAgent(BaseModel):
spam_decision: Literal["NotSpam", "Spam", "Uncertain"]
reason: str
class EmailResponse(BaseModel):
response: str
class EmailSummaryModel(BaseModel):
summary: str
@dataclass
class Email:
email_id: str
email_content: str
@dataclass
class AnalysisResult:
spam_decision: str
reason: str
email_length: int
email_summary: str
email_id: str
class DatabaseEvent(WorkflowEvent): ...
@executor(id="store_email")
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
new_email = Email(email_id=str(uuid4()), email_content=email_text)
await ctx.set_shared_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
await ctx.set_shared_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=new_email.email_content)], should_respond=True)
)
@executor(id="to_analysis_result")
async def to_analysis_result(response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisResult]) -> None:
parsed = AnalysisResultAgent.model_validate_json(response.agent_run_response.text)
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{email_id}")
await ctx.send_message(
AnalysisResult(
spam_decision=parsed.spam_decision,
reason=parsed.reason,
email_length=len(email.email_content),
email_summary="",
email_id=email_id,
)
)
@executor(id="submit_to_email_assistant")
async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
if analysis.spam_decision != "NotSpam":
raise RuntimeError("This executor should only handle NotSpam messages.")
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
)
@executor(id="finalize_and_send")
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
@executor(id="summarize_email")
async def summarize_email(analysis: AnalysisResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Only called for long NotSpam emails by selection_func
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
)
@executor(id="merge_summary")
async def merge_summary(response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisResult]) -> None:
summary = EmailSummaryModel.model_validate_json(response.agent_run_response.text)
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{email_id}")
# Build an AnalysisResult mirroring to_analysis_result but with summary
await ctx.send_message(
AnalysisResult(
spam_decision="NotSpam",
reason="",
email_length=len(email.email_content),
email_summary=summary.summary,
email_id=email_id,
)
)
@executor(id="handle_spam")
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
if analysis.spam_decision == "Spam":
await ctx.add_event(WorkflowCompletedEvent(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:
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', '')}"
)
)
else:
raise RuntimeError("This executor should only handle Uncertain messages.")
@executor(id="database_access")
async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> 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."))
async def main() -> None:
# Agents
chat_client = AzureChatClient(credential=AzureCliCredential())
email_analysis_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) "
"and 'reason' (string)."
),
response_format=AnalysisResultAgent,
),
id="email_analysis_agent",
)
email_assistant_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an email assistant that helps users draft responses to emails with professionalism."
),
response_format=EmailResponse,
),
id="email_assistant_agent",
)
email_summary_agent = AgentExecutor(
chat_client.create_agent(
instructions=("You are an assistant that helps users summarize emails."),
response_format=EmailSummaryModel,
),
id="email_summary_agent",
)
# Build the workflow
def select_targets(analysis: AnalysisResult, target_ids: list[str]) -> list[str]:
# Order: [handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain]
handle_spam_id, submit_to_email_assistant_id, summarize_email_id, handle_uncertain_id = target_ids
if analysis.spam_decision == "Spam":
return [handle_spam_id]
if analysis.spam_decision == "NotSpam":
targets = [submit_to_email_assistant_id]
if analysis.email_length > LONG_EMAIL_THRESHOLD:
targets.append(summarize_email_id)
return targets
return [handle_uncertain_id]
workflow = (
WorkflowBuilder()
.set_start_executor(store_email)
.add_edge(store_email, email_analysis_agent)
.add_edge(email_analysis_agent, to_analysis_result)
.add_multi_selection_edge_group(
to_analysis_result,
[handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain],
selection_func=select_targets,
)
.add_edge(submit_to_email_assistant, email_assistant_agent)
.add_edge(email_assistant_agent, finalize_and_send)
.add_edge(summarize_email, email_summary_agent)
.add_edge(email_summary_agent, merge_summary)
# Save to DB if short (no summary path)
.add_edge(to_analysis_result, database_access, condition=lambda r: r.email_length <= LONG_EMAIL_THRESHOLD)
# Save to DB with summary when long
.add_edge(merge_summary, database_access)
.build()
)
# Read an email sample
resources_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources", "email.txt")
if os.path.exists(resources_path):
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
email = f.read()
else:
email = "Hello team, here are the updates for this week..."
async for event in workflow.run_stream(email):
if isinstance(event, (WorkflowCompletedEvent, DatabaseEvent)):
print(f"{event}")
"""
Sample Output:
WorkflowCompletedEvent(data=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.
I'll aim to have the updated project timeline ready by Friday and will
coordinate with the team to schedule the client presentation for next week.
I'll also review the Q4 budget allocation and share my feedback soon.
If anything else comes up, please let me know.
Best regards,
Sarah)
DatabaseEvent(data=Email 32021432-2d4e-4c54-b04c-f81b4120340c saved to database.)
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,221 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dataclasses import dataclass
from typing import Any, Literal
from uuid import uuid4
from agent_framework import ChatMessage, Role # Core chat primitives used to form LLM requests
from agent_framework.azure import AzureChatClient # Thin client for Azure OpenAI chat models
from agent_framework.workflow import (
AgentExecutor, # Wraps an agent so it can run inside a workflow
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorResponse, # Result returned by an AgentExecutor
Case, # Case entry for a switch-case edge group
Default, # Default branch when no cases match
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
)
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from pydantic import BaseModel # Structured outputs with validation
"""
Sample: Switch-Case Edge Group with an explicit Uncertain branch.
The workflow stores a single email in shared state, asks a spam detection agent for a three way decision,
then routes with a switch-case group: NotSpam to the drafting assistant, Spam to a spam handler, and
Default to an Uncertain handler.
Purpose:
Demonstrate deterministic one of N routing with switch-case edges. Show how to:
- Persist input once in shared state, then pass around a small typed pointer that carries the email id.
- 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.
Prerequisites:
- Familiarity with WorkflowBuilder, executors, edges, and events.
- Understanding of switch-case edge groups and how Case and Default are evaluated in order.
- Working Azure OpenAI configuration for AzureChatClient, with Azure CLI login and required environment variables.
- Access to workflow/resources/ambiguous_email.txt, or accept the inline fallback string.
"""
EMAIL_STATE_PREFIX = "email:"
CURRENT_EMAIL_ID_KEY = "current_email_id"
class DetectionResultAgent(BaseModel):
"""Structured output returned by the spam detection agent."""
# The agent classifies the email and provides a rationale.
spam_decision: Literal["NotSpam", "Spam", "Uncertain"]
reason: str
class EmailResponse(BaseModel):
"""Structured output returned by the email assistant agent."""
# The drafted professional reply.
response: str
@dataclass
class DetectionResult:
# Internal typed payload used for routing and downstream handling.
spam_decision: str
reason: str
email_id: str
@dataclass
class Email:
# In memory record of the email content stored in shared state.
email_id: str
email_content: str
def get_case(expected_decision: str):
"""Factory that returns a predicate matching a specific spam_decision value."""
def condition(message: Any) -> bool:
# Only match when the upstream payload is a DetectionResult with the expected decision.
return isinstance(message, DetectionResult) and message.spam_decision == expected_decision
return condition
@executor(id="store_email")
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Persist the raw email once. Store under a unique key and set the current pointer for convenience.
new_email = Email(email_id=str(uuid4()), email_content=email_text)
await ctx.set_shared_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
await ctx.set_shared_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
# Kick off the detector by forwarding the email as a user message to the spam_detection_agent.
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=new_email.email_content)], should_respond=True)
)
@executor(id="to_detection_result")
async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None:
# Parse the detector JSON into a typed model. Attach the current email id for downstream lookups.
parsed = DetectionResultAgent.model_validate_json(response.agent_run_response.text)
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
await ctx.send_message(DetectionResult(spam_decision=parsed.spam_decision, reason=parsed.reason, email_id=email_id))
@executor(id="submit_to_email_assistant")
async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Only proceed for the NotSpam branch. Guard against accidental misrouting.
if detection.spam_decision != "NotSpam":
raise RuntimeError("This executor should only handle NotSpam messages.")
# Load the original content from shared state using the id carried in DetectionResult.
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
)
@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.
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
@executor(id="handle_spam")
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[None]) -> 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}"))
else:
raise RuntimeError("This executor should only handle Spam messages.")
@executor(id="handle_uncertain")
async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[None]) -> 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', '')}"
)
)
else:
raise RuntimeError("This executor should only handle Uncertain messages.")
async def main():
"""Main function to run the workflow."""
chat_client = AzureChatClient(credential=AzureCliCredential())
# Agents. response_format enforces that the LLM returns JSON that Pydantic can validate.
spam_detection_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Be less confident in your assessments. "
"Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) "
"and 'reason' (string)."
),
response_format=DetectionResultAgent,
),
id="spam_detection_agent",
)
email_assistant_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an email assistant that helps users draft responses to emails with professionalism."
),
response_format=EmailResponse,
),
id="email_assistant_agent",
)
# Build workflow: store -> detection agent -> to_detection_result -> switch (NotSpam or Spam or Default).
# The switch-case group evaluates cases in order, then falls back to Default when none match.
workflow = (
WorkflowBuilder()
.set_start_executor(store_email)
.add_edge(store_email, spam_detection_agent)
.add_edge(spam_detection_agent, to_detection_result)
.add_switch_case_edge_group(
to_detection_result,
[
Case(condition=get_case("NotSpam"), target=submit_to_email_assistant),
Case(condition=get_case("Spam"), target=handle_spam),
Default(target=handle_uncertain),
],
)
.add_edge(submit_to_email_assistant, email_assistant_agent)
.add_edge(email_assistant_agent, finalize_and_send)
.build()
)
# Read ambiguous email if available. Otherwise use a simple inline sample.
resources_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources", "ambiguous_email.txt")
if os.path.exists(resources_path):
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
email = f.read()
else:
email = (
"Hey there, I noticed you might be interested in our latest offer—no pressure, but it expires soon. "
"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}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing import Any
from agent_framework import ChatMessage, Role # Core chat primitives to build LLM requests
from agent_framework.azure import AzureChatClient # Client wrapper for Azure OpenAI chat models
from agent_framework.workflow import (
AgentExecutor, # Wraps an LLM agent for use inside a workflow
AgentExecutorRequest, # The message bundle sent to an AgentExecutor
AgentExecutorResponse, # The structured result returned by an AgentExecutor
AgentRunEvent, # Tracing event for agent execution steps
Executor, # Base class for custom Python executors
WorkflowBuilder, # Fluent builder for wiring the workflow graph
WorkflowCompletedEvent, # Terminal event carrying the final result
WorkflowContext, # Per run context and event bus
handler, # Decorator to mark an Executor method as invokable
)
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
"""
Sample: Concurrent fan out and fan in with three domain agents
A dispatcher fans out the same user prompt to research, marketing, and legal AgentExecutor nodes.
An aggregator then fans in their responses and produces a single consolidated report.
Purpose:
Show how to construct a parallel branch pattern in workflows. Demonstrate:
- Fan out by targeting multiple AgentExecutor nodes from one dispatcher.
- Fan in by collecting a list of AgentExecutorResponse objects and reducing them to a single result.
- Simple tracing using AgentRunEvent to observe execution order and progress.
Prerequisites:
- Familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
- Azure OpenAI access configured for AzureChatClient. Log in with Azure CLI and set any required environment variables.
- Comfort reading AgentExecutorResponse.agent_run_response.text for assistant output aggregation.
"""
class DispatchToExperts(Executor):
"""Dispatches the incoming prompt to all expert agent executors for parallel processing (fan out)."""
def __init__(self, expert_ids: list[str], id: str | None = None):
super().__init__(id)
self._expert_ids = expert_ids
@handler
async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Wrap the incoming prompt as a user message for each expert and request a response.
# Each send_message targets a different AgentExecutor by id so that branches run in parallel.
initial_message = ChatMessage(Role.USER, text=prompt)
for expert_id in self._expert_ids:
await ctx.send_message(
AgentExecutorRequest(messages=[initial_message], should_respond=True),
target_id=expert_id,
)
@dataclass
class AggregatedInsights:
"""Typed container for the aggregator to hold per domain strings before formatting."""
research: str
marketing: str
legal: str
class AggregateInsights(Executor):
"""Aggregates expert agent responses into a single consolidated result (fan in)."""
def __init__(self, expert_ids: list[str], id: str | None = None):
super().__init__(id)
self._expert_ids = expert_ids
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
# Map responses to text by executor id for a simple, predictable demo.
by_id: dict[str, str] = {}
for r in results:
# AgentExecutorResponse.agent_run_response.text is the assistant text produced by the agent.
by_id[r.executor_id] = r.agent_run_response.text
research_text = by_id.get("researcher", "")
marketing_text = by_id.get("marketer", "")
legal_text = by_id.get("legal", "")
aggregated = AggregatedInsights(
research=research_text,
marketing=marketing_text,
legal=legal_text,
)
# Provide a readable, consolidated string as the final workflow result.
consolidated = (
"Consolidated Insights\n"
"====================\n\n"
f"Research Findings:\n{aggregated.research}\n\n"
f"Marketing Angle:\n{aggregated.marketing}\n\n"
f"Legal/Compliance Notes:\n{aggregated.legal}\n"
)
await ctx.add_event(WorkflowCompletedEvent(data=consolidated))
async def main() -> None:
# 1) Create agent executors for domain experts
chat_client = AzureChatClient(credential=AzureCliCredential())
researcher = AgentExecutor(
chat_client.create_agent(
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
),
id="researcher",
)
marketer = AgentExecutor(
chat_client.create_agent(
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
),
id="marketer",
)
legal = AgentExecutor(
chat_client.create_agent(
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
),
id="legal",
)
expert_ids = [researcher.id, marketer.id, legal.id]
dispatcher = DispatchToExperts(expert_ids=expert_ids, id="dispatcher")
aggregator = AggregateInsights(expert_ids=expert_ids, id="aggregator")
# 2) Build a simple fan out and fan in workflow
workflow = (
WorkflowBuilder()
.set_start_executor(dispatcher)
.add_fan_out_edges(dispatcher, [researcher, marketer, legal]) # Parallel branches
.add_fan_in_edges([researcher, marketer, legal], aggregator) # Join at the aggregator
.build()
)
# 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)
if __name__ == "__main__":
asyncio.run(main())
@@ -9,24 +9,35 @@ from typing import Any
import aiofiles
from agent_framework.workflow import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowViz,
handler,
Executor, # Base class for custom workflow steps
WorkflowBuilder, # Fluent graph builder for executors and edges
WorkflowCompletedEvent, # Terminal event that carries final output
WorkflowContext, # Per run context with shared state and messaging
WorkflowViz, # Utility to visualize a workflow graph
handler, # Decorator to expose an Executor method as a step
)
"""
The following sample demonstrates a basic map reduce workflow that
processes a large text file by splitting it into smaller chunks,
mapping each word to a count, shuffling the results, and reducing them
to a final count per word.
Sample: Map reduce word count with fan out and fan in over file backed intermediate results
Intermediate results are stored in a temporary directory, and the
final results are written to a file in the same directory.
The workflow splits a large text into chunks, maps words to counts in parallel,
shuffles intermediate pairs to reducers, then reduces to per word totals.
It also demonstrates WorkflowViz for graph visualization.
This sample also shows how you can visualize a workflow using `WorkflowViz`.
Purpose:
Show how to:
- Partition input once and coordinate parallel mappers with shared state.
- Implement map, shuffle, and reduce executors that pass file paths instead of large payloads.
- Use fan out and fan in edges to express parallelism and joins.
- Persist intermediate results to disk to bound memory usage for large inputs.
- Visualize the workflow graph using WorkflowViz and export to SVG with the optional viz extra.
Prerequisites:
- Familiarity with WorkflowBuilder, executors, fan out and fan in edges, events, and streaming runs.
- aiofiles installed for async file I/O.
- Write access to a tmp directory next to this script.
- A source text at resources/long_text.txt.
- Optional for SVG export: install the viz extra for agent framework workflow.
"""
# Define the temporary directory for storing intermediate results
@@ -40,45 +51,43 @@ SHARED_STATE_DATA_KEY = "data_to_be_processed"
class SplitCompleted:
"""A class to signal the completion of the Split executor."""
"""Marker type published when splitting finishes. Triggers map executors."""
...
class Split(Executor):
"""An executor that splits data into smaller chunks based on the number of nodes available."""
"""Splits data into roughly equal chunks based on the number of mapper nodes."""
def __init__(self, map_executor_ids: list[str], id: str | None = None):
"""Initialize the executor with the number of nodes."""
"""Store mapper ids so we can assign non overlapping ranges per mapper."""
super().__init__(id)
self._map_executor_ids = map_executor_ids
@handler
async def split(self, data: str, ctx: WorkflowContext[SplitCompleted]) -> None:
"""Execute the task by splitting the data into chunks.
"""Tokenize input and assign contiguous index ranges to each mapper via shared state.
Args:
data: A string containing the text to be processed.
ctx: The execution context containing the shared state and other information.
data: The raw text to process.
ctx: Workflow context to persist shared state and send messages.
"""
# Process data into a list of words and remove empty lines/words.
# Process data into a list of words and remove empty lines or words.
word_list = self._preprocess(data)
# Store the data to be processed state for later use.
# Store tokenized words once so all mappers can read by index.
await ctx.set_shared_state(SHARED_STATE_DATA_KEY, word_list)
# Split the word_list into chunks that are represented by the start and end indices.
# The start and end indices tuples will be stored in the shared state.
# Divide indices into contiguous slices for each mapper.
map_executor_count = len(self._map_executor_ids)
chunk_size = len(word_list) // map_executor_count # Assuming map_executor_count is not 0.
chunk_size = len(word_list) // map_executor_count # Assumes count > 0.
async def _process_chunk(i: int) -> None:
"""Process each chunk and send a message to the executor."""
"""Assign the slice for mapper i, then signal that splitting is done."""
start_index = i * chunk_size
end_index = start_index + chunk_size if i < map_executor_count - 1 else len(word_list)
# The start and end indices are stored in the shared state for the MapExecutor.
# This allows the MapExecutor to know which part of the data it should process.
# The mapper reads its slice from shared state keyed by its own executor id.
await ctx.set_shared_state(self._map_executor_ids[i], (start_index, end_index))
await ctx.send_message(SplitCompleted(), self._map_executor_ids[i])
@@ -86,42 +95,36 @@ class Split(Executor):
await asyncio.gather(*tasks)
def _preprocess(self, data: str) -> list[str]:
"""Preprocess the input data and return a list of words.
Args:
data: The input data to be processed.
Returns:
A list of words extracted from the input data.
"""
"""Normalize lines and split on whitespace. Return a flat list of tokens."""
line_list = [line.strip() for line in data.splitlines() if line.strip()]
return [word for line in line_list for word in line.split() if word]
@dataclass
class MapCompleted:
"""A data class to hold the completed state of the MapExecutor."""
"""Signal that a mapper wrote its intermediate pairs to file."""
file_path: str
class Map(Executor):
"""An executor that applies a function to each item in the data and save the result to a file."""
"""Maps each token to a count of 1 and writes pairs to a per mapper file."""
@handler
async def map(self, _: SplitCompleted, ctx: WorkflowContext[MapCompleted]) -> None:
"""Execute the task by applying a function to each item and same result to a file.
"""Read the assigned slice, emit (word, 1) pairs, and persist to disk.
Args:
data: An instance of SplitCompleted signaling the map step can be started.
ctx: The execution context containing the shared state and other information.
_: SplitCompleted marker indicating maps can begin.
ctx: Workflow context for shared state access and messaging.
"""
# Retrieve the data to be processed from the shared state.
# Retrieve tokens and our assigned slice.
data_to_be_processed: list[str] = await ctx.get_shared_state(SHARED_STATE_DATA_KEY)
chunk_start, chunk_end = await ctx.get_shared_state(self.id)
results = [(item, 1) for item in data_to_be_processed[chunk_start:chunk_end]]
# Write this mapper's results as simple text lines for easy debugging.
file_path = os.path.join(TEMP_DIR, f"map_results_{self.id}.txt")
async with aiofiles.open(file_path, "w") as f:
await f.writelines([f"{item}: {count}\n" for item, count in results])
@@ -131,32 +134,32 @@ class Map(Executor):
@dataclass
class ShuffleCompleted:
"""A data class to hold the completed state of the ShuffleExecutor."""
"""Signal that a shuffle partition file is ready for a specific reducer."""
file_path: str
reducer_id: str
class Shuffle(Executor):
"""An executor that redistributes results from the map step to the reduce step."""
"""Groups intermediate pairs by key and partitions them across reducers."""
def __init__(self, reducer_ids: list[str], id: str | None = None):
"""Initialize the executor with the number of nodes."""
"""Remember reducer ids so we can partition work deterministically."""
super().__init__(id)
self._reducer_ids = reducer_ids
@handler
async def shuffle(self, data: list[MapCompleted], ctx: WorkflowContext[ShuffleCompleted]) -> None:
"""Execute the task by aggregating the results.
"""Aggregate mapper outputs and write one partition file per reducer.
Args:
data: A list of MapCompleted instances containing the file paths of the map results.
ctx: The execution context containing the shared state and other information.
data: MapCompleted records with file paths for each mapper output.
ctx: Workflow context to emit per reducer ShuffleCompleted messages.
"""
chunks = await self._preprocess(data)
async def _process_chunk(chunk: list[tuple[str, list[int]]], index: int) -> None:
"""Process each chunk and save it to a file."""
"""Write one grouped partition for reducer index and notify that reducer."""
file_path = os.path.join(TEMP_DIR, f"shuffle_results_{index}.txt")
async with aiofiles.open(file_path, "w") as f:
await f.writelines([f"{key}: {value}\n" for key, value in chunk])
@@ -166,15 +169,12 @@ class Shuffle(Executor):
await asyncio.gather(*tasks)
async def _preprocess(self, data: list[MapCompleted]) -> list[list[tuple[str, list[int]]]]:
"""Preprocess the input data and return a list of data to be processed by the reduce executors.
Args:
data: A list of MapCompleted instances containing the file paths of the map results.
"""Load all mapper files, group by key, sort keys, and partition for reducers.
Returns:
A list of lists, where each inner list contains tuples of (key, value) pairs to be processed
by the reduce executors.
List of partitions. Each partition is a list of (key, [1, 1, ...]) tuples.
"""
# Load all intermediate pairs.
map_results: list[tuple[str, int]] = []
for result in data:
async with aiofiles.open(result.file_path, "r") as f:
@@ -182,20 +182,16 @@ class Shuffle(Executor):
(line.strip().split(": ")[0], int(line.strip().split(": ")[1])) for line in await f.readlines()
])
# Group values by the first element
# Group values by token.
intermediate_results: defaultdict[str, list[int]] = defaultdict(list[int])
for item in map_results:
key = item[0]
value = item[1]
for key, value in map_results:
intermediate_results[key].append(value)
# Convert defaultdict to a list
# Deterministic ordering helps with debugging and test stability.
aggregated_results = [(key, values) for key, values in intermediate_results.items()]
# Sort by the first element
aggregated_results.sort(key=lambda x: x[0])
# Split the intermediate results into chunks for the reduce executors
# Partition keys across reducers as evenly as possible.
reduce_executor_count = len(self._reducer_ids)
chunk_size = len(aggregated_results) // reduce_executor_count
remaining = len(aggregated_results) % reduce_executor_count
@@ -203,7 +199,6 @@ class Shuffle(Executor):
chunks = [
aggregated_results[i : i + chunk_size] for i in range(0, len(aggregated_results) - remaining, chunk_size)
]
# Append the remaining items to the last chunk
if remaining > 0:
chunks[-1].extend(aggregated_results[-remaining:])
@@ -212,37 +207,37 @@ class Shuffle(Executor):
@dataclass
class ReduceCompleted:
"""A data class to hold the completed state of the ReduceExecutor."""
"""Signal that a reducer wrote final counts for its partition."""
file_path: str
class Reduce(Executor):
"""An executor that reduces the results from the ShuffleExecutor."""
"""Sums grouped counts per key for its assigned partition."""
@handler
async def _execute(self, data: ShuffleCompleted, ctx: WorkflowContext[ReduceCompleted]) -> None:
"""Execute the task by reducing the results.
"""Read one shuffle partition and reduce it to totals.
Args:
data: An instance of ShuffleCompleted containing the file path of the shuffle results.
ctx: The execution context containing the shared state and other information.
data: ShuffleCompleted with the partition file path and target reducer id.
ctx: Workflow context used to emit ReduceCompleted with our output file path.
"""
if data.reducer_id != self.id:
# If the reducer ID does not match, skip processing.
# This partition belongs to a different reducer. Skip.
return
# Read the intermediate results from the file
# Read grouped values from the shuffle output.
async with aiofiles.open(data.file_path, "r") as f:
lines = await f.readlines()
# Aggregate the results
# Sum values per key. Values are serialized Python lists like [1, 1, ...].
reduced_results: dict[str, int] = defaultdict(int)
for line in lines:
key, value = line.split(": ")
reduced_results[key] = sum(ast.literal_eval(value))
# Write the reduced results to a file
# Persist our partition totals.
file_path = os.path.join(TEMP_DIR, f"reduced_results_{self.id}.txt")
async with aiofiles.open(file_path, "w") as f:
await f.writelines([f"{key}: {value}\n" for key, value in reduced_results.items()])
@@ -251,21 +246,16 @@ class Reduce(Executor):
class CompletionExecutor(Executor):
"""An executor that completes the workflow by aggregating the results from the ReduceExecutors."""
"""Joins all reducer outputs and emits the final completion event."""
@handler
async def complete(self, data: list[ReduceCompleted], ctx: WorkflowContext[Any]) -> None:
"""Execute the task by aggregating the results.
Args:
data: A list of ReduceCompleted instances containing the file paths of the reduced results.
ctx: The execution context containing the shared state and other information.
"""
"""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 main():
"""Main function to run the workflow."""
"""Construct the map reduce workflow, visualize it, then run it over a sample file."""
# Step 1: Create the executors.
map_operations = [Map(id=f"map_executor_{i}") for i in range(3)]
split_operation = Split(
@@ -279,34 +269,34 @@ async def main():
)
completion_executor = CompletionExecutor(id="completion_executor")
# Step 2: Build the workflow.
# Step 2: Build the workflow graph using fan out and fan in edges.
workflow = (
WorkflowBuilder()
.set_start_executor(split_operation)
.add_fan_out_edges(split_operation, map_operations)
.add_fan_in_edges(map_operations, shuffle_operation)
.add_fan_out_edges(shuffle_operation, reduce_operations)
.add_fan_in_edges(reduce_operations, completion_executor)
.add_fan_out_edges(split_operation, map_operations) # Split -> many mappers
.add_fan_in_edges(map_operations, shuffle_operation) # All mappers -> shuffle
.add_fan_out_edges(shuffle_operation, reduce_operations) # Shuffle -> many reducers
.add_fan_in_edges(reduce_operations, completion_executor) # All reducers -> completion
.build()
)
# Step 2.5: Visualize the workflow (optional)
print("🎨 Generating workflow visualization...")
print("Generating workflow visualization...")
viz = WorkflowViz(workflow)
# Print out the mermaid string.
print("🧜 Mermaid string: \n=======")
# Print out the Mermaid string.
print("Mermaid string: \n=======")
print(viz.to_mermaid())
print("=======")
# Print out the DiGraph string.
print("📊 DiGraph string: \n=======")
print("DiGraph string: \n=======")
print(viz.to_digraph())
print("=======")
try:
# Export the DiGraph visualization as SVG.
svg_file = viz.export(format="svg")
print(f"🖼️ SVG file saved to: {svg_file}")
print(f"SVG file saved to: {svg_file}")
except ImportError:
print("💡 Tip: Install 'viz' extra to export workflow visualization: pip install agent-framework-workflow[viz]")
print("Tip: Install 'viz' extra to export workflow visualization: pip install agent-framework-workflow[viz]")
# Step 3: Open the text file and read its content.
async with aiofiles.open(os.path.join(DIR, "resources", "long_text.txt"), "r") as f:
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework.workflow import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
executor,
handler,
)
"""
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(...).
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.
- 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.
Prerequisites
- No external services required.
"""
# Example 1: A custom Executor subclass
# ------------------------------------
#
# Subclassing Executor lets you define a named node with lifecycle hooks if needed.
# The work itself is implemented in an async method decorated with @handler.
#
# 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)
#
# Within a handler you typically:
# - Compute a result
# - Forward that result to downstream node(s) using ctx.send_message(result)
class UpperCase(Executor):
def __init__(self, id: str | None = None):
super().__init__(id=id)
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Convert the input to uppercase and forward it to the next node.
Note: The WorkflowContext is parameterized with the type this handler will
emit. Here WorkflowContext[str] means downstream nodes should expect str.
"""
result = text.upper()
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
# Example 2: A standalone function-based 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
# @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.
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(...)).
"""
result = text[::-1]
# Send the result with a workflow completion event.
await ctx.add_event(WorkflowCompletedEvent(result))
async def main():
"""Build and run a simple 2-step workflow using the fluent builder API."""
upper_case = UpperCase(id="upper_case_executor")
# Build the workflow using a fluent pattern:
# 1) add_edge(from_node, to_node) defines a directed edge upper_case -> reverse_text
# 2) set_start_executor(node) declares the entry point
# 3) build() finalizes and returns an immutable Workflow object
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.
events = await workflow.run("hello world")
print(events.get_completed_event())
"""
Sample Output:
WorkflowCompletedEvent(data=DLROW OLLEH)
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,87 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import AgentRunEvent, WorkflowBuilder
from azure.identity import AzureCliCredential
"""
Step 2: Agents in a Workflow non-streaming
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.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming or non streaming runs.
"""
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
chat_client = AzureChatClient(credential=AzureCliCredential())
writer_agent = chat_client.create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer",
)
reviewer_agent = chat_client.create_agent(
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
"Provide the feedback in the most concise manner possible."
),
name="reviewer",
)
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# 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
for event in events:
if isinstance(event, AgentRunEvent):
print(f"{event.executor_id}: {event.data}")
print(f"{'=' * 60}\n{events.get_completed_event()}")
"""
Sample Output:
writer: "Charge Up Your Adventure—Affordable Fun, Electrified!"
reviewer: Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
**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.
============================================================
Workflow Completed Event:
WorkflowCompletedEvent(data=Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
**Feedback:**s
- 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.)
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,128 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import ChatAgent, ChatMessage
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import Executor, WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, handler
from azure.identity import AzureCliCredential
"""
Step 3: Agents in a workflow with streaming
A Writer agent generates content,
then passes the conversation to a Reviewer agent that finalizes the result.
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.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
"""
class Writer(Executor):
"""Custom executor that owns a domain specific agent for content generation.
This class demonstrates:
- Attaching a ChatAgent to an Executor so it participates as a node in a workflow.
- Using a @handler method to accept a typed input and forward a typed output via ctx.send_message.
"""
agent: ChatAgent
def __init__(self, chat_client: AzureChatClient, id: str = "writer"):
# Create a domain specific agent using your configured AzureChatClient.
agent = chat_client.create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
# Associate this agent with the executor node. The base Executor stores it on self.agent.
super().__init__(agent=agent, id=id)
@handler
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None:
"""Generate content and forward the updated conversation.
Contract for this handler:
- message is the inbound user ChatMessage.
- ctx is a WorkflowContext that expects a list[ChatMessage] to be sent downstream.
Pattern shown here:
1) Seed the conversation with the inbound message.
2) Run the attached agent to produce assistant messages.
3) Forward the cumulative messages to the next executor with ctx.send_message.
"""
# Start the conversation with the incoming user message.
messages: list[ChatMessage] = [message]
# Run the agent and extend the conversation with the agent's messages.
response = await self.agent.run(messages)
messages.extend(response.messages)
# Forward the accumulated messages to the next executor in the workflow.
await ctx.send_message(messages)
class Reviewer(Executor):
"""Custom executor that owns a review agent and completes the workflow."""
agent: ChatAgent
def __init__(self, chat_client: AzureChatClient, id: str = "reviewer"):
# Create a domain specific agent that evaluates and refines content.
agent = chat_client.create_agent(
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer."
),
)
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.
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.
"""
response = await self.agent.run(messages)
await ctx.add_event(WorkflowCompletedEvent(response.text))
async def main():
"""Build the two node workflow and run it with streaming to observe events."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
chat_client = AzureChatClient(credential=AzureCliCredential())
# Instantiate the two agent backed executors.
writer = Writer(chat_client)
reviewer = Reviewer(chat_client)
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
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.
# Events include executor invoke and completion, as well as the terminal WorkflowCompletedEvent.
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.")
):
print(event)
"""
Sample Output:
ExecutorInvokeEvent(executor_id=writer)
ExecutorCompletedEvent(executor_id=writer)
ExecutorInvokeEvent(executor_id=reviewer)
WorkflowCompletedEvent(data=Drive the Future. Affordable Adventure, Electrified.)
ExecutorCompletedEvent(executor_id=reviewer)
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,256 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from agent_framework import AgentProtocol, ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor, # Wraps an agent so it can run inside a workflow
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorResponse, # Result returned by an AgentExecutor
RequestInfoEvent, # Event emitted when human input is requested
RequestInfoExecutor, # Special executor that collects human input out of band
RequestInfoMessage, # Base class for request payloads sent to RequestInfoExecutor
RequestResponse, # Correlates a human response with the original request
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowCompletedEvent, # Terminal event used to finish the workflow
WorkflowContext, # Per run context and event bus
handler, # Decorator to expose an Executor method as a step
)
from azure.identity import AzureCliCredential
from pydantic import BaseModel
"""
Sample: Human in the loop guessing game
An agent guesses a number, then a human guides it with higher, lower, or
correct via RequestInfoExecutor. The loop continues until the human confirms
correct, at which point the workflow
completes.
Purpose:
Show how to integrate a human step in the middle of an LLM workflow using RequestInfoExecutor and correlated
RequestResponse objects.
Demonstrate:
- Alternating turns between an AgentExecutor and a human, driven by events.
- Using Pydantic response_format to enforce structured JSON output from the agent instead of regex parsing.
- Driving the loop in application code with run_stream and send_responses_streaming.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
"""
# What RequestInfoExecutor does:
# RequestInfoExecutor is a workflow-native bridge that pauses the graph at a request for information,
# emits a RequestInfoEvent with a typed payload, and then resumes the graph only after your application
# supplies a matching RequestResponse keyed by the emitted request_id. It does not gather input by itself.
# Your application is responsible for collecting the human reply from any UI or CLI and then calling
# send_responses_streaming with a dict mapping request_id to the human's answer. The executor exists to
# standardize pause-and-resume human gating, to carry typed request payloads, and to preserve correlation.
# Request type sent to the RequestInfoExecutor for human feedback.
# Including the agent's last guess allows the UI or CLI to display context and helps
# the turn manager avoid extra state reads.
# Why subclass RequestInfoMessage:
# Subclassing RequestInfoMessage defines the exact schema of the request that the human will see.
# This gives you strong typing, forward-compatible validation, and clear correlation semantics.
# It also lets you attach contextual fields (such as the previous guess) so the UI can render a rich prompt
# without fetching extra state from elsewhere.
@dataclass
class HumanFeedbackRequest(RequestInfoMessage):
prompt: str = ""
guess: int | None = None
class GuessOutput(BaseModel):
"""Structured output from the agent. Enforced via response_format for reliable parsing."""
guess: int
class TurnManager(AgentExecutor):
"""Coordinates turns between the agent and the human.
Responsibilities:
- Kick off the first agent turn.
- After each agent reply, request human feedback with a HumanFeedbackRequest.
- After each human reply, either finish the game or prompt the agent again with feedback.
"""
def __init__(self, agent: AgentProtocol, id: str | None = None):
super().__init__(agent, id=id)
@handler
async def start(self, _: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
"""Start the game by asking the agent for an initial guess.
Contract:
- Input is a simple starter token (ignored here).
- Output is an AgentExecutorRequest that triggers the agent to produce a guess.
"""
user = ChatMessage(Role.USER, text="Start by making your first guess.")
await ctx.send_message(AgentExecutorRequest(messages=[user], should_respond=True))
@handler
async def on_agent_response(
self,
result: AgentExecutorResponse,
ctx: WorkflowContext[HumanFeedbackRequest],
) -> None:
"""Handle the agent's guess and request human guidance.
Steps:
1) Parse the agent's JSON into GuessOutput for robustness.
2) Send a HumanFeedbackRequest to the RequestInfoExecutor with a clear instruction:
- higher means the human's secret number is higher than the agent's guess.
- lower means the human's secret number is lower than the agent's guess.
- correct confirms the guess is exactly right.
- exit quits the demo.
"""
# Parse structured model output (defensive default if the agent did not reply).
text = result.agent_run_response.text or ""
last_guess = GuessOutput.model_validate_json(text).guess if text else None
# Craft a precise human prompt that defines higher and lower relative to the agent's guess.
prompt = (
f"The agent guessed: {last_guess if last_guess is not None else text}. "
"Type one of: higher (your number is higher than this guess), "
"lower (your number is lower than this guess), correct, or exit."
)
await ctx.send_message(HumanFeedbackRequest(prompt=prompt, guess=last_guess))
@handler
async def on_human_feedback(
self,
feedback: RequestResponse[HumanFeedbackRequest, str],
ctx: WorkflowContext[AgentExecutorRequest | WorkflowCompletedEvent],
) -> None:
"""Continue the game or finish based on human feedback.
The RequestResponse contains both the human's string reply and the correlated HumanFeedbackRequest,
which carries the prior guess for convenience.
"""
reply = (feedback.data or "").strip().lower()
# Prefer the correlated request's guess to avoid extra shared state reads.
last_guess = getattr(feedback.original_request, "guess", None)
if reply == "correct":
await ctx.add_event(WorkflowCompletedEvent(f"Guessed correctly: {last_guess}"))
return
# Provide feedback to the agent to try again.
# We keep the agent's output strictly JSON to ensure stable parsing on the next turn.
user_msg = ChatMessage(
Role.USER,
text=(f'Feedback: {reply}. Return ONLY a JSON object matching the schema {{"guess": <int 1..10>}}.'),
)
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
async def main() -> None:
# Create the chat agent and wrap it in an AgentExecutor.
# response_format enforces that the model produces JSON compatible with GuessOutput.
chat_client = AzureChatClient(credential=AzureCliCredential())
agent = chat_client.create_agent(
instructions=(
"You guess a number between 1 and 10. "
"If the user says 'higher' or 'lower', adjust your next guess. "
'You MUST return ONLY a JSON object exactly matching this schema: {"guess": <integer 1..10>}. '
"No explanations or additional text."
),
response_format=GuessOutput,
)
# Build a simple loop: TurnManager <-> RequestInfoExecutor.
# TurnManager runs the agent, asks the human, processes feedback, and either finishes or repeats.
turn_manager = TurnManager(agent=agent, id="turn_manager")
# Naming note:
# This variable is currently named hitl for historical reasons. The name can feel ambiguous or magical.
# Consider renaming to request_info_executor in your own code for clarity, since it directly represents
# the RequestInfoExecutor node that gathers human replies out of band.
hitl = RequestInfoExecutor(id="request_info")
top_builder = (
WorkflowBuilder()
.set_start_executor(turn_manager)
.add_edge(turn_manager, turn_manager) # TurnManager executes its own agent step
.add_edge(turn_manager, hitl) # Ask human for guidance
.add_edge(hitl, turn_manager) # Feed human guidance back to the agent turn manager
)
# Build the workflow (no checkpointing in this minimal sample).
workflow = top_builder.build()
# 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
# User guidance printing:
# If you want to instruct users up front, print a short banner before the loop.
# Example:
# print(
# "Interactive mode. When prompted, type one of: higher, lower, correct, or exit. "
# "The agent will keep guessing until you reply correct.",
# flush=True,
# )
while not completed:
# First iteration uses run_stream("start").
# Subsequent iterations use send_responses_streaming with pending_responses from the console.
stream = (
workflow.send_responses_streaming(pending_responses) if pending_responses else workflow.run_stream("start")
)
events = [event async for event in stream]
pending_responses = None
# Collect human requests and the terminal completion if present.
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):
# RequestInfoEvent for our HumanFeedbackRequest.
requests.append((event.request_id, event.data.prompt))
# Other events are ignored for brevity.
# If we have any human requests, prompt the user and prepare responses.
if requests and not completed:
responses: dict[str, str] = {}
for req_id, prompt in requests:
# Simple console prompt for the sample.
print(f"HITL> {prompt}")
# Instructional print already appears above. The input line below is the user entry point.
# If desired, you can add more guidance here, but keep it concise.
answer = input("Enter higher/lower/correct/exit: ").lower()
if answer == "exit":
print("Exiting...")
return
responses[req_id] = answer
pending_responses = responses
# Show final result.
print(completed)
"""
Sample Output:
HITL> The agent guessed: 5. 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: higher
HITL> The agent guessed: 8. 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: higher
HITL> The agent guessed: 10. 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: 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)
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -3,7 +3,12 @@
import asyncio
from enum import Enum
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
ExecutorCompletedEvent,
WorkflowBuilder,
@@ -11,11 +16,18 @@ from agent_framework.workflow import (
WorkflowContext,
handler,
)
from azure.identity import AzureCliCredential
"""
The following sample demonstrates a basic workflow with two executors
where one executor guesses a number and the other executor judges the
guess iteratively.
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.
Prerequisites:
- Azure AI/ Azure OpenAI for `AzureChatClient` agent.
- Authentication via `azure-identity` uses `AzureCliCredential()` (run `az login`).
"""
@@ -66,39 +78,68 @@ class GuessNumberExecutor(Executor):
await ctx.send_message(self._guess)
class JudgeExecutor(Executor):
"""An executor that judges the guessed number."""
class SubmitToJudgeAgent(Executor):
"""Send the numeric guess to a judge agent which replies ABOVE/BELOW/MATCHED."""
def __init__(self, target: int, id: str | None = None):
"""Initialize the executor with a target number."""
def __init__(self, judge_agent_id: str, target: int, id: str | None = None):
super().__init__(id=id)
self._judge_agent_id = judge_agent_id
self._target = target
@handler
async def judge(self, number: int, ctx: WorkflowContext[NumberSignal]) -> None:
"""Judge the guessed number."""
if number == self._target:
result = NumberSignal.MATCHED
elif number < self._target:
result = NumberSignal.ABOVE
else:
result = NumberSignal.BELOW
async def submit(self, guess: int, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
prompt = (
"You are a number judge. Given a target number and a guess, reply with exactly one token:"
" 'MATCHED' if guess == target, 'ABOVE' if the target is above the guess,"
" or 'BELOW' if the target is below.\n"
f"Target: {self._target}\nGuess: {guess}\nResponse:"
)
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True),
target_id=self._judge_agent_id,
)
await ctx.send_message(result)
class ParseJudgeResponse(Executor):
"""Parse AgentExecutorResponse into NumberSignal for the loop."""
@handler
async def parse(self, response: AgentExecutorResponse, ctx: WorkflowContext[NumberSignal]) -> None:
text = response.agent_run_response.text.strip().upper()
if "MATCHED" in text:
await ctx.send_message(NumberSignal.MATCHED)
elif "ABOVE" in text and "BELOW" not in text:
await ctx.send_message(NumberSignal.ABOVE)
else:
await ctx.send_message(NumberSignal.BELOW)
async def main():
"""Main function to run the workflow."""
# Step 1: Create the executors.
guess_number_executor = GuessNumberExecutor((1, 100))
judge_executor = JudgeExecutor(30)
# Agent judge setup
chat_client = AzureChatClient(credential=AzureCliCredential())
judge_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You strictly respond with one of: MATCHED, ABOVE, BELOW based on the given target and guess."
)
),
id="judge_agent",
)
submit_to_judge = SubmitToJudgeAgent(judge_agent_id=judge_agent.id, target=30, id="submit_judge")
parse_judge = ParseJudgeResponse(id="parse_judge")
# Step 2: Build the workflow with the defined edges.
# This time we are creating a loop in the workflow.
workflow = (
WorkflowBuilder()
.add_edge(guess_number_executor, judge_executor)
.add_edge(judge_executor, guess_number_executor)
.add_edge(guess_number_executor, submit_to_judge)
.add_edge(submit_to_judge, judge_agent)
.add_edge(judge_agent, parse_judge)
.add_edge(parse_judge, guess_number_executor)
.set_start_executor(guess_number_executor)
.build()
)
@@ -20,10 +20,10 @@ logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
"""
Magentic Workflow (multi-agent) sample.
Sample: Magentic Orchestration (multi-agent)
This sample shows how to orchestrate multiple agents using the
MagenticBuilder:
What it does:
- Orchestrates multiple agents using `MagenticBuilder` with streaming callbacks.
- ResearcherAgent (ChatAgent backed by an OpenAI chat client) for
finding information.
@@ -37,7 +37,10 @@ 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 to the console, and prints the final aggregated answer at completion.
events, and prints the final answer.
Prerequisites:
- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
"""
@@ -25,10 +25,11 @@ logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
"""
Magentic workflow with human-in-the-loop plan review and update.
Sample: Magentic Orchestration + Human Plan Review
This sample builds a Magentic workflow with two cooperating agents and enables
plan review so a human can approve or revise the plan before execution:
What it does:
- Builds a Magentic workflow with two agents and enables human plan review.
A human approves or edits the plan via `RequestInfoEvent` before execution.
- researcher: ChatAgent backed by OpenAIChatClient (web/search-capable model)
- coder: ChatAgent backed by OpenAIAssistantsClient with the Hosted Code Interpreter tool
@@ -40,8 +41,8 @@ Key behaviors demonstrated:
- Callbacks: on_agent_stream (incremental chunks), on_agent_response (final messages),
on_result (final answer), and on_exception
Prereqs: configure your OpenAI credentials in the environment so the Chat/Assistants
clients can run. You can swap clients/models as needed.
Prerequisites:
- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
"""
@@ -0,0 +1,19 @@
Subject: Action Required: Verify Your Account
Dear Valued Customer,
We have detected unusual activity on your account and need to verify your identity to ensure your security.
To maintain access to your account, please login to your account and complete the verification process.
Account Details:
- User: johndoe@contoso.com
- Last Login: 08/15/2025
- Location: Seattle, WA
- Device: Mobile
This is an automated security measure. If you believe this email was sent in error, please contact our support team immediately.
Best regards,
Security Team
Customer Service Department
@@ -0,0 +1,18 @@
Subject: Team Meeting Follow-up - Action Items
Hi Sarah,
I wanted to follow up on our team meeting this morning and share the action items we discussed:
1. Update the project timeline by Friday
2. Schedule client presentation for next week
3. Review the budget allocation for Q4
Please let me know if you have any questions or if I missed anything from our discussion.
Best regards,
Alex Johnson
Project Manager
Tech Solutions Inc.
alex.johnson@techsolutions.com
(555) 123-4567
@@ -0,0 +1,25 @@
Subject: 🎉 CONGRATULATIONS! You've WON $1,000,000 - CLAIM NOW! 🎉
Dear Valued Customer,
URGENT NOTICE: You have been selected as our GRAND PRIZE WINNER!
🏆 YOU HAVE WON $1,000,000 USD 🏆
This is NOT a joke! You are one of only 5 lucky winners selected from millions of email addresses worldwide.
To claim your prize, you MUST respond within 24 HOURS or your winnings will be forfeited!
CLICK HERE NOW: http://win-claim.com
What you need to do:
1. Reply with your full name
2. Provide your bank account details
3. Send a processing fee of $500 via wire transfer
ACT FAST! This offer expires TONIGHT at midnight!
Best regards,
Dr. Johnson Williams
International Lottery Commission
Phone: +1-555-999-1234
@@ -0,0 +1,89 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from agent_framework.workflow import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
"""
Sample: Sequential workflow with streaming.
Two custom executors run in sequence. The first converts text to uppercase,
the second reverses the text and completes the workflow. The run_stream loop prints events as they occur.
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.
Prerequisites:
- No external services required.
"""
class UpperCaseExecutor(Executor):
"""Converts an input string to uppercase and forwards it.
Concepts:
- @handler methods define invokable steps.
- WorkflowContext[str] indicates this step emits a string to the next node.
"""
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Transform the input to uppercase and send it downstream."""
result = text.upper()
# Pass the intermediate result to the next executor in the chain.
await ctx.send_message(result)
class ReverseTextExecutor(Executor):
"""Reverses the incoming string and completes the workflow.
Concepts:
- Use ctx.add_event to publish a WorkflowCompletedEvent 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."""
result = text[::-1]
await ctx.add_event(WorkflowCompletedEvent(result))
async def main():
"""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")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
# Step 2: Build the workflow graph.
# Order matters. We connect upper_case_executor -> reverse_text_executor and set the start.
workflow = (
WorkflowBuilder()
.add_edge(upper_case_executor, reverse_text_executor)
.set_start_executor(upper_case_executor)
.build()
)
# Step 3: Stream events for a single input.
# The stream will include executor invoke and completion events, plus the final WorkflowCompletedEvent.
completion_event = None
async for event in workflow.run_stream("hello world"):
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
if completion_event:
print(f"Workflow completed with result: {completion_event.data}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,85 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework.workflow import WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, 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.
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 ExecutorInvokeEvent and WorkflowCompletedEvent
for observability.
Prerequisites:
- No external services required.
"""
# Step 1: Define methods using the executor decorator.
@executor(id="upper_case_executor")
async def to_upper_case(text: str, ctx: WorkflowContext[str]) -> None:
"""Transform the input to uppercase and forward it to the next step.
Concepts:
- The @executor decorator registers this function as a workflow node.
- WorkflowContext[str] indicates that this node emits a string payload downstream.
"""
result = text.upper()
# Send the intermediate result to the next executor in the workflow graph.
await ctx.send_message(result)
@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.
Concepts:
- Terminal nodes publish a WorkflowCompletedEvent using ctx.add_event.
- No further messages are forwarded after completion.
"""
result = text[::-1]
# Emit the terminal event that carries the final output for this run.
await ctx.add_event(WorkflowCompletedEvent(result))
async def main():
"""Build a two-step sequential workflow and run it with streaming to observe events."""
# Step 2: Build the workflow with the defined edges.
# Order matters. upper_case_executor runs first, then reverse_text_executor.
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.
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}")
"""
Sample Output:
Event: ExecutorInvokeEvent(executor_id=upper_case_executor)
Event: ExecutorCompletedEvent(executor_id=upper_case_executor)
Event: ExecutorInvokeEvent(executor_id=reverse_text_executor)
Event: WorkflowCompletedEvent(data=DLROW OLLEH)
Event: ExecutorCompletedEvent(executor_id=reverse_text_executor)
Workflow completed with result: DLROW OLLEH
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,224 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
executor,
)
from azure.identity import AzureCliCredential
from pydantic import BaseModel
"""
Sample: Shared state with agents and conditional routing.
Store an email once by id, classify it with a detector agent, then either draft a reply with an assistant
agent or finish with a spam notice. Stream events as the workflow runs.
Purpose:
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.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs.
"""
EMAIL_STATE_PREFIX = "email:"
CURRENT_EMAIL_ID_KEY = "current_email_id"
class DetectionResultAgent(BaseModel):
"""Structured output returned by the spam detection agent."""
is_spam: bool
reason: str
class EmailResponse(BaseModel):
"""Structured output returned by the email assistant agent."""
response: str
@dataclass
class DetectionResult:
"""Internal detection result enriched with the shared state email_id for later lookups."""
is_spam: bool
reason: str
email_id: str
@dataclass
class Email:
"""In memory record stored in shared state to avoid re-sending large bodies on edges."""
email_id: str
email_content: str
def get_condition(expected_result: bool):
"""Create a condition predicate for DetectionResult.is_spam.
Contract:
- If the message is not a DetectionResult, allow it to pass to avoid accidental dead ends.
- Otherwise, return True only when is_spam matches expected_result.
"""
def condition(message: Any) -> bool:
if not isinstance(message, DetectionResult):
return True
return message.is_spam == expected_result
return condition
@executor(id="store_email")
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
"""Persist the raw email content in shared state and trigger spam detection.
Responsibilities:
- Generate a unique email_id (UUID) for downstream retrieval.
- Store the Email object under a namespaced key and set the current id pointer.
- Emit an AgentExecutorRequest asking the detector to respond.
"""
new_email = Email(email_id=str(uuid4()), email_content=email_text)
await ctx.set_shared_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
await ctx.set_shared_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=new_email.email_content)], should_respond=True)
)
@executor(id="to_detection_result")
async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None:
"""Parse spam detection JSON into a structured model and enrich with email_id.
Steps:
1) Validate the agent's JSON output into DetectionResultAgent.
2) Retrieve the current email_id from shared state.
3) Send a typed DetectionResult for conditional routing.
"""
parsed = DetectionResultAgent.model_validate_json(response.agent_run_response.text)
email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY)
await ctx.send_message(DetectionResult(is_spam=parsed.is_spam, reason=parsed.reason, email_id=email_id))
@executor(id="submit_to_email_assistant")
async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
"""Forward non spam email content to the drafting agent.
Guard:
- This path should only receive non spam. Raise if misrouted.
"""
if detection.is_spam:
raise RuntimeError("This executor should only handle non-spam messages.")
# Load the original content by id from shared state and forward it to the assistant.
email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True)
)
@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."""
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(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."""
if detection.is_spam:
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
else:
raise RuntimeError("This executor should only handle spam messages.")
async def main() -> None:
# Create chat client and agents. response_format enforces structured JSON from each agent.
chat_client = AzureChatClient(credential=AzureCliCredential())
spam_detection_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Always return JSON with fields is_spam (bool) and reason (string)."
),
response_format=DetectionResultAgent,
),
id="spam_detection_agent",
)
email_assistant_agent = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an email assistant that helps users draft responses to emails with professionalism. "
"Return JSON with a single field 'response' containing the drafted reply."
),
response_format=EmailResponse,
),
id="email_assistant_agent",
)
# Build the workflow graph with conditional edges.
# Flow:
# store_email -> spam_detection_agent -> to_detection_result -> branch:
# False -> submit_to_email_assistant -> email_assistant_agent -> finalize_and_send
# True -> handle_spam
workflow = (
WorkflowBuilder()
.set_start_executor(store_email)
.add_edge(store_email, spam_detection_agent)
.add_edge(spam_detection_agent, to_detection_result)
.add_edge(to_detection_result, submit_to_email_assistant, condition=get_condition(False))
.add_edge(to_detection_result, handle_spam, condition=get_condition(True))
.add_edge(submit_to_email_assistant, email_assistant_agent)
.add_edge(email_assistant_agent, finalize_and_send)
.build()
)
# Read an email from resources/spam.txt if available; otherwise use a default sample.
resources_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources", "spam.txt")
if os.path.exists(resources_path):
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
email = f.read()
else:
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(f"{event}")
"""
Sample Output:
WorkflowCompletedEvent(data=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.)
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -1,291 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from agent_framework.workflow import Case, Default, Executor, WorkflowBuilder, WorkflowContext, handler
"""
The following sample demonstrates the foundation patterns that the workflow framework supports.
These patterns include:
- Single connection
- Single connection with condition
- Fan-out and fan-in connections
- Conditional fan-out connections
- Partitioning fan-out connections
The samples here use numbers and simple arithmetic operations to demonstrate the patterns.
"""
class AddOneExecutor(Executor):
"""An executor that processes a number by adding one."""
@handler
async def add_one(self, number: int, ctx: WorkflowContext[int]) -> None:
"""Execute the task by adding one to the input number."""
result = number + 1
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
print("Adding one to the number:", number, "Result:", result)
class MultiplyByTwoExecutor(Executor):
"""An executor that processes a number by multiplying it by two."""
@handler
async def multiply_by_two(self, number: int, ctx: WorkflowContext[int]) -> None:
"""Execute the task by multiplying the input number by two."""
result = number * 2
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
print("Multiplying the number by two:", number, "Result:", result)
class DivideByTwoExecutor(Executor):
"""An executor that processes a number by dividing it by two."""
@handler
async def divide_by_two(self, number: int, ctx: WorkflowContext[float]) -> None:
"""Execute the task by dividing the input number by two."""
result = number / 2
# Send the result with a workflow completion event.
await ctx.send_message(result)
print("Dividing the number by two:", number, "Result:", result)
class AggregateResultExecutor(Executor):
"""An executor that receives results and prints them."""
@handler
async def aggregate_results(self, results: Any, ctx: WorkflowContext[None]) -> None:
"""Print whatever results are received."""
print("Aggregating results:", results)
async def single_edge():
"""A sample to demonstrate a single directed connection between two executors.
Three executors are connected in a sequence: AddOneExecutor -> AddOneExecutor -> AggregateResultExecutor.
Expected output:
Adding one to the number: 1 Result: 2
Adding one to the number: 2 Result: 3
Aggregating results: 3
"""
add_one_executor_a = AddOneExecutor()
add_one_executor_b = AddOneExecutor()
aggregate_result_executor = AggregateResultExecutor()
workflow = (
WorkflowBuilder()
.add_edge(add_one_executor_a, add_one_executor_b)
.add_edge(add_one_executor_b, aggregate_result_executor)
.set_start_executor(add_one_executor_a)
.build()
)
await workflow.run(1)
async def single_edge_with_condition():
"""A sample to demonstrate a single directed connection with a condition.
Three executors are connected: AddOneExecutor -> AddOneExecutor, AggregateResultExecutor.
The AddOneExecutor will loop back to itself until the number reaches 10, then it will start
sending the result to AggregateResultExecutor when the number is greater than 8. The workflow
stops when the number reaches 11.
Expected output:
Adding one to the number: 1 Result: 2
Adding one to the number: 2 Result: 3
Adding one to the number: 3 Result: 4
Adding one to the number: 4 Result: 5
Adding one to the number: 5 Result: 6
Adding one to the number: 6 Result: 7
Adding one to the number: 7 Result: 8
Adding one to the number: 8 Result: 9
Adding one to the number: 9 Result: 10
Aggregating results: 9
Adding one to the number: 10 Result: 11
Aggregating results: 10
Aggregating results: 11
"""
add_one_executor_a = AddOneExecutor()
aggregate_result_executor = AggregateResultExecutor()
workflow = (
WorkflowBuilder()
.add_edge(add_one_executor_a, add_one_executor_a, condition=lambda x: x < 11)
.add_edge(add_one_executor_a, aggregate_result_executor, condition=lambda x: x > 8)
.set_start_executor(add_one_executor_a)
.build()
)
await workflow.run(1)
async def fan_out_fan_in_edge_group():
"""A sample to demonstrate a fan-out and fan-in connection between executors.
Four executors are connected in a fan-out and fan-in pattern:
AddOneExecutor -> MultiplyByTwoExecutor, DivideByTwoExecutor -> AggregateResultExecutor.
The AddOneExecutor sends its output to both MultiplyByTwoExecutor and DivideByTwoExecutor,
and both of these executors send their results to AggregateResultExecutor.
The target of the fan-in connection will wait for all the results from the sources before proceeding.
Expected output:
Adding one to the number: 1 Result: 2
Multiplying the number by two: 2 Result: 4
Dividing the number by two: 2 Result: 1.0
Aggregating results: [4, 1.0]
"""
add_one_executor = AddOneExecutor()
multiply_by_two_executor = MultiplyByTwoExecutor()
divide_by_two_executor = DivideByTwoExecutor()
aggregate_result_executor = AggregateResultExecutor()
workflow = (
WorkflowBuilder()
.add_fan_out_edges(add_one_executor, [multiply_by_two_executor, divide_by_two_executor])
.add_fan_in_edges([multiply_by_two_executor, divide_by_two_executor], aggregate_result_executor)
.set_start_executor(add_one_executor)
.build()
)
await workflow.run(1)
async def switch_case_edge_group():
"""A sample to demonstrate a switch-case connection.
Four executors are connected in a switch-case pattern:
AddOneExecutor -> AddOneExecutor, MultiplyByTwoExecutor, DivideByTwoExecutor -> AggregateResultExecutor.
The message from AddOneExecutor will be evaluated against the conditions one by one, and the first condition
that evaluates to True will determine the target executors. If no conditions match, the message will be sent
to the last targets.
This pattern resembles a switch-case statement with a default case where the first matching case is executed.
Expected output:
Adding one to the number: 1 Result: 2
Adding one to the number: 2 Result: 3
Adding one to the number: 3 Result: 4
Adding one to the number: 4 Result: 5
Adding one to the number: 5 Result: 6
Adding one to the number: 6 Result: 7
Adding one to the number: 7 Result: 8
Adding one to the number: 8 Result: 9
Adding one to the number: 9 Result: 10
Adding one to the number: 10 Result: 11
Multiplying the number by two: 11 Result: 22
"""
add_one_executor = AddOneExecutor()
multiply_by_two_executor = MultiplyByTwoExecutor()
divide_by_two_executor = DivideByTwoExecutor()
aggregate_result_executor = AggregateResultExecutor()
workflow = (
WorkflowBuilder()
.set_start_executor(add_one_executor)
.add_switch_case_edge_group(
source=add_one_executor,
cases=[
# Loop back to the add_one_executor if the number is less than 11
Case(condition=lambda x: x < 11, target=add_one_executor),
# multiply_by_two_executor when the number is larger than or equal to 11 and even.
Case(condition=lambda x: x % 2 == 0, target=multiply_by_two_executor),
# Otherwise, send to the divide_by_two_executor.
Default(target=divide_by_two_executor),
],
)
.add_fan_in_edges([multiply_by_two_executor, divide_by_two_executor], aggregate_result_executor)
.build()
)
await workflow.run(1)
async def multi_selection_edge_group():
"""A sample to demonstrate a multi-selection edge connection.
Four executors are connected in a multi-selection edge pattern:
AddOneExecutor -> AddOneExecutor, MultiplyByTwoExecutor, DivideByTwoExecutor -> AggregateResultExecutor.
The AddOneExecutor sends its output to one or more executors based on the partitioning function.
Expected output:
Adding one to the number: 1 Result: 2
Adding one to the number: 2 Result: 3
Adding one to the number: 3 Result: 4
Adding one to the number: 4 Result: 5
Adding one to the number: 5 Result: 6
Adding one to the number: 6 Result: 7
Adding one to the number: 7 Result: 8
Adding one to the number: 8 Result: 9
Adding one to the number: 9 Result: 10
Adding one to the number: 10 Result: 11
Adding one to the number: 11 Result: 12
Adding one to the number: 12 Result: 13
Dividing the number by two: 12 Result: 6.0
Multiplying the number by two: 13 Result: 26
Aggregating results: [26, 6.0]
"""
add_one_executor = AddOneExecutor()
multiply_by_two_executor = MultiplyByTwoExecutor()
divide_by_two_executor = DivideByTwoExecutor()
aggregate_result_executor = AggregateResultExecutor()
def selection_func(number: int, target_ids: list[str]) -> list[str]:
"""Selection function to determine which executor to send the number to."""
if number < 12:
# Loop back to the add_one_executor if the number is less than 12
return [add_one_executor.id]
if number % 2 == 0:
# Send it to the add_one_executor to add one more time and the
# divide_by_two_executor to divide the result by two.
return [add_one_executor.id, divide_by_two_executor.id]
# Otherwise, send it to the multiply_by_two_executor to multiply the result by two.
return [multiply_by_two_executor.id]
workflow = (
WorkflowBuilder()
.set_start_executor(add_one_executor)
.add_multi_selection_edge_group(
add_one_executor,
[add_one_executor, multiply_by_two_executor, divide_by_two_executor],
selection_func=selection_func,
)
.add_fan_in_edges([multiply_by_two_executor, divide_by_two_executor], aggregate_result_executor)
.build()
)
await workflow.run(1)
async def main():
"""Main function to run the workflows."""
print("**Running single connection workflow**")
await single_edge()
print("**Running single connection with condition workflow**")
await single_edge_with_condition()
print("**Running fan-out and fan-in connection workflow**")
await fan_out_fan_in_edge_group()
print("**Running conditional fan-out connection workflow**")
await switch_case_edge_group()
print("**Running multi-selection edge group workflow**")
await multi_selection_edge_group()
if __name__ == "__main__":
asyncio.run(main())
@@ -1,72 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from agent_framework.workflow import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
"""
The following sample demonstrates a basic workflow with two executors
that process a string in sequence. The first executor converts the
input string to uppercase, and the second executor reverses the string.
"""
class UpperCaseExecutor(Executor):
"""An executor that converts text to uppercase."""
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Execute the task by converting the input string to uppercase."""
result = text.upper()
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
class ReverseTextExecutor(Executor):
"""An executor that reverses text."""
@handler
async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None:
"""Execute the task by reversing the input string."""
result = text[::-1]
# Send the result with a workflow completion event.
await ctx.add_event(WorkflowCompletedEvent(result))
async def main():
"""Main function to run the workflow."""
# Step 1: Create the executors.
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
# Step 2: Build the workflow with the defined edges.
workflow = (
WorkflowBuilder()
.add_edge(upper_case_executor, reverse_text_executor)
.set_start_executor(upper_case_executor)
.build()
)
# Step 3: Run the workflow with an initial message.
completion_event = None
async for event in workflow.run_stream("hello world"):
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
# The WorkflowCompletedEvent contains the final result.
completion_event = event
if completion_event:
print(f"Workflow completed with result: {completion_event.data}")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,64 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework.workflow import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
"""
The following sample demonstrates a basic workflow with two executors
that process a string in sequence. The first executor converts the
input string to uppercase, and the second executor reverses the string.
"""
class UpperCaseExecutor(Executor):
"""An executor that converts text to uppercase."""
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Execute the task by converting the input string to uppercase."""
result = text.upper()
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
class ReverseTextExecutor(Executor):
"""An executor that reverses text."""
@handler
async def reverse_text(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Execute the task by reversing the input string."""
result = text[::-1]
# Send the result with a workflow completion event.
await ctx.add_event(WorkflowCompletedEvent(result))
async def main():
"""Main function to run the workflow."""
# Step 1: Create the executors.
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
# Step 2: Build the workflow with the defined edges.
workflow = (
WorkflowBuilder()
.add_edge(upper_case_executor, reverse_text_executor)
.set_start_executor(upper_case_executor)
.build()
)
# Step 3: Run the workflow with an initial message.
events = await workflow.run("hello world")
print(events.get_completed_event())
if __name__ == "__main__":
asyncio.run(main())
@@ -1,118 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from agent_framework.workflow import (
Case,
Default,
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
"""
The following sample demonstrates a basic workflow with two executors
that detect spam messages and respond accordingly. The first executor
checks if the input string is spam, and depending on the result, the
workflow takes different paths.
"""
@dataclass
class SpamDetectorResponse:
"""A data class to hold the email message content."""
email: str
is_spam: bool = False
class SpamDetector(Executor):
"""An executor that determines if a message is spam."""
def __init__(self, spam_keywords: list[str], id: str | None = None):
"""Initialize the executor with spam keywords."""
super().__init__(id=id)
self._spam_keywords = spam_keywords
@handler
async def handle_email(self, email: str, ctx: WorkflowContext[SpamDetectorResponse]) -> None:
"""Determine if the input string is spam."""
result = any(keyword in email.lower() for keyword in self._spam_keywords)
await ctx.send_message(SpamDetectorResponse(email=email, is_spam=result))
class SendResponse(Executor):
"""An executor that responds to a message based on spam detection."""
@handler
async def handle_detector_response(
self,
spam_detector_response: SpamDetectorResponse,
ctx: WorkflowContext[None],
) -> None:
"""Respond with a message based on whether the input is spam."""
if spam_detector_response.is_spam:
raise RuntimeError("Input is spam, cannot respond.")
# Simulate processing delay
print(f"Responding to message: {spam_detector_response.email}")
await asyncio.sleep(1)
await ctx.add_event(WorkflowCompletedEvent("Message processed successfully."))
class RemoveSpam(Executor):
"""An executor that removes spam messages."""
@handler
async def handle_detector_response(
self,
spam_detector_response: SpamDetectorResponse,
ctx: WorkflowContext[None],
) -> None:
"""Remove the spam message."""
if spam_detector_response.is_spam is False:
raise RuntimeError("Input is not spam, cannot remove.")
# Simulate processing delay
print(f"Removing spam message: {spam_detector_response.email}")
await asyncio.sleep(1)
await ctx.add_event(WorkflowCompletedEvent("Spam message removed."))
async def main():
"""Main function to run the workflow."""
# Keyword based spam detection
spam_keywords = ["spam", "advertisement", "offer"]
# Step 1: Create the executors.
spam_detector = SpamDetector(spam_keywords, id="spam_detector")
send_response = SendResponse(id="send_response")
remove_spam = RemoveSpam(id="remove_spam")
# Step 2: Build the workflow with the defined edges with conditions.
workflow = (
WorkflowBuilder()
.set_start_executor(spam_detector)
.add_switch_case_edge_group(
spam_detector,
[
Case(condition=lambda x: x.is_spam, target=remove_spam),
Default(target=send_response),
],
)
.build()
)
# Step 3: Run the workflow with an input message.
async for event in workflow.run_stream("This is a spam."):
print(f"Event: {event}")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,149 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunEvent,
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
from azure.identity import AzureCliCredential
"""
The following sample demonstrates a basic workflow that simulates
a round-robin group chat.
"""
class RoundRobinGroupChatManager(Executor):
"""An executor that manages a round-robin group chat."""
def __init__(self, members: list[str], max_round: int, id: str | None = None):
"""Initialize the executor with a unique identifier."""
super().__init__(id)
self._members = members
self._max_round = max_round
self._current_round = 0
@handler
async def start(self, task: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
"""Execute the task by sending messages to the next executor in the round-robin sequence."""
initial_message = ChatMessage(Role.USER, text=task)
# Send the initial message to the members
await asyncio.gather(*[
ctx.send_message(
AgentExecutorRequest(messages=[initial_message], should_respond=False),
target_id=member_id,
)
for member_id in self._members
])
# Invoke the first member to start the round-robin chat
await ctx.send_message(
AgentExecutorRequest(messages=[], should_respond=True),
target_id=self._get_next_member(),
)
@handler
async def handle_agent_response(
self, response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest]
) -> None:
"""Execute the task by sending messages to the next executor in the round-robin sequence."""
# Send the response to the other members
await asyncio.gather(*[
ctx.send_message(
AgentExecutorRequest(messages=response.agent_run_response.messages, should_respond=False),
target_id=member_id,
)
for member_id in self._members
if member_id != response.executor_id
])
# Check for termination condition
if self._should_terminate():
await ctx.add_event(WorkflowCompletedEvent(data=response))
return
# Request the next member to respond
selection = self._get_next_member()
await ctx.send_message(AgentExecutorRequest(messages=[], should_respond=True), target_id=selection)
def _should_terminate(self) -> bool:
"""Determine if the group chat should terminate based on the current round."""
return self._current_round >= self._max_round
def _get_next_member(self) -> str:
"""Get the next member in the round-robin sequence."""
next_member = self._members[self._current_round % len(self._members)]
self._current_round += 1
return next_member
async def main():
"""Main function to run the group chat workflow."""
# Step 1: Create the executors.
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
chat_client = AzureChatClient(credential=AzureCliCredential())
writer = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
),
id="writer",
)
reviewer = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer."
),
),
id="reviewer",
)
group_chat_manager = RoundRobinGroupChatManager(
members=[writer.id, reviewer.id],
# max_rounds is odd, so that the writer gets the last round
max_round=5,
id="group_chat_manager",
)
# Step 2: Build the workflow with the defined edges.
workflow = (
WorkflowBuilder()
.set_start_executor(group_chat_manager)
.add_fan_out_edges(group_chat_manager, [writer, reviewer])
.add_edge(writer, group_chat_manager)
.add_edge(reviewer, group_chat_manager)
.build()
)
# Step 3: Run the workflow with an initial message.
completion_event = None
async for event in workflow.run_stream(
"Create a slogan for a new electric SUV that is affordable and fun to drive."
):
if isinstance(event, AgentRunEvent):
print(f"{event}")
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
if completion_event:
print(f"Completion Event: {completion_event}")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,230 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
RequestInfoEvent,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
from azure.identity import AzureCliCredential
"""
The following sample demonstrates a basic workflow that simulates
a round-robin group chat with a Human-in-the-Loop (HIL) executor.
"""
class CriticGroupChatManager(Executor):
"""An executor that manages a round-robin group chat."""
def __init__(self, members: list[str], id: str | None = None):
"""Initialize the executor with a unique identifier."""
super().__init__(id)
self._members = members
self._current_round = 0
self._chat_history: list[ChatMessage] = []
@handler
async def start(self, task: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
"""Handler that starts the group chat with an initial task."""
initial_message = ChatMessage(Role.USER, text=task)
# Send the initial message to the members
await asyncio.gather(*[
ctx.send_message(
AgentExecutorRequest(messages=[initial_message], should_respond=False),
target_id=member_id,
)
for member_id in self._members
])
# Invoke the first member to start the round-robin chat
await ctx.send_message(
AgentExecutorRequest(messages=[], should_respond=True),
target_id=self._get_next_member(),
)
# Update the cache with the initial message
self._chat_history.append(initial_message)
@handler
async def handle_agent_response(
self,
response: AgentExecutorResponse,
ctx: WorkflowContext[RequestInfoMessage | AgentExecutorRequest],
) -> None:
"""Handler that processes the response from the agent."""
# Update the chat history with the response
self._chat_history.extend(response.agent_run_response.messages)
# Send the response to the other members
await asyncio.gather(*[
ctx.send_message(
AgentExecutorRequest(messages=response.agent_run_response.messages, should_respond=False),
target_id=member_id,
)
for member_id in self._members
if member_id != response.executor_id
])
# Check if we need to request additional information
if self._should_request_info():
await ctx.send_message(RequestInfoMessage())
return
# Check for termination condition
if self._should_terminate():
await ctx.add_event(WorkflowCompletedEvent(data=response))
return
# Request the next member to respond
selection = self._get_next_member()
await ctx.send_message(AgentExecutorRequest(messages=[], should_respond=True), target_id=selection)
@handler
async def handle_request_response(
self,
response: RequestResponse[RequestInfoMessage, list[ChatMessage]],
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
"""Handler that processes the response from the RequestInfoExecutor."""
messages: list[ChatMessage] = response.data or []
# Update the chat history with the response
self._chat_history.extend(messages)
# Send the response to the other members
await asyncio.gather(*[
ctx.send_message(
AgentExecutorRequest(messages=messages, should_respond=False),
target_id=member_id,
)
for member_id in self._members
])
# Check for termination condition
if self._should_terminate():
await ctx.add_event(WorkflowCompletedEvent(data=response))
return
# Request the next member to respond
selection = self._get_next_member()
await ctx.send_message(AgentExecutorRequest(messages=[], should_respond=True), target_id=selection)
def _should_terminate(self) -> bool:
"""Determine if the group chat should terminate based on the last message."""
if len(self._chat_history) == 0:
return False
last_message = self._chat_history[-1]
return bool(last_message.role == Role.USER and "approve" in last_message.text.lower())
def _should_request_info(self) -> bool:
"""Determine if the group chat should request HIL based on the last message."""
if len(self._chat_history) == 0:
return True
last_message = self._chat_history[-1]
return last_message.role == Role.ASSISTANT
def _get_next_member(self) -> str:
"""Get the next member in the round-robin sequence."""
next_member = self._members[self._current_round % len(self._members)]
self._current_round += 1
return next_member
async def main():
"""Main function to run the group chat workflow."""
# Step 1: Create the executors.
# For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred
# authentication option.
chat_client = AzureChatClient(credential=AzureCliCredential())
writer = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="Writer",
id="Writer",
),
)
reviewer = AgentExecutor(
chat_client.create_agent(
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer. "
"You do not address user requests. Only provide feedback to the writer."
),
name="Reviewer",
id="Reviewer",
),
)
group_chat_manager = CriticGroupChatManager(members=[writer.id, reviewer.id], id="GroupChatManager")
request_info_executor = RequestInfoExecutor()
# Step 2: Build the workflow with the defined edges.
workflow = (
WorkflowBuilder()
.set_start_executor(group_chat_manager)
.add_edge(group_chat_manager, request_info_executor)
.add_edge(request_info_executor, group_chat_manager)
.add_fan_out_edges(group_chat_manager, [writer, reviewer])
.add_edge(writer, group_chat_manager)
.add_edge(reviewer, group_chat_manager)
.build()
)
# Step 3: Run the workflow with an initial message.
# Here we are capturing the RequestInfoEvent event and allowing the user to provide input.
# Once the user provides input, we will provide it back to the workflow to continue the execution.
completion_event: WorkflowCompletedEvent | None = None
request_info_event: RequestInfoEvent | None = None
user_input = ""
while True:
# Depending on whether we have a RequestInfoEvent event, we either
# run the workflow normally or send the message to the HIL executor.
if not request_info_event:
response_stream = workflow.run_stream(
"Create a slogan for a new electric SUV that is affordable and fun to drive."
)
else:
response_stream = workflow.send_responses_streaming({
request_info_event.request_id: [ChatMessage(Role.USER, text=user_input)]
})
request_info_event = None
async for event in response_stream:
print(event)
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
elif isinstance(event, RequestInfoEvent):
request_info_event = event
# Prompt for user input if we are waiting for human intervention
if request_info_event:
user_input = input("Human feedback required. Please provide your input (type 'approve' to end): ")
elif completion_event:
break
print(f"Completion Event: {completion_event}")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,216 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from pathlib import Path
from typing import Any
from agent_framework.workflow import (
Executor,
FileCheckpointStorage,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
"""
Demonstrates workflow checkpointing, shared state, and resumption at superstep boundaries.
Flow:
1) UpperCaseExecutor: "hello world" -> "HELLO WORLD" (writes shared_state: original_input, upper_output)
2) ReverseTextExecutor: "HELLO WORLD" -> "DLROW OLLEH"
3) LowerCaseExecutor: "DLROW OLLEH" -> "dlrow olleh" (reads shared_state, emits WorkflowCompletedEvent)
Initial run checkpoints:
- after_initial_execution: messages from upper_case_executor
- superstep_1: messages from reverse_text_executor
- superstep_2: no messages (final events only)
Resume:
- Resume from the checkpoint containing "DLROW OLLEH" (superstep_1); only LowerCaseExecutor runs.
- Iteration continues from the checkpoint; one checkpoint is created after the resumed superstep.
"""
# Define the temporary directory for storing checkpoints
DIR = os.path.dirname(__file__)
TEMP_DIR = os.path.join(DIR, "tmp", "checkpoints")
os.makedirs(TEMP_DIR, exist_ok=True)
class UpperCaseExecutor(Executor):
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
result = text.upper()
print(f"UpperCaseExecutor: '{text}' -> '{result}'")
# Persist executor state into checkpointable context
prev = await ctx.get_state() or {}
count = int(prev.get("count", 0)) + 1
await ctx.set_state({
"count": count,
"last_input": text,
"last_output": result,
})
# Write to shared_state so downstream executors (and checkpoints) can see it
await ctx.set_shared_state("original_input", text)
await ctx.set_shared_state("upper_output", result)
await ctx.send_message(result)
class LowerCaseExecutor(Executor):
@handler
async def to_lower_case(self, text: str, ctx: WorkflowContext[Any]) -> None:
result = text.lower()
print(f"LowerCaseExecutor: '{text}' -> '{result}'")
# Read from shared_state written by UpperCaseExecutor
orig = await ctx.get_shared_state("original_input")
upper = await ctx.get_shared_state("upper_output")
print(f"LowerCaseExecutor (shared_state): original_input='{orig}', upper_output='{upper}'")
# Persist executor state into checkpointable context
prev = await ctx.get_state() or {}
count = int(prev.get("count", 0)) + 1
await ctx.set_state({
"count": count,
"last_input": text,
"last_output": result,
"final": True,
})
await ctx.add_event(WorkflowCompletedEvent(result))
class ReverseTextExecutor(Executor):
def __init__(self, id: str):
"""Initialize the executor with an ID."""
super().__init__(id=id)
@handler
async def reverse_text(self, text: str, ctx: WorkflowContext[str]) -> None:
result = text[::-1]
print(f"ReverseTextExecutor: '{text}' -> '{result}'")
# Persist executor state into checkpointable context
prev = await ctx.get_state() or {}
count = int(prev.get("count", 0)) + 1
await ctx.set_state({
"count": count,
"last_input": text,
"last_output": result,
})
await ctx.send_message(result)
async def find_checkpoint_with_message(
checkpoint_storage: FileCheckpointStorage, workflow_id: str, needle: str
) -> str | None:
"""Find the checkpoint that contains a message data value exactly equal to 'needle'."""
checkpoints = await checkpoint_storage.list_checkpoints(workflow_id=workflow_id)
# Sort by timestamp ascending so earlier checkpoints appear first
checkpoints.sort(key=lambda cp: cp.timestamp)
for checkpoint in checkpoints:
for executor_messages in checkpoint.messages.values():
for message in executor_messages:
if message.get("data") == needle:
return checkpoint.checkpoint_id
return None
async def main():
# Clear existing checkpoints in this sample directory
checkpoint_dir = Path(TEMP_DIR)
for file in checkpoint_dir.glob("*.json"):
file.unlink()
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
lower_case_executor = LowerCaseExecutor(id="lower_case_executor")
checkpoint_storage = FileCheckpointStorage(storage_path=TEMP_DIR)
workflow = (
WorkflowBuilder(max_iterations=5)
.add_edge(upper_case_executor, reverse_text_executor)
.add_edge(reverse_text_executor, lower_case_executor)
.set_start_executor(upper_case_executor)
.with_checkpointing(checkpoint_storage=checkpoint_storage)
.build()
)
print("Running workflow with initial message...")
async for event in workflow.run_stream(message="hello world"):
print(f"Event: {event}")
# Inspect checkpoints
all_checkpoints = await checkpoint_storage.list_checkpoints()
if not all_checkpoints:
print("No checkpoints found!")
return
# All checkpoints from this run share one workflow_id
workflow_id = all_checkpoints[0].workflow_id
# Dump a quick summary including shared_state keys of interest
print("\nCheckpoint summary:")
for cp in sorted(all_checkpoints, key=lambda c: c.timestamp):
msg_count = sum(len(v) for v in cp.messages.values())
state_keys = sorted(list(cp.executor_states.keys())) if hasattr(cp, "executor_states") else []
orig = cp.shared_state.get("original_input") if hasattr(cp, "shared_state") else None
upper = cp.shared_state.get("upper_output") if hasattr(cp, "shared_state") else None
print(
f"- {cp.checkpoint_id} | "
f"iter={cp.iteration_count} | messages={msg_count} | states={state_keys} | "
f"shared_state: original_input='{orig}', upper_output='{upper}'"
)
# Find the checkpoint with DLROW OLLEH
# This will have us resume at the third (last) executor (node)
checkpoint_id = await find_checkpoint_with_message(checkpoint_storage, workflow_id, "DLROW OLLEH")
if not checkpoint_id:
print("Could not find checkpoint with 'DLROW OLLEH'!")
return
# The previous workflow can also be used.
# Showing that the workflow can run from a previous checkpoint,
# when checkpointing is not enabled for the particular instance.
new_workflow = (
WorkflowBuilder(max_iterations=5)
.add_edge(upper_case_executor, reverse_text_executor)
.add_edge(reverse_text_executor, lower_case_executor)
.set_start_executor(upper_case_executor)
.build()
)
print(f"\nResuming from checkpoint: {checkpoint_id}")
async for event in new_workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage=checkpoint_storage):
print(f"Resumed Event: {event}")
"""
Sample Output:
Running workflow with initial message...
UpperCaseExecutor: 'hello world' -> 'HELLO WORLD'
Event: ExecutorInvokeEvent(executor_id=upper_case_executor)
Event: ExecutorCompletedEvent(executor_id=upper_case_executor)
ReverseTextExecutor: 'HELLO WORLD' -> 'DLROW OLLEH'
Event: ExecutorInvokeEvent(executor_id=reverse_text_executor)
Event: ExecutorCompletedEvent(executor_id=reverse_text_executor)
LowerCaseExecutor: 'DLROW OLLEH' -> 'dlrow olleh'
LowerCaseExecutor (shared_state): original_input='hello world', upper_output='HELLO WORLD'
Event: ExecutorInvokeEvent(executor_id=lower_case_executor)
Event: WorkflowCompletedEvent(data=dlrow olleh)
Event: ExecutorCompletedEvent(executor_id=lower_case_executor)
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'
- a78c345a-e5d9-45ba-82c0-cb725452d91b | label='superstep_1' | iter=1 | messages=1 | states=['reverse_text_executor', 'upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
- 637c1dbd-a525-4404-9583-da03980537a2 | label='superstep_2' | iter=2 | messages=0 | states=['lower_case_executor', 'reverse_text_executor', 'upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
Resuming from checkpoint: a78c345a-e5d9-45ba-82c0-cb725452d91b
LowerCaseExecutor: 'DLROW OLLEH' -> 'dlrow olleh'
LowerCaseExecutor (shared_state): original_input='hello world', upper_output='HELLO WORLD'
Resumed Event: ExecutorInvokeEvent(executor_id=lower_case_executor)
Resumed Event: WorkflowCompletedEvent(data=dlrow olleh)
Resumed Event: ExecutorCompletedEvent(executor_id=lower_case_executor)
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -1,248 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from uuid import uuid4
from agent_framework import AgentRunResponseUpdate, ChatClientProtocol, ChatMessage, Contents, Role
from agent_framework.openai import OpenAIChatClient
from agent_framework.workflow import AgentRunUpdateEvent, Executor, WorkflowBuilder, WorkflowContext, handler
from pydantic import BaseModel
"""
The following sample demonstrates how to wrap a workflow as an agent using WorkflowAgent.
This sample shows how to:
1. Create a workflow with a reflection pattern (Worker + Reviewer executors)
2. Wrap the workflow as an agent using the .as_agent() method
3. Stream responses from the workflow agent like a regular agent
4. Implement a review-retry mechanism where responses are iteratively improved
The example implements a quality-controlled AI assistant where:
- Worker executor generates responses to user queries
- Reviewer executor evaluates the responses and provides feedback
- If not approved, the Worker incorporates feedback and regenerates the response
- The cycle continues until the response is approved
- Only approved responses are emitted to the external consumer
Key concepts demonstrated:
- WorkflowAgent: Wraps a workflow to make it behave as an agent
- Bidirectional workflow with cycles (Worker ↔ Reviewer)
- AgentRunUpdateEvent: How workflows communicate with external consumers
- Structured output parsing for review feedback
- State management with pending requests tracking
"""
@dataclass
class ReviewRequest:
request_id: str
user_messages: list[ChatMessage]
agent_messages: list[ChatMessage]
@dataclass
class ReviewResponse:
request_id: str
feedback: str
approved: bool
class Reviewer(Executor):
"""An executor that reviews messages and provides feedback."""
def __init__(self, chat_client: ChatClientProtocol) -> None:
super().__init__()
self._chat_client = chat_client
@handler
async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse]) -> None:
print(f"🔍 Reviewer: Evaluating response for request {request.request_id[:8]}...")
# Use the chat client to review the message and use structured output.
# NOTE: this can be modified to use an evaluation framework.
class _Response(BaseModel):
feedback: str
approved: bool
# Define the system prompt.
messages = [
ChatMessage(
role=Role.SYSTEM,
text="You are a reviewer for an AI agent, please provide feedback on the "
"following exchange between a user and the AI agent, "
"and indicate if the agent's responses are approved or not.\n"
"Use the following criteria for your evaluation:\n"
"- Relevance: Does the response address the user's query?\n"
"- Accuracy: Is the information provided correct?\n"
"- Clarity: Is the response easy to understand?\n"
"- Completeness: Does the response cover all aspects of the query?\n"
"Be critical in your evaluation and provide constructive feedback.\n"
"Do not approve until all criteria are met.",
)
]
# Add user and agent messages to the chat history.
messages.extend(request.user_messages)
# Add agent messages to the chat history.
messages.extend(request.agent_messages)
# Add add one more instruction for the assistant to follow.
messages.append(
ChatMessage(role=Role.USER, text="Please provide a review of the agent's responses to the user.")
)
print("🔍 Reviewer: Sending review request to LLM...")
# Get the response from the chat client.
response = await self._chat_client.get_response(messages=messages, response_format=_Response)
# Parse the response.
parsed = _Response.model_validate_json(response.messages[-1].text)
print(f"🔍 Reviewer: Review complete - Approved: {parsed.approved}")
print(f"🔍 Reviewer: Feedback: {parsed.feedback}")
# Send the review response.
await ctx.send_message(
ReviewResponse(request_id=request.request_id, feedback=parsed.feedback, approved=parsed.approved)
)
class Worker(Executor):
"""An executor that performs tasks for the user."""
def __init__(self, chat_client: ChatClientProtocol) -> None:
super().__init__()
self._chat_client = chat_client
self._pending_requests: dict[str, tuple[ReviewRequest, list[ChatMessage]]] = {}
@handler
async def handle_user_messages(self, user_messages: list[ChatMessage], ctx: WorkflowContext[ReviewRequest]) -> None:
print("🔧 Worker: Received user messages, generating response...")
# Handle user messages and prepare a review request for the reviewer.
# Define the system prompt.
messages = [ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant.")]
# Add user messages.
messages.extend(user_messages)
print("🔧 Worker: Calling LLM to generate response...")
# Get the response from the chat client.
response = await self._chat_client.get_response(messages=messages)
print(f"🔧 Worker: Response generated: {response.messages[-1].text}")
# Add agent messages.
messages.extend(response.messages)
# Create the review request.
request = ReviewRequest(request_id=str(uuid4()), user_messages=user_messages, agent_messages=response.messages)
print(f"🔧 Worker: Generated response, sending to reviewer (ID: {request.request_id[:8]})")
# Send the review request.
await ctx.send_message(request)
# Add to pending requests.
self._pending_requests[request.request_id] = (request, messages)
@handler
async def handle_review_response(self, review: ReviewResponse, ctx: WorkflowContext[ReviewRequest]) -> None:
print(f"🔧 Worker: Received review for request {review.request_id[:8]} - Approved: {review.approved}")
# Handle the review response. Depending on the approval status,
# either emit the approved response as AgentRunUpdateEvent, or
# retry given the feedback.
if review.request_id not in self._pending_requests:
raise ValueError(f"Received review response for unknown request ID: {review.request_id}")
# Remove the request from pending requests.
request, messages = self._pending_requests.pop(review.request_id)
if review.approved:
print("✅ Worker: Response approved! Emitting to external consumer...")
# If approved, emit the agent run response update to the workflow's
# external consumer.
contents: list[Contents] = []
for message in request.agent_messages:
contents.extend(message.contents)
# Emitting an AgentRunUpdateEvent in a workflow wrapped by a WorkflowAgent
# will send the AgentRunResponseUpdate to the WorkflowAgent's
# event stream.
await ctx.add_event(
AgentRunUpdateEvent(self.id, data=AgentRunResponseUpdate(contents=contents, role=Role.ASSISTANT))
)
return
print(f"❌ Worker: Response not approved. Feedback: {review.feedback}")
print("🔧 Worker: Incorporating feedback and regenerating response...")
# Construct new messages with feedback.
messages.append(ChatMessage(role=Role.SYSTEM, text=review.feedback))
# Add additional instruction to address the feedback.
messages.append(
ChatMessage(
role=Role.SYSTEM,
text="Please incorporate the feedback above, and provide a response to user's next message.",
)
)
messages.extend(request.user_messages)
# Get the new response from the chat client.
response = await self._chat_client.get_response(messages=messages)
print(f"🔧 Worker: New response generated after feedback: {response.messages[-1].text}")
# Process the response.
messages.extend(response.messages)
print(f"🔧 Worker: Generated improved response, sending for re-review (ID: {review.request_id[:8]})")
# Send an updated review request.
new_request = ReviewRequest(
request_id=review.request_id, user_messages=request.user_messages, agent_messages=response.messages
)
await ctx.send_message(new_request)
# Add to pending requests.
self._pending_requests[new_request.request_id] = (new_request, messages)
async def main() -> None:
print("🚀 Starting Workflow Agent Demo")
print("=" * 50)
# Create executors.
print("📝 Creating chat client and executors...")
mini_chat_client = OpenAIChatClient(ai_model_id="gpt-4.1-nano")
chat_client = OpenAIChatClient(ai_model_id="gpt-4.1")
reviewer = Reviewer(chat_client=chat_client)
worker = Worker(chat_client=mini_chat_client)
print("🏗️ Building workflow with Worker ↔ Reviewer cycle...")
# Create the workflow agent with an underlying reflection workflow.
agent = (
WorkflowBuilder()
.add_edge(worker, reviewer) # <--- This edge allows the worker to send requests to the reviewer
.add_edge(reviewer, worker) # <--- This edge allows the reviewer to send feedback back to the worker
.set_start_executor(worker)
.build()
.as_agent() # Convert the workflow to an agent.
)
print("🎯 Running workflow agent with user query...")
print("Query: 'Write code for parallel reading 1 million files on disk and write to a sorted output file.'")
print("-" * 50)
# Run the agent and stream events.
async for event in agent.run_stream(
"Write code for parallel reading 1 million files on disk and write to a sorted output file."
):
print(f"📤 Agent Response: {event}")
print("=" * 50)
print("✅ Workflow completed!")
if __name__ == "__main__":
print("🎬 Initializing Workflow as Agent Sample...")
asyncio.run(main())
@@ -15,33 +15,14 @@ from agent_framework.workflow import (
)
"""
The following sample demonstrates basic sub-workflows.
Sample: Sub-Workflows (Basics)
This sample shows how to:
1. Create workflows that execute other workflows as sub-workflows
2. Pass data between parent and sub-workflows
3. Collect results from sub-workflows
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.
The example simulates a simple text processing system where:
- Sub-workflows process individual text strings (count words, characters)
- Parent workflow orchestrates multiple sub-workflows and collects results
Key concepts demonstrated:
- WorkflowExecutor: Wraps a workflow to make it behave as an executor
- Sub-workflow isolation: Sub-workflows work independently
- Result collection: Parent workflows can gather outputs from sub-workflows
Simple flow visualization:
Parent Orchestrator
|
| TextProcessingRequest(text, task_id)
v
[ Sub-workflow: WorkflowExecutor(TextProcessor) ]
|
| WorkflowCompletedEvent(TextProcessingResult)
v
Parent collects results and summarizes
Prerequisites:
- No external services required.
"""
@@ -183,7 +164,7 @@ async def main():
"Hello world! This is a simple test.",
"Python is a powerful programming language used for many applications.",
"Short text.",
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.",
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.", # noqa: E501
"", # Empty string
" Spaces around text ",
]
@@ -34,8 +34,13 @@ except ImportError:
)
"""
Sample: Sub-workflow with parallel requests
This sample demonstrates the PROPER pattern for request interception.
Prerequisites:
- No external services required (external handling simulated via `RequestInfoExecutor`).
Key principles:
1. Only ONE executor intercepts a given request type from a specific sub-workflow
2. Different executors can intercept DIFFERENT request types from the same sub-workflow
@@ -1,7 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing import Any
from agent_framework_workflow import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowExecutor,
handler,
intercepts_request,
)
"""
The following sample demonstrates sub-workflows with request interception and conditional forwarding.
Sample: Sub-Workflows with Request Interception
This sample shows how to:
1. Create workflows that execute other workflows as sub-workflows
@@ -27,6 +44,9 @@ Key concepts demonstrated:
- External request routing: RequestInfoExecutor handles forwarded external requests
- Sub-workflow isolation: Sub-workflows work normally without knowing they're nested
Prerequisites:
- No external services required (external calls are simulated via `RequestInfoExecutor`).
Simple flow visualization:
Parent Orchestrator (@intercepts_request)
@@ -44,23 +64,6 @@ Simple flow visualization:
Response routed back to sub-workflow using request_id
"""
import asyncio
from dataclasses import dataclass
from typing import Any
from agent_framework_workflow import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowExecutor,
handler,
intercepts_request,
)
# 1. Define domain-specific message types
@dataclass
@@ -0,0 +1,87 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Any
from agent_framework_workflow import Executor, WorkflowBuilder, WorkflowContext, handler
"""Basic tracing workflow sample.
Sample: Workflow Tracing basics
A minimal two executor workflow demonstrates built in OpenTelemetry spans when diagnostics are enabled.
The sample raises an error if tracing is not configured.
Purpose:
- Require diagnostics by checking AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS and wiring a console exporter.
- Show the span categories produced by a simple graph:
- workflow.build (events: build.started, build.validation_completed, build.completed, edge_group.process)
- workflow.run (events: workflow.started, workflow.completed or workflow.error)
- executor.process (for each executor invocation)
- message.send (for each outbound message)
- Provide a tiny flow that is easy to run and reason about: uppercase then print.
Prerequisites:
- No external services required for the workflow itself.
- To print spans to the console, install the OpenTelemetry SDK: pip install opentelemetry-sdk
- Enable diagnostics:
configure your .env file with `AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true` or run:
export AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true
"""
def _ensure_tracing_configured() -> None:
"""Fail fast unless diagnostics are enabled and the SDK is present.
If the env var is set, attach a ConsoleSpanExporter so spans print to stdout.
"""
env = os.getenv("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS", "").lower()
if env not in {"1", "true", "yes"}:
raise RuntimeError(
"Tracing diagnostics are disabled. Set AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true "
"and rerun the sample."
)
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
except Exception as exc: # pragma: no cover
raise RuntimeError("OpenTelemetry SDK not found. Install it with: pip install opentelemetry-sdk") from exc
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
class StartExecutor(Executor):
@handler # type: ignore[misc]
async def handle_input(self, message: str, ctx: WorkflowContext[str]) -> None:
# Transform and forward downstream. This produces executor.process and message.send spans.
await ctx.send_message(message.upper())
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.
print(f"Final result: {message}")
async def main() -> None:
_ensure_tracing_configured() # Enforce tracing configuration before building or running the workflow.
# Build a two node graph: StartExecutor -> EndExecutor. The builder emits a workflow.build span.
workflow = (
WorkflowBuilder()
.add_edge(StartExecutor(id="start"), EndExecutor(id="end"))
.set_start_executor("start") # set_start_executor accepts an executor id string or the instance
.build()
) # workflow.build span emitted here
# Run once with a simple payload. You should see workflow.run plus executor and message spans.
await workflow.run("hello tracing") # workflow.run + executor.process and message.send spans
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())
@@ -0,0 +1,181 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing import Any
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunEvent,
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowViz,
handler,
)
from azure.identity import AzureCliCredential
"""
Sample: Concurrent (Fan-out/Fan-in) with Agents + Visualization
What it does:
- Fan-out: dispatch the same prompt to multiple domain agents (research, marketing, legal).
- Fan-in: aggregate their responses into one consolidated output.
- Visualization: generate Mermaid and GraphViz representations via `WorkflowViz` and optionally export SVG.
Prerequisites:
- Azure AI/ Azure OpenAI for `AzureChatClient` agents.
- Authentication via `azure-identity` — uses `AzureCliCredential()` (run `az login`).
- For visualization export: `pip install agent-framework-workflow[viz]` and install GraphViz binaries.
"""
class DispatchToExperts(Executor):
"""Dispatches the incoming prompt to all expert agent executors (fan-out)."""
def __init__(self, expert_ids: list[str], id: str | None = None):
super().__init__(id)
self._expert_ids = expert_ids
@handler
async def dispatch(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Wrap the incoming prompt as a user message for each expert and request a response.
initial_message = ChatMessage(Role.USER, text=prompt)
for expert_id in self._expert_ids:
await ctx.send_message(
AgentExecutorRequest(messages=[initial_message], should_respond=True),
target_id=expert_id,
)
@dataclass
class AggregatedInsights:
"""Structured output from the aggregator."""
research: str
marketing: str
legal: str
class AggregateInsights(Executor):
"""Aggregates expert agent responses into a single consolidated result (fan-in)."""
def __init__(self, expert_ids: list[str], id: str | None = None):
super().__init__(id)
self._expert_ids = expert_ids
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
# Map responses to text by executor id for a simple, predictable demo.
by_id: dict[str, str] = {}
for r in results:
# AgentExecutorResponse.agent_run_response.text contains concatenated assistant text
by_id[r.executor_id] = r.agent_run_response.text
research_text = by_id.get("researcher", "")
marketing_text = by_id.get("marketer", "")
legal_text = by_id.get("legal", "")
aggregated = AggregatedInsights(
research=research_text,
marketing=marketing_text,
legal=legal_text,
)
# Provide a readable, consolidated string as the final workflow result.
consolidated = (
"Consolidated Insights\n"
"====================\n\n"
f"Research Findings:\n{aggregated.research}\n\n"
f"Marketing Angle:\n{aggregated.marketing}\n\n"
f"Legal/Compliance Notes:\n{aggregated.legal}\n"
)
await ctx.add_event(WorkflowCompletedEvent(data=consolidated))
async def main() -> None:
# 1) Create agent executors for domain experts
chat_client = AzureChatClient(credential=AzureCliCredential())
researcher = AgentExecutor(
chat_client.create_agent(
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
),
id="researcher",
)
marketer = AgentExecutor(
chat_client.create_agent(
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
),
id="marketer",
)
legal = AgentExecutor(
chat_client.create_agent(
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
),
id="legal",
)
expert_ids = [researcher.id, marketer.id, legal.id]
dispatcher = DispatchToExperts(expert_ids=expert_ids, id="dispatcher")
aggregator = AggregateInsights(expert_ids=expert_ids, id="aggregator")
# 2) Build a simple fan-out/fan-in workflow
workflow = (
WorkflowBuilder()
.set_start_executor(dispatcher)
.add_fan_out_edges(dispatcher, [researcher, marketer, legal])
.add_fan_in_edges([researcher, marketer, legal], aggregator)
.build()
)
# 2.5) Generate workflow visualization
print("Generating workflow visualization...")
viz = WorkflowViz(workflow)
# Print out the mermaid string.
print("Mermaid string: \n=======")
print(viz.to_mermaid())
print("=======")
# Print out the DiGraph string.
print("DiGraph string: \n=======")
print(viz.to_digraph())
print("=======")
try:
# Export the DiGraph visualization as SVG.
svg_file = viz.export(format="svg")
print(f"SVG file saved to: {svg_file}")
except ImportError:
print("Tip: Install 'viz' extra to export workflow visualization: pip install agent-framework-workflow[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)
if __name__ == "__main__":
asyncio.run(main())