diff --git a/python/packages/workflow/agent_framework_workflow/_executor.py b/python/packages/workflow/agent_framework_workflow/_executor.py index 5d0812c9b9..2cd1b1623a 100644 --- a/python/packages/workflow/agent_framework_workflow/_executor.py +++ b/python/packages/workflow/agent_framework_workflow/_executor.py @@ -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 diff --git a/python/packages/workflow/agent_framework_workflow/_function_executor.py b/python/packages/workflow/agent_framework_workflow/_function_executor.py index 6068bc0823..05edc07fbd 100644 --- a/python/packages/workflow/agent_framework_workflow/_function_executor.py +++ b/python/packages/workflow/agent_framework_workflow/_function_executor.py @@ -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 diff --git a/python/packages/workflow/agent_framework_workflow/_runner.py b/python/packages/workflow/agent_framework_workflow/_runner.py index 09921299b0..bfdca5c27d 100644 --- a/python/packages/workflow/agent_framework_workflow/_runner.py +++ b/python/packages/workflow/agent_framework_workflow/_runner.py @@ -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 diff --git a/python/packages/workflow/agent_framework_workflow/_runner_context.py b/python/packages/workflow/agent_framework_workflow/_runner_context.py index 990c70680d..c78ff62f87 100644 --- a/python/packages/workflow/agent_framework_workflow/_runner_context.py +++ b/python/packages/workflow/agent_framework_workflow/_runner_context.py @@ -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 = "" + + +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 "" + + # 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) diff --git a/python/packages/workflow/agent_framework_workflow/_validation.py b/python/packages/workflow/agent_framework_workflow/_validation.py index a91ecfa793..94d4426c28 100644 --- a/python/packages/workflow/agent_framework_workflow/_validation.py +++ b/python/packages/workflow/agent_framework_workflow/_validation.py @@ -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() diff --git a/python/packages/workflow/agent_framework_workflow/_workflow.py b/python/packages/workflow/agent_framework_workflow/_workflow.py index 807ce27587..9232149a6a 100644 --- a/python/packages/workflow/agent_framework_workflow/_workflow.py +++ b/python/packages/workflow/agent_framework_workflow/_workflow.py @@ -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: diff --git a/python/packages/workflow/tests/test_validation.py b/python/packages/workflow/tests/test_validation.py index dcfdf20705..213a9f0e53 100644 --- a/python/packages/workflow/tests/test_validation.py +++ b/python/packages/workflow/tests/test_validation.py @@ -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") diff --git a/python/packages/workflow/tests/test_workflow_builder.py b/python/packages/workflow/tests/test_workflow_builder.py index 469cd5a3c5..ebd3d2c9d0 100644 --- a/python/packages/workflow/tests/test_workflow_builder.py +++ b/python/packages/workflow/tests/test_workflow_builder.py @@ -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 diff --git a/python/samples/getting_started/workflow/README.md b/python/samples/getting_started/workflow/README.md index f35113203e..c453877414 100644 --- a/python/samples/getting_started/workflow/README.md +++ b/python/samples/getting_started/workflow/README.md @@ -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 +- Agent‑based 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) diff --git a/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py b/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py new file mode 100644 index 0000000000..97d563215a --- /dev/null +++ b/python/samples/getting_started/workflow/agents/azure_chat_agents_streaming.py @@ -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()) diff --git a/python/samples/getting_started/workflow/agents/custom_agent_executors.py b/python/samples/getting_started/workflow/agents/custom_agent_executors.py new file mode 100644 index 0000000000..6d416eb1b1 --- /dev/null +++ b/python/samples/getting_started/workflow/agents/custom_agent_executors.py @@ -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()) diff --git a/python/samples/getting_started/workflow/agents/foundry_chat_agents_streaming.py b/python/samples/getting_started/workflow/agents/foundry_chat_agents_streaming.py new file mode 100644 index 0000000000..da7cba755a --- /dev/null +++ b/python/samples/getting_started/workflow/agents/foundry_chat_agents_streaming.py @@ -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()) diff --git a/python/samples/getting_started/workflow/step_10b_workflow_agent_human_in_the_loop.py b/python/samples/getting_started/workflow/agents/workflow_as_agent_human_in_the_loop.py similarity index 52% rename from python/samples/getting_started/workflow/step_10b_workflow_agent_human_in_the_loop.py rename to python/samples/getting_started/workflow/agents/workflow_as_agent_human_in_the_loop.py index 7e2f782030..54940837c0 100644 --- a/python/samples/getting_started/workflow/step_10b_workflow_agent_human_in_the_loop.py +++ b/python/samples/getting_started/workflow/agents/workflow_as_agent_human_in_the_loop.py @@ -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()) diff --git a/python/samples/getting_started/workflow/agents/workflow_as_agent_reflection_pattern.py b/python/samples/getting_started/workflow/agents/workflow_as_agent_reflection_pattern.py new file mode 100644 index 0000000000..8fa8835176 --- /dev/null +++ b/python/samples/getting_started/workflow/agents/workflow_as_agent_reflection_pattern.py @@ -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()) diff --git a/python/samples/getting_started/workflow/checkpoint/checkpoint_with_resume.py b/python/samples/getting_started/workflow/checkpoint/checkpoint_with_resume.py new file mode 100644 index 0000000000..db70db6ec5 --- /dev/null +++ b/python/samples/getting_started/workflow/checkpoint/checkpoint_with_resume.py @@ -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()) diff --git a/python/samples/getting_started/workflow/conditional_edges/edge_condition.py b/python/samples/getting_started/workflow/conditional_edges/edge_condition.py new file mode 100644 index 0000000000..2dace8b484 --- /dev/null +++ b/python/samples/getting_started/workflow/conditional_edges/edge_condition.py @@ -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()) diff --git a/python/samples/getting_started/workflow/conditional_edges/multi_selection_edge_group.py b/python/samples/getting_started/workflow/conditional_edges/multi_selection_edge_group.py new file mode 100644 index 0000000000..afed90325b --- /dev/null +++ b/python/samples/getting_started/workflow/conditional_edges/multi_selection_edge_group.py @@ -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()) diff --git a/python/samples/getting_started/workflow/conditional_edges/switch_case_edge_group.py b/python/samples/getting_started/workflow/conditional_edges/switch_case_edge_group.py new file mode 100644 index 0000000000..ba6eed5fc2 --- /dev/null +++ b/python/samples/getting_started/workflow/conditional_edges/switch_case_edge_group.py @@ -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()) diff --git a/python/samples/getting_started/workflow/fan_out_fan_in/fan_out_fan_in_edges.py b/python/samples/getting_started/workflow/fan_out_fan_in/fan_out_fan_in_edges.py new file mode 100644 index 0000000000..ca96bb78d6 --- /dev/null +++ b/python/samples/getting_started/workflow/fan_out_fan_in/fan_out_fan_in_edges.py @@ -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()) diff --git a/python/samples/getting_started/workflow/step_06_map_reduce_and_visualization.py b/python/samples/getting_started/workflow/fan_out_fan_in/map_reduce_and_visualization.py similarity index 58% rename from python/samples/getting_started/workflow/step_06_map_reduce_and_visualization.py rename to python/samples/getting_started/workflow/fan_out_fan_in/map_reduce_and_visualization.py index ab15e1ac64..e551c44136 100644 --- a/python/samples/getting_started/workflow/step_06_map_reduce_and_visualization.py +++ b/python/samples/getting_started/workflow/fan_out_fan_in/map_reduce_and_visualization.py @@ -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: diff --git a/python/samples/getting_started/workflow/foundational/step1_executors_and_edges.py b/python/samples/getting_started/workflow/foundational/step1_executors_and_edges.py new file mode 100644 index 0000000000..58b547219b --- /dev/null +++ b/python/samples/getting_started/workflow/foundational/step1_executors_and_edges.py @@ -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()) diff --git a/python/samples/getting_started/workflow/foundational/step2_agents_in_a_workflow.py b/python/samples/getting_started/workflow/foundational/step2_agents_in_a_workflow.py new file mode 100644 index 0000000000..aed9d22056 --- /dev/null +++ b/python/samples/getting_started/workflow/foundational/step2_agents_in_a_workflow.py @@ -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()) diff --git a/python/samples/getting_started/workflow/foundational/step3_streaming.py b/python/samples/getting_started/workflow/foundational/step3_streaming.py new file mode 100644 index 0000000000..7f86d3bfb7 --- /dev/null +++ b/python/samples/getting_started/workflow/foundational/step3_streaming.py @@ -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()) diff --git a/python/samples/getting_started/workflow/human_in_the_loop/guessing_game_with_human_input.py b/python/samples/getting_started/workflow/human_in_the_loop/guessing_game_with_human_input.py new file mode 100644 index 0000000000..c82e4d666e --- /dev/null +++ b/python/samples/getting_started/workflow/human_in_the_loop/guessing_game_with_human_input.py @@ -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": }}.'), + ) + 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": }. ' + "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()) diff --git a/python/samples/getting_started/workflow/step_03_basic_workflow_loop.py b/python/samples/getting_started/workflow/loop/simple_loop.py similarity index 54% rename from python/samples/getting_started/workflow/step_03_basic_workflow_loop.py rename to python/samples/getting_started/workflow/loop/simple_loop.py index 028c142323..b2ed84e42a 100644 --- a/python/samples/getting_started/workflow/step_03_basic_workflow_loop.py +++ b/python/samples/getting_started/workflow/loop/simple_loop.py @@ -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() ) diff --git a/python/samples/getting_started/workflow/step_08a_magentic_workflow.py b/python/samples/getting_started/workflow/orchestration/magentic.py similarity index 95% rename from python/samples/getting_started/workflow/step_08a_magentic_workflow.py rename to python/samples/getting_started/workflow/orchestration/magentic.py index 4f2d8bd271..e5fd8d4ba6 100644 --- a/python/samples/getting_started/workflow/step_08a_magentic_workflow.py +++ b/python/samples/getting_started/workflow/orchestration/magentic.py @@ -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`. """ diff --git a/python/samples/getting_started/workflow/step_08b_magentic_human_plan_update.py b/python/samples/getting_started/workflow/orchestration/magentic_human_plan_update.py similarity index 95% rename from python/samples/getting_started/workflow/step_08b_magentic_human_plan_update.py rename to python/samples/getting_started/workflow/orchestration/magentic_human_plan_update.py index 5a49d5c8c5..9e946c2d23 100644 --- a/python/samples/getting_started/workflow/step_08b_magentic_human_plan_update.py +++ b/python/samples/getting_started/workflow/orchestration/magentic_human_plan_update.py @@ -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`. """ diff --git a/python/samples/getting_started/workflow/resources/ambiguous_email.txt b/python/samples/getting_started/workflow/resources/ambiguous_email.txt new file mode 100644 index 0000000000..a9668280bd --- /dev/null +++ b/python/samples/getting_started/workflow/resources/ambiguous_email.txt @@ -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 \ No newline at end of file diff --git a/python/samples/getting_started/workflow/resources/email.txt b/python/samples/getting_started/workflow/resources/email.txt new file mode 100644 index 0000000000..3ab05c36ac --- /dev/null +++ b/python/samples/getting_started/workflow/resources/email.txt @@ -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 \ No newline at end of file diff --git a/python/samples/getting_started/workflow/resources/spam.txt b/python/samples/getting_started/workflow/resources/spam.txt new file mode 100644 index 0000000000..e25f62fd40 --- /dev/null +++ b/python/samples/getting_started/workflow/resources/spam.txt @@ -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 \ No newline at end of file diff --git a/python/samples/getting_started/workflow/sequential/sequential_executors.py b/python/samples/getting_started/workflow/sequential/sequential_executors.py new file mode 100644 index 0000000000..9da46054f3 --- /dev/null +++ b/python/samples/getting_started/workflow/sequential/sequential_executors.py @@ -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()) diff --git a/python/samples/getting_started/workflow/sequential/sequential_streaming.py b/python/samples/getting_started/workflow/sequential/sequential_streaming.py new file mode 100644 index 0000000000..9333611824 --- /dev/null +++ b/python/samples/getting_started/workflow/sequential/sequential_streaming.py @@ -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()) diff --git a/python/samples/getting_started/workflow/shared_states/shared_states_with_agents.py b/python/samples/getting_started/workflow/shared_states/shared_states_with_agents.py new file mode 100644 index 0000000000..82777dd63e --- /dev/null +++ b/python/samples/getting_started/workflow/shared_states/shared_states_with_agents.py @@ -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()) diff --git a/python/samples/getting_started/workflow/step_00_foundation_patterns.py b/python/samples/getting_started/workflow/step_00_foundation_patterns.py deleted file mode 100644 index 206734b359..0000000000 --- a/python/samples/getting_started/workflow/step_00_foundation_patterns.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_01_basic_workflow_sequential.py b/python/samples/getting_started/workflow/step_01_basic_workflow_sequential.py deleted file mode 100644 index 673c496bfc..0000000000 --- a/python/samples/getting_started/workflow/step_01_basic_workflow_sequential.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_01a_basic_workflow_sequential_non_stream.py b/python/samples/getting_started/workflow/step_01a_basic_workflow_sequential_non_stream.py deleted file mode 100644 index 2b21ababa8..0000000000 --- a/python/samples/getting_started/workflow/step_01a_basic_workflow_sequential_non_stream.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_02_basic_workflow_condition.py b/python/samples/getting_started/workflow/step_02_basic_workflow_condition.py deleted file mode 100644 index 72b64b398b..0000000000 --- a/python/samples/getting_started/workflow/step_02_basic_workflow_condition.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_04_basic_group_chat.py b/python/samples/getting_started/workflow/step_04_basic_group_chat.py deleted file mode 100644 index a03722fbc7..0000000000 --- a/python/samples/getting_started/workflow/step_04_basic_group_chat.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_05_basic_group_chat_with_hil.py b/python/samples/getting_started/workflow/step_05_basic_group_chat_with_hil.py deleted file mode 100644 index fcf30b5612..0000000000 --- a/python/samples/getting_started/workflow/step_05_basic_group_chat_with_hil.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_07_checkpoint_resume.py b/python/samples/getting_started/workflow/step_07_checkpoint_resume.py deleted file mode 100644 index 54a1087ed1..0000000000 --- a/python/samples/getting_started/workflow/step_07_checkpoint_resume.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_10a_workflow_agent_reflection_pattern.py b/python/samples/getting_started/workflow/step_10a_workflow_agent_reflection_pattern.py deleted file mode 100644 index 0988476e62..0000000000 --- a/python/samples/getting_started/workflow/step_10a_workflow_agent_reflection_pattern.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflow/step_09a_sub_workflow.py b/python/samples/getting_started/workflow/sub_workflow/sub_workflow_basics.py similarity index 87% rename from python/samples/getting_started/workflow/step_09a_sub_workflow.py rename to python/samples/getting_started/workflow/sub_workflow/sub_workflow_basics.py index e0ca073557..ed023be224 100644 --- a/python/samples/getting_started/workflow/step_09a_sub_workflow.py +++ b/python/samples/getting_started/workflow/sub_workflow/sub_workflow_basics.py @@ -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 ", ] diff --git a/python/samples/getting_started/workflow/step_09c_sub_workflow_parallel_requests.py b/python/samples/getting_started/workflow/sub_workflow/sub_workflow_parallel_requests.py similarity index 99% rename from python/samples/getting_started/workflow/step_09c_sub_workflow_parallel_requests.py rename to python/samples/getting_started/workflow/sub_workflow/sub_workflow_parallel_requests.py index 02892d7f0a..7ab7737a8e 100644 --- a/python/samples/getting_started/workflow/step_09c_sub_workflow_parallel_requests.py +++ b/python/samples/getting_started/workflow/sub_workflow/sub_workflow_parallel_requests.py @@ -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 diff --git a/python/samples/getting_started/workflow/step_09b_sub_workflow_request_interception.py b/python/samples/getting_started/workflow/sub_workflow/sub_workflow_request_interception.py similarity index 98% rename from python/samples/getting_started/workflow/step_09b_sub_workflow_request_interception.py rename to python/samples/getting_started/workflow/sub_workflow/sub_workflow_request_interception.py index a3933c6c9e..eae530334b 100644 --- a/python/samples/getting_started/workflow/step_09b_sub_workflow_request_interception.py +++ b/python/samples/getting_started/workflow/sub_workflow/sub_workflow_request_interception.py @@ -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 diff --git a/python/samples/getting_started/workflow/tracing/tracing_basics.py b/python/samples/getting_started/workflow/tracing/tracing_basics.py new file mode 100644 index 0000000000..0fbccd9e47 --- /dev/null +++ b/python/samples/getting_started/workflow/tracing/tracing_basics.py @@ -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()) diff --git a/python/samples/getting_started/workflow/visualization/concurrent_with_visualization.py b/python/samples/getting_started/workflow/visualization/concurrent_with_visualization.py new file mode 100644 index 0000000000..28cdae27f4 --- /dev/null +++ b/python/samples/getting_started/workflow/visualization/concurrent_with_visualization.py @@ -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())