Python: WorkflowBuilder registry (#2486)

* Add workflow builder factory pattern

* Add internal edge groups to registered executors; next samples

* Update samples: Part 1

* register -> register_executor

* update hil samples

* Update other samples

* Update agent  samples

* Update doc string

* Add new sample

* Fix mypy

* Address comments

* Fix mypy
This commit is contained in:
Tao Chen
2025-12-04 21:26:10 -08:00
committed by GitHub
Unverified
parent 6809510413
commit f2ed5b55f6
33 changed files with 1609 additions and 696 deletions
@@ -232,7 +232,7 @@ class Case:
"""
condition: Callable[[Any], bool]
target: Executor
target: Executor | str
@dataclass
@@ -255,7 +255,7 @@ class Default:
assert fallback.target.id == "dead_letter"
"""
target: Executor
target: Executor | str
@dataclass(init=False)
@@ -101,21 +101,20 @@ class WorkflowGraphValidator:
def __init__(self) -> None:
self._edges: list[Edge] = []
self._executors: dict[str, Executor] = {}
self._start_executor_ref: Executor | str | None = None
# region Core Validation Methods
def validate_workflow(
self,
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor | str,
start_executor: Executor,
) -> None:
"""Validate the entire workflow graph.
Args:
edge_groups: list of edge groups in the workflow
executors: Map of executor IDs to executor instances
start_executor: The starting executor (can be instance or ID)
start_executor: The starting executor
Raises:
WorkflowValidationError: If any validation fails
@@ -123,22 +122,20 @@ class WorkflowGraphValidator:
self._executors = executors
self._edges = [edge for group in edge_groups for edge in group.edges]
self._edge_groups = edge_groups
self._start_executor_ref = start_executor
# If only the start executor exists, add it to the executor map
# Handle the special case where the workflow consists of only a single executor and no edges.
# In this scenario, the executor map will be empty because there are no edge groups to reference executors.
# Adding the start executor to the map ensures that single-executor workflows (without any edges) are supported,
# allowing validation and execution to proceed for workflows that do not require inter-executor communication.
if not self._executors and start_executor and isinstance(start_executor, Executor):
if not self._executors:
self._executors[start_executor.id] = start_executor
# Validate that start_executor exists in the graph
# It should because we check for it in the WorkflowBuilder
# but we do it here for completeness.
start_executor_id = start_executor.id if isinstance(start_executor, Executor) else start_executor
if start_executor_id not in self._executors:
raise GraphConnectivityError(f"Start executor '{start_executor_id}' is not present in the workflow graph")
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)
@@ -152,16 +149,16 @@ class WorkflowGraphValidator:
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:
if start_executor.id not in edge_executor_ids:
raise GraphConnectivityError(
f"Start executor '{start_executor_id}' is not present in the workflow graph"
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()
self._validate_type_compatibility()
self._validate_graph_connectivity(start_executor_id)
self._validate_graph_connectivity(start_executor.id)
self._validate_self_loops()
self._validate_dead_ends()
@@ -398,7 +395,7 @@ class WorkflowGraphValidator:
def validate_workflow_graph(
edge_groups: Sequence[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor | str,
start_executor: Executor,
) -> None:
"""Convenience function to validate a workflow graph.
@@ -180,7 +180,7 @@ class Workflow(DictConvertible):
self,
edge_groups: list[EdgeGroup],
executors: dict[str, Executor],
start_executor: Executor | str,
start_executor: Executor,
runner_context: RunnerContext,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
name: str | None = None,
@@ -192,19 +192,16 @@ class Workflow(DictConvertible):
Args:
edge_groups: A list of EdgeGroup instances that define the workflow edges.
executors: A dictionary mapping executor IDs to Executor instances.
start_executor: The starting executor for the workflow, which can be an Executor instance or its ID.
start_executor: The starting executor for the workflow.
runner_context: The RunnerContext instance to be used during workflow execution.
max_iterations: The maximum number of iterations the workflow will run for convergence.
name: Optional human-readable name for the workflow.
description: Optional description of what the workflow does.
kwargs: Additional keyword arguments. Unused in this implementation.
"""
# Convert start_executor to string ID if it's an Executor instance
start_executor_id = start_executor.id if isinstance(start_executor, Executor) else start_executor
self.edge_groups = list(edge_groups)
self.executors = dict(executors)
self.start_executor_id = start_executor_id
self.start_executor_id = start_executor.id
self.max_iterations = max_iterations
self.id = str(uuid.uuid4())
self.name = name
@@ -3,8 +3,13 @@
import logging
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any
from typing_extensions import deprecated
from agent_framework import AgentThread
from .._agents import AgentProtocol
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._agent_executor import AgentExecutor
@@ -36,6 +41,76 @@ else:
logger = logging.getLogger(__name__)
@dataclass
class _EdgeRegistration:
"""A data class representing an edge registration in the workflow builder.
Args:
source: The registered source name.
target: The registered target name.
condition: An optional condition function for the edge.
"""
source: str
target: str
condition: Callable[[Any], bool] | None = None
@dataclass
class _FanOutEdgeRegistration:
"""A data class representing a fan-out edge registration in the workflow builder.
Args:
source: The registered source name.
targets: A list of registered target names.
"""
source: str
targets: list[str]
@dataclass
class _FanInEdgeRegistration:
"""A data class representing a fan-in edge registration in the workflow builder.
Args:
sources: A list of registered source names.
target: The registered target name.
"""
sources: list[str]
target: str
@dataclass
class _SwitchCaseEdgeGroupRegistration:
"""A data class representing a switch-case edge group registration in the workflow builder.
Args:
source: The registered source name.
cases: A list of case objects that determine the target executor for each message.
"""
source: str
cases: list[Case | Default]
@dataclass
class _MultiSelectionEdgeGroupRegistration:
"""A data class representing a multi-selection edge group registration in the workflow builder.
Args:
source: The registered source name.
targets: A list of registered target names.
selection_func: A function that selects target executors for messages.
Takes (message, list[registered target names]) and returns list[registered target names].
"""
source: str
targets: list[str]
selection_func: Callable[[Any, list[str]], list[str]]
class WorkflowBuilder:
"""A builder class for constructing workflows.
@@ -65,8 +140,10 @@ class WorkflowBuilder:
# Build a workflow
workflow = (
WorkflowBuilder()
.add_edge(UpperCaseExecutor(id="upper"), ReverseExecutor(id="reverse"))
.set_start_executor("upper")
.register_executor(lambda: UpperCaseExecutor(id="upper"), name="UpperCase")
.register_executor(lambda: ReverseExecutor(id="reverse"), name="Reverse")
.add_edge("UpperCase", "Reverse")
.set_start_executor("UpperCase")
.build()
)
@@ -101,6 +178,16 @@ class WorkflowBuilder:
# the start node vs edge nodes and triggering a GraphConnectivityError during validation.
self._agent_wrappers: dict[int, Executor] = {}
# Registrations for lazy initialization of executors
self._edge_registry: list[
_EdgeRegistration
| _FanOutEdgeRegistration
| _SwitchCaseEdgeGroupRegistration
| _MultiSelectionEdgeGroupRegistration
| _FanInEdgeRegistration
] = []
self._executor_registry: dict[str, Callable[[], Executor]] = {}
# Agents auto-wrapped by builder now always stream incremental updates.
def _add_executor(self, executor: Executor) -> str:
@@ -173,6 +260,135 @@ class WorkflowBuilder:
f"WorkflowBuilder expected an Executor or AgentProtocol instance; got {type(candidate).__name__}."
)
def register_executor(self, factory_func: Callable[[], Executor], name: str | list[str]) -> Self:
"""Register an executor factory function for lazy initialization.
This method allows you to register a factory function that creates an executor.
The executor will be instantiated only when the workflow is built, enabling
deferred initialization and potentially reducing startup time.
Args:
factory_func: A callable that returns an Executor instance when called.
name: The name(s) of the registered executor factory. This doesn't have to match
the executor's ID, but it must be unique within the workflow.
Example:
.. code-block:: python
from typing_extensions import Never
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
class UpperCaseExecutor(Executor):
@handler
async def process(self, text: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(text.upper())
class ReverseExecutor(Executor):
@handler
async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(text[::-1])
# Build a workflow
workflow = (
WorkflowBuilder()
.register_executor(lambda: UpperCaseExecutor(id="upper"), name="UpperCase")
.register_executor(lambda: ReverseExecutor(id="reverse"), name="Reverse")
.set_start_executor("UpperCase")
.add_edge("UpperCase", "Reverse")
.build()
)
If multiple names are provided, the same factory function will be registered under each name.
...code-block:: python
from agent_framework import WorkflowBuilder, Executor, WorkflowContext, handler
class LoggerExecutor(Executor):
@handler
async def log(self, message: str, ctx: WorkflowContext) -> None:
print(f"Log: {message}")
# Register the same executor factory under multiple names
workflow = (
WorkflowBuilder()
.register_executor(lambda: CustomExecutor(id="logger"), name=["ExecutorA", "ExecutorB"])
.set_start_executor("ExecutorA")
.add_edge("ExecutorA", "ExecutorB")
.build()
"""
names = [name] if isinstance(name, str) else name
for n in names:
if n in self._executor_registry:
raise ValueError(f"An executor factory with the name '{n}' is already registered.")
for n in names:
self._executor_registry[n] = factory_func
return self
def register_agent(
self,
factory_func: Callable[[], AgentProtocol],
name: str,
agent_thread: AgentThread | None = None,
output_response: bool = False,
) -> Self:
"""Register an agent factory function for lazy initialization.
This method allows you to register a factory function that creates an agent.
The agent will be instantiated and wrapped in an AgentExecutor only when the workflow is built,
enabling deferred initialization and potentially reducing startup time.
Args:
factory_func: A callable that returns an AgentProtocol instance when called.
name: The name of the registered agent factory. This doesn't have to match
the agent's internal name. But it must be unique within the workflow.
agent_thread: The thread to use for running the agent. If None, a new thread will be created when
the agent is instantiated.
output_response: Whether to yield an AgentRunResponse as a workflow output when the agent completes.
Example:
.. code-block:: python
from agent_framework import WorkflowBuilder
from agent_framework_anthropic import AnthropicAgent
# Build a workflow
workflow = (
WorkflowBuilder()
.register_executor(lambda: ..., name="SomeOtherExecutor")
.register_agent(
lambda: AnthropicAgent(name="writer", model="claude-3-5-sonnet-20241022"),
name="WriterAgent",
output_response=True,
)
.add_edge("SomeOtherExecutor", "WriterAgent")
.set_start_executor("SomeOtherExecutor")
.build()
)
"""
if name in self._executor_registry:
raise ValueError(f"An executor factory with the name '{name}' is already registered.")
def wrapped_factory() -> AgentExecutor:
agent = factory_func()
return AgentExecutor(
agent,
agent_thread=agent_thread,
output_response=output_response,
)
self._executor_registry[name] = wrapped_factory
return self
@deprecated("Use register_agent() for lazy initialization instead.")
def add_agent(
self,
agent: AgentProtocol,
@@ -214,6 +430,11 @@ class WorkflowBuilder:
# Add the agent to a workflow
workflow = WorkflowBuilder().add_agent(agent, output_response=True).set_start_executor(agent).build()
"""
logger.warning(
"Adding an agent instance directly to WorkflowBuilder is not recommended, "
"because workflow instances created from the builder will share the same agent instance. "
"Consider using register_agent() for lazy initialization instead."
)
executor = self._maybe_wrap_agent(
agent, agent_thread=agent_thread, output_response=output_response, executor_id=id
)
@@ -222,8 +443,8 @@ class WorkflowBuilder:
def add_edge(
self,
source: Executor | AgentProtocol,
target: Executor | AgentProtocol,
source: Executor | AgentProtocol | str,
target: Executor | AgentProtocol | str,
condition: Callable[[Any], bool] | None = None,
) -> Self:
"""Add a directed edge between two executors.
@@ -232,8 +453,8 @@ class WorkflowBuilder:
Messages sent by the source executor will be routed to the target executor.
Args:
source: The source executor of the edge.
target: The target executor of the edge.
source: The source executor or registered name of the source factory for the edge.
target: The target executor or registered name of the target factory for the edge.
condition: An optional condition function that determines whether the edge
should be traversed based on the message type.
@@ -261,7 +482,12 @@ class WorkflowBuilder:
# Connect executors with an edge
workflow = (
WorkflowBuilder().add_edge(ProcessorA(id="a"), ProcessorB(id="b")).set_start_executor("a").build()
WorkflowBuilder()
.register_executor(lambda: ProcessorA(id="a"), name="ProcessorA")
.register_executor(lambda: ProcessorB(id="b"), name="ProcessorB")
.add_edge("ProcessorA", "ProcessorB")
.set_start_executor("ProcessorA")
.build()
)
@@ -272,14 +498,33 @@ class WorkflowBuilder:
workflow = (
WorkflowBuilder()
.add_edge(ProcessorA(id="a"), ProcessorB(id="b"), condition=only_large_numbers)
.set_start_executor("a")
.register_executor(lambda: ProcessorA(id="a"), name="ProcessorA")
.register_executor(lambda: ProcessorB(id="b"), name="ProcessorB")
.add_edge("ProcessorA", "ProcessorB", condition=only_large_numbers)
.set_start_executor("ProcessorA")
.build()
)
"""
# TODO(@taochen): Support executor factories for lazy initialization
source_exec = self._maybe_wrap_agent(source)
target_exec = self._maybe_wrap_agent(target)
if not isinstance(source, str) or not isinstance(target, str):
logger.warning(
"Adding an edge with Executor or AgentProtocol instances directly is not recommended, "
"because workflow instances created from the builder will share the same executor/agent instances. "
"Consider using a registered name for lazy initialization instead."
)
if (isinstance(source, str) and not isinstance(target, str)) or (
not isinstance(source, str) and isinstance(target, str)
):
raise ValueError("Both source and target must be either names (str) or Executor/AgentProtocol instances.")
if isinstance(source, str) and isinstance(target, str):
# Both are names; defer resolution to build time
self._edge_registry.append(_EdgeRegistration(source=source, target=target, condition=condition))
return self
# Both are Executor/AgentProtocol instances; wrap and add now
source_exec = self._maybe_wrap_agent(source) # type: ignore[arg-type]
target_exec = self._maybe_wrap_agent(target) # type: ignore[arg-type]
source_id = self._add_executor(source_exec)
target_id = self._add_executor(target_exec)
self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition)) # type: ignore[call-arg]
@@ -287,8 +532,8 @@ class WorkflowBuilder:
def add_fan_out_edges(
self,
source: Executor | AgentProtocol,
targets: Sequence[Executor | AgentProtocol],
source: Executor | AgentProtocol | str,
targets: Sequence[Executor | AgentProtocol | str],
) -> Self:
"""Add multiple edges to the workflow where messages from the source will be sent to all targets.
@@ -296,8 +541,8 @@ class WorkflowBuilder:
Messages from the source will be broadcast to all target executors concurrently.
Args:
source: The source executor of the edges.
targets: A list of target executors for the edges.
source: The source executor or registered name of the source factory for the edges.
targets: A list of target executors or registered names of the target factories for the edges.
Returns:
Self: The WorkflowBuilder instance for method chaining.
@@ -330,13 +575,34 @@ class WorkflowBuilder:
# Broadcast to multiple validators
workflow = (
WorkflowBuilder()
.add_fan_out_edges(DataSource(id="source"), [ValidatorA(id="val_a"), ValidatorB(id="val_b")])
.set_start_executor("source")
.register_executor(lambda: DataSource(id="source"), name="DataSource")
.register_executor(lambda: ValidatorA(id="val_a"), name="ValidatorA")
.register_executor(lambda: ValidatorB(id="val_b"), name="ValidatorB")
.add_fan_out_edges("DataSource", ["ValidatorA", "ValidatorB"])
.set_start_executor("DataSource")
.build()
)
"""
source_exec = self._maybe_wrap_agent(source)
target_execs = [self._maybe_wrap_agent(t) for t in targets]
if not isinstance(source, str) or any(not isinstance(t, str) for t in targets):
logger.warning(
"Adding fan-out edges with Executor or AgentProtocol instances directly is not recommended, "
"because workflow instances created from the builder will share the same executor/agent instances. "
"Consider using registered names for lazy initialization instead."
)
if (isinstance(source, str) and not all(isinstance(t, str) for t in targets)) or (
not isinstance(source, str) and any(isinstance(t, str) for t in targets)
):
raise ValueError("Both source and targets must be either names (str) or Executor/AgentProtocol instances.")
if isinstance(source, str) and all(isinstance(t, str) for t in targets):
# Both are names; defer resolution to build time
self._edge_registry.append(_FanOutEdgeRegistration(source=source, targets=list(targets))) # type: ignore
return self
# Both are Executor/AgentProtocol instances; wrap and add now
source_exec = self._maybe_wrap_agent(source) # type: ignore[arg-type]
target_execs = [self._maybe_wrap_agent(t) for t in targets] # type: ignore[arg-type]
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)) # type: ignore[call-arg]
@@ -345,7 +611,7 @@ class WorkflowBuilder:
def add_switch_case_edge_group(
self,
source: Executor | AgentProtocol,
source: Executor | AgentProtocol | str,
cases: Sequence[Case | Default],
) -> Self:
"""Add an edge group that represents a switch-case statement.
@@ -362,7 +628,7 @@ class WorkflowBuilder:
(i.e., no condition matched).
Args:
source: The source executor of the edges.
source: The source executor or registered name of the source factory for the edge group.
cases: A list of case objects that determine the target executor for each message.
Returns:
@@ -401,24 +667,47 @@ class WorkflowBuilder:
# Route based on score value
workflow = (
WorkflowBuilder()
.register_executor(lambda: Evaluator(id="eval"), name="Evaluator")
.register_executor(lambda: HighScoreHandler(id="high"), name="HighScoreHandler")
.register_executor(lambda: LowScoreHandler(id="low"), name="LowScoreHandler")
.add_switch_case_edge_group(
Evaluator(id="eval"),
"Evaluator",
[
Case(condition=lambda r: r.score > 10, target=HighScoreHandler(id="high")),
Default(target=LowScoreHandler(id="low")),
Case(condition=lambda r: r.score > 10, target="HighScoreHandler"),
Default(target="LowScoreHandler"),
],
)
.set_start_executor("eval")
.set_start_executor("Evaluator")
.build()
)
"""
source_exec = self._maybe_wrap_agent(source)
if not isinstance(source, str) or not all(isinstance(case.target, str) for case in cases):
logger.warning(
"Adding a switch-case edge group with Executor or AgentProtocol instances directly is not recommended, "
"because workflow instances created from the builder will share the same executor/agent instance. "
"Consider using a registered name for lazy initialization instead."
)
if (isinstance(source, str) and not all(isinstance(case.target, str) for case in cases)) or (
not isinstance(source, str) and any(isinstance(case.target, str) for case in cases)
):
raise ValueError(
"Both source and case targets must be either names (str) or Executor/AgentProtocol instances."
)
if isinstance(source, str) and all(isinstance(case.target, str) for case in cases):
# Source is a name; defer resolution to build time
self._edge_registry.append(_SwitchCaseEdgeGroupRegistration(source=source, cases=list(cases))) # type: ignore
return self
# Source is an Executor/AgentProtocol instance; wrap and add now
source_exec = self._maybe_wrap_agent(source) # type: ignore[arg-type]
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]
case.target = self._maybe_wrap_agent(case.target) # type: ignore[arg-type]
self._add_executor(case.target)
if isinstance(case, Default):
internal_cases.append(SwitchCaseEdgeGroupDefault(target_id=case.target.id))
@@ -430,8 +719,8 @@ class WorkflowBuilder:
def add_multi_selection_edge_group(
self,
source: Executor | AgentProtocol,
targets: Sequence[Executor | AgentProtocol],
source: Executor | AgentProtocol | str,
targets: Sequence[Executor | AgentProtocol | str],
selection_func: Callable[[Any, list[str]], list[str]],
) -> Self:
"""Add an edge group that represents a multi-selection execution model.
@@ -444,10 +733,11 @@ class WorkflowBuilder:
and return a list of executor IDs indicating which target executors should receive the message.
Args:
source: The source executor of the edges.
targets: A list of target executors for the edges.
source: The source executor or registered name of the source factory for the edge group.
targets: A list of target executors or registered names of the target factories for the edges.
selection_func: A function that selects target executors for messages.
Takes (message, list[executor_id]) and returns list[executor_id].
Takes (message, list[executor_id or registered target names]) and
returns list[executor_id or registered target names].
Returns:
Self: The WorkflowBuilder instance for method chaining.
@@ -485,25 +775,52 @@ class WorkflowBuilder:
# Select workers based on task priority
def select_workers(task: Task, executor_ids: list[str]) -> list[str]:
def select_workers(task: Task, available: list[str]) -> list[str]:
if task.priority == "high":
return executor_ids # Send to all workers
return [executor_ids[0]] # Send to first worker only
return available # Send to all workers
return [available[0]] # Send to first worker only
workflow = (
WorkflowBuilder()
.register_executor(lambda: TaskDispatcher(id="dispatcher"), name="TaskDispatcher")
.register_executor(lambda: WorkerA(id="worker_a"), name="WorkerA")
.register_executor(lambda: WorkerB(id="worker_b"), name="WorkerB")
.add_multi_selection_edge_group(
TaskDispatcher(id="dispatcher"),
[WorkerA(id="worker_a"), WorkerB(id="worker_b")],
"TaskDispatcher",
["WorkerA", "WorkerB"],
selection_func=select_workers,
)
.set_start_executor("dispatcher")
.set_start_executor("TaskDispatcher")
.build()
)
"""
source_exec = self._maybe_wrap_agent(source)
target_execs = [self._maybe_wrap_agent(t) for t in targets]
if not isinstance(source, str) or any(not isinstance(t, str) for t in targets):
logger.warning(
"Adding fan-out edges with Executor or AgentProtocol instances directly is not recommended, "
"because workflow instances created from the builder will share the same executor/agent instances. "
"Consider using registered names for lazy initialization instead."
)
if (isinstance(source, str) and not all(isinstance(t, str) for t in targets)) or (
not isinstance(source, str) and any(isinstance(t, str) for t in targets)
):
raise ValueError("Both source and targets must be either names (str) or Executor/AgentProtocol instances.")
if isinstance(source, str) and all(isinstance(t, str) for t in targets):
# Both are names; defer resolution to build time
self._edge_registry.append(
_MultiSelectionEdgeGroupRegistration(
source=source,
targets=list(targets), # type: ignore
selection_func=selection_func,
)
)
return self
# Both are Executor/AgentProtocol instances; wrap and add now
source_exec = self._maybe_wrap_agent(source) # type: ignore
target_execs = [self._maybe_wrap_agent(t) for t in targets] # type: ignore
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)) # type: ignore[call-arg]
@@ -512,8 +829,8 @@ class WorkflowBuilder:
def add_fan_in_edges(
self,
sources: Sequence[Executor | AgentProtocol],
target: Executor | AgentProtocol,
sources: Sequence[Executor | AgentProtocol | str],
target: Executor | AgentProtocol | str,
) -> Self:
"""Add multiple edges from sources to a single target executor.
@@ -525,8 +842,8 @@ class WorkflowBuilder:
types of the source executors.
Args:
sources: A list of source executors for the edges.
target: The target executor for the edges.
sources: A list of source executors or registered names of the source factories for the edges.
target: The target executor or registered name of the target factory for the edges.
Returns:
Self: The WorkflowBuilder instance for method chaining.
@@ -554,20 +871,41 @@ class WorkflowBuilder:
# Collect results from multiple producers
workflow = (
WorkflowBuilder()
.add_fan_in_edges([Producer(id="prod_1"), Producer(id="prod_2")], Aggregator(id="agg"))
.set_start_executor("prod_1")
.register_executor(lambda: Producer(id="prod_1"), name="Producer1")
.register_executor(lambda: Producer(id="prod_2"), name="Producer2")
.register_executor(lambda: Aggregator(id="agg"), name="Aggregator")
.add_fan_in_edges(["Producer1", "Producer2"], "Aggregator")
.set_start_executor("Producer1")
.build()
)
"""
source_execs = [self._maybe_wrap_agent(s) for s in sources]
target_exec = self._maybe_wrap_agent(target)
if not all(isinstance(s, str) for s in sources) or not isinstance(target, str):
logger.warning(
"Adding fan-in edges with Executor or AgentProtocol instances directly is not recommended, "
"because workflow instances created from the builder will share the same executor/agent instances. "
"Consider using registered names for lazy initialization instead."
)
if (all(isinstance(s, str) for s in sources) and not isinstance(target, str)) or (
not all(isinstance(s, str) for s in sources) and isinstance(target, str)
):
raise ValueError("Both sources and target must be either names (str) or Executor/AgentProtocol instances.")
if all(isinstance(s, str) for s in sources) and isinstance(target, str):
# Both are names; defer resolution to build time
self._edge_registry.append(_FanInEdgeRegistration(sources=list(sources), target=target)) # type: ignore
return self
# Both are Executor/AgentProtocol instances; wrap and add now
source_execs = [self._maybe_wrap_agent(s) for s in sources] # type: ignore
target_exec = self._maybe_wrap_agent(target) # type: ignore
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)) # type: ignore[call-arg]
return self
def add_chain(self, executors: Sequence[Executor | AgentProtocol]) -> Self:
def add_chain(self, executors: Sequence[Executor | AgentProtocol | str]) -> 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.
@@ -576,7 +914,7 @@ class WorkflowBuilder:
Circles in the chain are not allowed, meaning the chain cannot have two executors with the same ID.
Args:
executors: A list of executors to be added to the chain.
executors: A list of executors or registered names of the executor factories to chain together.
Returns:
Self: The WorkflowBuilder instance for method chaining.
@@ -609,13 +947,38 @@ class WorkflowBuilder:
# Chain executors in sequence
workflow = (
WorkflowBuilder()
.add_chain([Step1(id="step1"), Step2(id="step2"), Step3(id="step3")])
.register_executor(lambda: Step1(id="step1"), name="step1")
.register_executor(lambda: Step2(id="step2"), name="step2")
.register_executor(lambda: Step3(id="step3"), name="step3")
.add_chain(["step1", "step2", "step3"])
.set_start_executor("step1")
.build()
)
"""
if len(executors) < 2:
raise ValueError("At least two executors are required to form a chain.")
if not all(isinstance(e, str) for e in executors):
logger.warning(
"Adding a chain with Executor or AgentProtocol instances directly is not recommended, "
"because workflow instances created from the builder will share the same executor/agent instances. "
"Consider using registered names for lazy initialization instead."
)
if not all(isinstance(e, str) for e in executors) and any(isinstance(e, str) for e in executors):
raise ValueError(
"All executors in the chain must be either names (str) or Executor/AgentProtocol instances."
)
if all(isinstance(e, str) for e in executors):
# All are names; defer resolution to build time
for i in range(len(executors) - 1):
self.add_edge(executors[i], executors[i + 1])
return self
# Both are Executor/AgentProtocol instances; wrap and add now
# Wrap each candidate first to ensure stable IDs before adding edges
wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors]
wrapped: list[Executor] = [self._maybe_wrap_agent(e) for e in executors] # type: ignore[arg-type]
for i in range(len(wrapped) - 1):
self.add_edge(wrapped[i], wrapped[i + 1])
return self
@@ -628,7 +991,7 @@ class WorkflowBuilder:
Args:
executor: The starting executor, which can be an Executor instance, AgentProtocol instance,
or the string ID of an executor previously added to the workflow.
or the name of a registered executor factory.
Returns:
Self: The WorkflowBuilder instance for method chaining.
@@ -652,18 +1015,19 @@ class WorkflowBuilder:
await ctx.yield_output(text)
# Set by executor instance
entry = EntryPoint(id="entry")
workflow = WorkflowBuilder().add_edge(entry, Processor(id="proc")).set_start_executor(entry).build()
# Set by executor ID string
workflow = (
WorkflowBuilder()
.add_edge(EntryPoint(id="entry"), Processor(id="proc"))
.set_start_executor("entry")
.register_executor(lambda: EntryPoint(id="entry"), name="EntryPoint")
.register_executor(lambda: Processor(id="proc"), name="Processor")
.add_edge("EntryPoint", "Processor")
.set_start_executor("EntryPoint")
.build()
)
"""
if self._start_executor is not None:
start_id = self._start_executor if isinstance(self._start_executor, str) else self._start_executor.id
logger.warning(f"Overwriting existing start executor: {start_id} for the workflow.")
if isinstance(executor, str):
self._start_executor = executor
else:
@@ -711,9 +1075,11 @@ class WorkflowBuilder:
workflow = (
WorkflowBuilder()
.set_max_iterations(500)
.add_edge(StepA(id="step_a"), StepB(id="step_b"))
.add_edge(StepB(id="step_b"), StepA(id="step_a")) # Cycle
.set_start_executor("step_a")
.register_executor(lambda: StepA(id="step_a"), name="StepA")
.register_executor(lambda: StepB(id="step_b"), name="StepB")
.add_edge("StepA", "StepB")
.add_edge("StepB", "StepA") # Cycle
.set_start_executor("StepA")
.build()
)
"""
@@ -759,8 +1125,10 @@ class WorkflowBuilder:
storage = FileCheckpointStorage("./checkpoints")
workflow = (
WorkflowBuilder()
.add_edge(ProcessorA(id="proc_a"), ProcessorB(id="proc_b"))
.set_start_executor("proc_a")
.register_executor(lambda: ProcessorA(id="proc_a"), name="ProcessorA")
.register_executor(lambda: ProcessorB(id="proc_b"), name="ProcessorB")
.add_edge("ProcessorA", "ProcessorB")
.set_start_executor("ProcessorA")
.with_checkpointing(storage)
.build()
)
@@ -771,6 +1139,70 @@ class WorkflowBuilder:
self._checkpoint_storage = checkpoint_storage
return self
def _resolve_edge_registry(self) -> tuple[Executor, list[Executor], list[EdgeGroup]]:
"""Resolve deferred edge registrations into executors and edge groups."""
if not self._start_executor:
raise ValueError("Starting executor must be set using set_start_executor before building the workflow.")
start_executor: Executor | None = None
if isinstance(self._start_executor, Executor):
start_executor = self._start_executor
executors: dict[str, Executor] = {}
deferred_edge_groups: list[EdgeGroup] = []
for name, exec_factory in self._executor_registry.items():
instance = exec_factory()
if isinstance(self._start_executor, str) and name == self._start_executor:
start_executor = instance
# All executors will get their own internal edge group for receiving system messages
deferred_edge_groups.append(InternalEdgeGroup(instance.id)) # type: ignore[call-arg]
executors[name] = instance
def _get_executor(name: str) -> Executor:
"""Helper to get executor by the registered name. Raises if not found."""
if name not in executors:
raise ValueError(f"Executor with name '{name}' has not been registered.")
return executors[name]
for registration in self._edge_registry:
match registration:
case _EdgeRegistration(source, target, condition):
source_exec: Executor = _get_executor(source)
target_exec: Executor = _get_executor(target)
deferred_edge_groups.append(SingleEdgeGroup(source_exec.id, target_exec.id, condition)) # type: ignore[call-arg]
case _FanOutEdgeRegistration(source, targets):
source_exec = _get_executor(source)
target_execs = [_get_executor(t) for t in targets]
deferred_edge_groups.append(FanOutEdgeGroup(source_exec.id, [t.id for t in target_execs])) # type: ignore[call-arg]
case _SwitchCaseEdgeGroupRegistration(source, cases):
source_exec = _get_executor(source)
cases_converted: list[SwitchCaseEdgeGroupCase | SwitchCaseEdgeGroupDefault] = []
for case in cases:
if not isinstance(case.target, str):
raise ValueError("Switch case target must be a registered executor name (str) if deferred.")
target_exec = _get_executor(case.target)
if isinstance(case, Default):
cases_converted.append(SwitchCaseEdgeGroupDefault(target_id=target_exec.id))
else:
cases_converted.append(
SwitchCaseEdgeGroupCase(condition=case.condition, target_id=target_exec.id)
)
deferred_edge_groups.append(SwitchCaseEdgeGroup(source_exec.id, cases_converted)) # type: ignore[call-arg]
case _MultiSelectionEdgeGroupRegistration(source, targets, selection_func):
source_exec = _get_executor(source)
target_execs = [_get_executor(t) for t in targets]
deferred_edge_groups.append(
FanOutEdgeGroup(source_exec.id, [t.id for t in target_execs], selection_func) # type: ignore[call-arg]
)
case _FanInEdgeRegistration(sources, target):
source_execs = [_get_executor(s) for s in sources]
target_exec = _get_executor(target)
deferred_edge_groups.append(FanInEdgeGroup([s.id for s in source_execs], target_exec.id)) # type: ignore[call-arg]
if start_executor is None:
raise ValueError("Failed to resolve starting executor from registered factories.")
return start_executor, list(executors.values()), deferred_edge_groups
def build(self) -> Workflow:
"""Build and return the constructed workflow.
@@ -802,7 +1234,12 @@ class WorkflowBuilder:
# Build and execute a workflow
workflow = WorkflowBuilder().set_start_executor(MyExecutor(id="executor")).build()
workflow = (
WorkflowBuilder()
.register_executor(lambda: MyExecutor(id="executor"), name="MyExecutor")
.set_start_executor("MyExecutor")
.build()
)
# The workflow is now immutable and ready to run
events = await workflow.run("hello")
@@ -818,16 +1255,16 @@ class WorkflowBuilder:
# Add workflow build started event
span.add_event(OtelAttr.BUILD_STARTED)
if not self._start_executor:
raise ValueError(
"Starting executor must be set using set_start_executor before building the workflow."
)
# Resolve lazy edge registrations
start_executor, deferred_executors, deferred_edge_groups = self._resolve_edge_registry()
executors = self._executors | {exe.id: exe for exe in deferred_executors}
edge_groups = self._edge_groups + deferred_edge_groups
# Perform validation before creating the workflow
validate_workflow_graph(
self._edge_groups,
self._executors,
self._start_executor,
edge_groups,
executors,
start_executor,
)
# Add validation completed event
@@ -837,9 +1274,9 @@ class WorkflowBuilder:
# Create workflow instance after validation
workflow = Workflow(
self._edge_groups,
self._executors,
self._start_executor,
edge_groups,
executors,
start_executor,
context,
self._max_iterations,
name=self._name,
@@ -208,3 +208,318 @@ def test_add_agent_duplicate_id_raises_error():
# Adding second agent with same name should raise ValueError
with pytest.raises(ValueError, match="Duplicate executor ID"):
builder.add_agent(agent2)
# Tests for new executor registration patterns
def test_register_executor_basic():
"""Test basic executor registration with lazy initialization."""
builder = WorkflowBuilder()
# Register an executor factory - ID must match the registered name
result = builder.register_executor(lambda: MockExecutor(id="TestExecutor"), name="TestExecutor")
# Verify that register returns the builder for chaining
assert result is builder
# Build workflow and verify executor is instantiated
workflow = builder.set_start_executor("TestExecutor").build()
assert "TestExecutor" in workflow.executors
assert isinstance(workflow.executors["TestExecutor"], MockExecutor)
def test_register_multiple_executors():
"""Test registering multiple executors and connecting them with edges."""
builder = WorkflowBuilder()
# Register multiple executors - IDs must match registered names
builder.register_executor(lambda: MockExecutor(id="ExecutorA"), name="ExecutorA")
builder.register_executor(lambda: MockExecutor(id="ExecutorB"), name="ExecutorB")
builder.register_executor(lambda: MockExecutor(id="ExecutorC"), name="ExecutorC")
# Build workflow with edges using registered names
workflow = (
builder.set_start_executor("ExecutorA")
.add_edge("ExecutorA", "ExecutorB")
.add_edge("ExecutorB", "ExecutorC")
.build()
)
# Verify all executors are present
assert "ExecutorA" in workflow.executors
assert "ExecutorB" in workflow.executors
assert "ExecutorC" in workflow.executors
assert workflow.start_executor_id == "ExecutorA"
def test_register_with_multiple_names():
"""Test registering the same factory function under multiple names."""
builder = WorkflowBuilder()
# Register same executor factory under multiple names
# Note: Each call creates a new instance, so IDs won't conflict
counter = {"val": 0}
def make_executor():
counter["val"] += 1
return MockExecutor(id="ExecutorA" if counter["val"] == 1 else "ExecutorB")
builder.register_executor(make_executor, name=["ExecutorA", "ExecutorB"])
# Set up workflow
workflow = builder.set_start_executor("ExecutorA").add_edge("ExecutorA", "ExecutorB").build()
# Verify both executors are present
assert "ExecutorA" in workflow.executors
assert "ExecutorB" in workflow.executors
assert workflow.start_executor_id == "ExecutorA"
def test_register_duplicate_name_raises_error():
"""Test that registering duplicate names raises an error."""
builder = WorkflowBuilder()
# Register first executor
builder.register_executor(lambda: MockExecutor(id="executor_1"), name="MyExecutor")
# Registering second executor with same name should raise ValueError
with pytest.raises(ValueError, match="already registered"):
builder.register_executor(lambda: MockExecutor(id="executor_2"), name="MyExecutor")
def test_register_agent_basic():
"""Test basic agent registration with lazy initialization."""
builder = WorkflowBuilder()
# Register an agent factory
result = builder.register_agent(
lambda: DummyAgent(id="agent_test", name="test_agent"), name="TestAgent", output_response=True
)
# Verify that register_agent returns the builder for chaining
assert result is builder
# Build workflow and verify agent is wrapped in AgentExecutor
workflow = builder.set_start_executor("TestAgent").build()
assert "test_agent" in workflow.executors
assert isinstance(workflow.executors["test_agent"], AgentExecutor)
assert workflow.executors["test_agent"]._output_response is True # type: ignore
def test_register_agent_with_thread():
"""Test registering an agent with a custom thread."""
builder = WorkflowBuilder()
custom_thread = AgentThread()
# Register agent with custom thread
builder.register_agent(
lambda: DummyAgent(id="agent_with_thread", name="threaded_agent"),
name="ThreadedAgent",
agent_thread=custom_thread,
output_response=False,
)
# Build workflow and verify agent executor configuration
workflow = builder.set_start_executor("ThreadedAgent").build()
executor = workflow.executors["threaded_agent"]
assert isinstance(executor, AgentExecutor)
assert executor.id == "threaded_agent"
assert executor._output_response is False # type: ignore
assert executor._agent_thread is custom_thread # type: ignore
def test_register_agent_duplicate_name_raises_error():
"""Test that registering agents with duplicate names raises an error."""
builder = WorkflowBuilder()
# Register first agent
builder.register_agent(lambda: DummyAgent(id="agent1", name="first"), name="MyAgent")
# Registering second agent with same name should raise ValueError
with pytest.raises(ValueError, match="already registered"):
builder.register_agent(lambda: DummyAgent(id="agent2", name="second"), name="MyAgent")
def test_register_and_add_edge_with_strings():
"""Test that registered executors can be connected using string names."""
builder = WorkflowBuilder()
# Register executors
builder.register_executor(lambda: MockExecutor(id="source"), name="Source")
builder.register_executor(lambda: MockExecutor(id="target"), name="Target")
# Add edge using string names
workflow = builder.set_start_executor("Source").add_edge("Source", "Target").build()
# Verify edge is created correctly
assert workflow.start_executor_id == "source"
assert "source" in workflow.executors
assert "target" in workflow.executors
def test_register_agent_and_add_edge_with_strings():
"""Test that registered agents can be connected using string names."""
builder = WorkflowBuilder()
# Register agents
builder.register_agent(lambda: DummyAgent(id="writer_id", name="writer"), name="Writer")
builder.register_agent(lambda: DummyAgent(id="reviewer_id", name="reviewer"), name="Reviewer")
# Add edge using string names
workflow = builder.set_start_executor("Writer").add_edge("Writer", "Reviewer").build()
# Verify edge is created correctly
assert workflow.start_executor_id == "writer"
assert "writer" in workflow.executors
assert "reviewer" in workflow.executors
assert all(isinstance(e, AgentExecutor) for e in workflow.executors.values())
def test_register_with_fan_out_edges():
"""Test using registered names with fan-out edge groups."""
builder = WorkflowBuilder()
# Register executors - IDs must match registered names
builder.register_executor(lambda: MockExecutor(id="Source"), name="Source")
builder.register_executor(lambda: MockExecutor(id="Target1"), name="Target1")
builder.register_executor(lambda: MockExecutor(id="Target2"), name="Target2")
# Add fan-out edges using registered names
workflow = builder.set_start_executor("Source").add_fan_out_edges("Source", ["Target1", "Target2"]).build()
# Verify all executors are present
assert "Source" in workflow.executors
assert "Target1" in workflow.executors
assert "Target2" in workflow.executors
def test_register_with_fan_in_edges():
"""Test using registered names with fan-in edge groups."""
builder = WorkflowBuilder()
# Register executors - IDs must match registered names
builder.register_executor(lambda: MockExecutor(id="Source1"), name="Source1")
builder.register_executor(lambda: MockExecutor(id="Source2"), name="Source2")
builder.register_executor(lambda: MockAggregator(id="Aggregator"), name="Aggregator")
# Add fan-in edges using registered names
# Both Source1 and Source2 need to be reachable, so connect Source1 to Source2
workflow = (
builder.set_start_executor("Source1")
.add_edge("Source1", "Source2")
.add_fan_in_edges(["Source1", "Source2"], "Aggregator")
.build()
)
# Verify all executors are present
assert "Source1" in workflow.executors
assert "Source2" in workflow.executors
assert "Aggregator" in workflow.executors
def test_register_with_chain():
"""Test using registered names with add_chain."""
builder = WorkflowBuilder()
# Register executors - IDs must match registered names
builder.register_executor(lambda: MockExecutor(id="Step1"), name="Step1")
builder.register_executor(lambda: MockExecutor(id="Step2"), name="Step2")
builder.register_executor(lambda: MockExecutor(id="Step3"), name="Step3")
# Add chain using registered names
workflow = builder.add_chain(["Step1", "Step2", "Step3"]).set_start_executor("Step1").build()
# Verify all executors are present
assert "Step1" in workflow.executors
assert "Step2" in workflow.executors
assert "Step3" in workflow.executors
assert workflow.start_executor_id == "Step1"
def test_register_factory_called_only_once():
"""Test that registered factory functions are called only during build."""
call_count = 0
def factory():
nonlocal call_count
call_count += 1
return MockExecutor(id="Test")
builder = WorkflowBuilder()
builder.register_executor(factory, name="Test")
# Factory should not be called yet
assert call_count == 0
# Add edge without building
builder.set_start_executor("Test")
# Factory should still not be called
assert call_count == 0
# Build workflow
workflow = builder.build()
# Factory should now be called exactly once
assert call_count == 1
assert "Test" in workflow.executors
def test_mixing_eager_and_lazy_initialization_error():
"""Test that mixing eager executor instances with lazy string names raises appropriate error."""
builder = WorkflowBuilder()
# Create an eager executor instance
eager_executor = MockExecutor(id="eager")
# Register a lazy executor
builder.register_executor(lambda: MockExecutor(id="Lazy"), name="Lazy")
# Mixing eager and lazy should raise an error during add_edge
with pytest.raises(ValueError, match="Both source and target must be either names"):
builder.add_edge(eager_executor, "Lazy")
def test_register_with_condition():
"""Test adding edges with conditions using registered names."""
builder = WorkflowBuilder()
def condition_func(msg: MockMessage) -> bool:
return msg.data > 0
# Register executors - IDs must match registered names
builder.register_executor(lambda: MockExecutor(id="Source"), name="Source")
builder.register_executor(lambda: MockExecutor(id="Target"), name="Target")
# Add edge with condition
workflow = builder.set_start_executor("Source").add_edge("Source", "Target", condition=condition_func).build()
# Verify workflow is built correctly
assert "Source" in workflow.executors
assert "Target" in workflow.executors
def test_register_agent_creates_unique_instances():
"""Test that registered agent factories create new instances on each build."""
instance_ids: list[int] = []
def agent_factory() -> DummyAgent:
agent = DummyAgent(id=f"agent_{len(instance_ids)}", name="test")
instance_ids.append(id(agent))
return agent
# Build first workflow
builder1 = WorkflowBuilder()
builder1.register_agent(agent_factory, name="Agent")
_ = builder1.set_start_executor("Agent").build()
# Build second workflow
builder2 = WorkflowBuilder()
builder2.register_agent(agent_factory, name="Agent")
_ = builder2.set_start_executor("Agent").build()
# Verify that two different agent instances were created
assert len(instance_ids) == 2
assert instance_ids[0] != instance_ids[1]
@@ -0,0 +1,104 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import (
AgentRunResponse,
ChatAgent,
Executor,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
executor,
handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
Step 4: Using Factories to Define Executors and Agents
What this example shows
- Defining custom executors using both class-based and function-based approaches.
- Registering executor and agent factories with WorkflowBuilder for lazy instantiation.
- Building a simple workflow that transforms input text through multiple steps.
Benefits of using factories
- Decouples executor and agent creation from workflow definition.
- Isolated instances are created for workflow builder build, allowing for cleaner state management
and handling parallel workflow runs.
It is recommended to use factories when defining executors and agents for production workflows.
Prerequisites
- No external services required.
"""
class UpperCase(Executor):
def __init__(self, id: str):
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."""
result = text.upper()
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
@executor(id="reverse_text_executor")
async def reverse_text(text: str, ctx: WorkflowContext[str]) -> None:
"""Reverse the input string and send it downstream."""
result = text[::-1]
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
def create_agent() -> ChatAgent:
"""Factory function to create a Writer agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=("You decode messages. Try to reconstruct the original message."),
name="decoder",
)
async def main():
"""Build and run a simple 2-step workflow using the fluent builder API."""
# Build the workflow using a fluent pattern:
# 1) register_executor(factory, name) registers an executor factory
# 2) register_agent(factory, name) registers an agent factory
# 3) add_chain([node_names]) adds a sequence of nodes to the workflow
# 4) set_start_executor(node) declares the entry point
# 5) build() finalizes and returns an immutable Workflow object
workflow = (
WorkflowBuilder()
.register_executor(lambda: UpperCase(id="upper_case_executor"), name="UpperCase")
.register_executor(lambda: reverse_text, name="ReverseText")
.register_agent(create_agent, name="DecoderAgent", output_response=True)
.add_chain(["UpperCase", "ReverseText", "DecoderAgent"])
.set_start_executor("UpperCase")
.build()
)
output: AgentRunResponse | None = None
async for event in workflow.run_stream("hello world"):
if isinstance(event, WorkflowOutputEvent) and isinstance(event.data, AgentRunResponse):
output = event.data
if output:
print(f"Decoded output: {output.text}")
else:
print("No output received.")
"""
Sample Output:
HELLO WORLD
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -1,11 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import AsyncExitStack
from typing import Any
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowOutputEvent
from agent_framework import AgentRunUpdateEvent, ChatAgent, WorkflowBuilder, WorkflowOutputEvent
from agent_framework.azure import AzureAIAgentClient
from azure.identity.aio import AzureCliCredential
@@ -29,48 +26,36 @@ Prerequisites:
"""
async def create_azure_ai_agent() -> tuple[Callable[..., Awaitable[Any]], Callable[[], Awaitable[None]]]:
"""Helper method to create a Azure AI agent factory and a close function.
def create_writer_agent(client: AzureAIAgentClient) -> ChatAgent:
return client.create_agent(
name="Writer",
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
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(AzureAIAgentClient(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
def create_reviewer_agent(client: AzureAIAgentClient) -> ChatAgent:
return client.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."
),
)
async def main() -> None:
agent, close = await create_azure_ai_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."
),
)
async with AzureCliCredential() as cred, AzureAIAgentClient(async_credential=cred) as client:
# Build the workflow by adding agents directly as edges.
# Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
workflow = (
WorkflowBuilder()
.set_start_executor(writer)
.add_edge(writer, reviewer)
.register_agent(lambda: create_writer_agent(client), name="writer")
.register_agent(lambda: create_reviewer_agent(client), name="reviewer", output_response=True)
.set_start_executor("writer")
.add_edge("writer", "reviewer")
.build()
)
@@ -89,8 +74,6 @@ async def main() -> None:
elif isinstance(event, WorkflowOutputEvent):
print("\n===== Final output =====")
print(event.data)
finally:
await close()
if __name__ == "__main__":
@@ -86,18 +86,17 @@ async def enrich_with_references(
await ctx.send_message(AgentExecutorRequest(messages=conversation))
async def main() -> None:
"""Run the workflow and stream combined updates from both agents."""
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
research_agent = chat_client.create_agent(
def create_research_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
name="research_agent",
instructions=(
"Produce a short, bullet-style briefing with two actionable ideas. Label the section as 'Initial Draft'."
),
)
final_editor_agent = chat_client.create_agent(
def create_final_editor_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
name="final_editor_agent",
instructions=(
"Use all conversation context (including external notes) to produce the final answer. "
@@ -105,11 +104,17 @@ async def main() -> None:
),
)
async def main() -> None:
"""Run the workflow and stream combined updates from both agents."""
workflow = (
WorkflowBuilder()
.set_start_executor(research_agent)
.add_edge(research_agent, enrich_with_references)
.add_edge(enrich_with_references, final_editor_agent)
.register_agent(create_research_agent, name="research_agent")
.register_agent(create_final_editor_agent, name="final_editor_agent")
.register_executor(lambda: enrich_with_references, name="enrich_with_references")
.set_start_executor("research_agent")
.add_edge("research_agent", "enrich_with_references")
.add_edge("enrich_with_references", "final_editor_agent")
.build()
)
@@ -26,35 +26,37 @@ Prerequisites:
"""
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 = AzureOpenAIChatClient(credential=AzureCliCredential())
# Define two domain specific chat agents.
writer_agent = chat_client.create_agent(
def create_writer_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer_agent",
name="writer",
)
reviewer_agent = chat_client.create_agent(
def create_reviewer_agent():
return AzureOpenAIChatClient(credential=AzureCliCredential()).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",
name="reviewer",
)
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
# Agents adapt to workflow mode: run_stream() for incremental updates, run() for complete responses.
workflow = (
WorkflowBuilder()
.set_start_executor(writer_agent)
.add_edge(writer_agent, reviewer_agent)
.register_agent(create_writer_agent, name="writer")
.register_agent(create_reviewer_agent, name="reviewer", output_response=True)
.set_start_executor("writer")
.add_edge("writer", "reviewer")
.build()
)
@@ -10,6 +10,7 @@ from agent_framework import (
AgentExecutorResponse,
AgentRunResponse,
AgentRunUpdateEvent,
ChatAgent,
ChatMessage,
Executor,
FunctionCallContent,
@@ -166,6 +167,31 @@ class Coordinator(Executor):
)
def create_writer_agent() -> ChatAgent:
"""Creates a writer agent with tools."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
name="writer_agent",
instructions=(
"You are a marketing writer. Call the available tools before drafting copy so you are precise. "
"Always call both tools once before drafting. Summarize tool outputs as bullet points, then "
"produce a 3-sentence draft."
),
tools=[fetch_product_brief, get_brand_voice_profile],
tool_choice=ToolMode.REQUIRED_ANY,
)
def create_final_editor_agent() -> ChatAgent:
"""Creates a final editor agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
def display_agent_run_update(event: AgentRunUpdateEvent, last_executor: str | None) -> None:
"""Display an AgentRunUpdateEvent in a readable format."""
printed_tool_calls: set[str] = set()
@@ -211,42 +237,25 @@ def display_agent_run_update(event: AgentRunUpdateEvent, last_executor: str | No
async def main() -> None:
"""Run the workflow and bridge human feedback between two agents."""
# Create agents with tools and instructions.
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
writer_agent = chat_client.create_agent(
name="writer_agent",
instructions=(
"You are a marketing writer. Call the available tools before drafting copy so you are precise. "
"Always call both tools once before drafting. Summarize tool outputs as bullet points, then "
"produce a 3-sentence draft."
),
tools=[fetch_product_brief, get_brand_voice_profile],
tool_choice=ToolMode.REQUIRED_ANY,
)
final_editor_agent = chat_client.create_agent(
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
coordinator = Coordinator(
id="coordinator",
writer_id="writer_agent",
final_editor_id="final_editor_agent",
)
# Build the workflow.
workflow = (
WorkflowBuilder()
.set_start_executor(writer_agent)
.add_edge(writer_agent, coordinator)
.add_edge(coordinator, writer_agent)
.add_edge(final_editor_agent, coordinator)
.add_edge(coordinator, final_editor_agent)
.register_agent(create_writer_agent, name="writer_agent")
.register_agent(create_final_editor_agent, name="final_editor_agent")
.register_executor(
lambda: Coordinator(
id="coordinator",
writer_id="writer_agent",
final_editor_id="final_editor_agent",
),
name="coordinator",
)
.set_start_executor("writer_agent")
.add_edge("writer_agent", "coordinator")
.add_edge("coordinator", "writer_agent")
.add_edge("final_editor_agent", "coordinator")
.add_edge("coordinator", "final_editor_agent")
.build()
)
@@ -41,9 +41,9 @@ class Writer(Executor):
agent: ChatAgent
def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "writer"):
def __init__(self, id: str = "writer"):
# Create a domain specific agent using your configured AzureOpenAIChatClient.
self.agent = chat_client.create_agent(
self.agent = AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
@@ -83,9 +83,9 @@ class Reviewer(Executor):
agent: ChatAgent
def __init__(self, chat_client: AzureOpenAIChatClient, id: str = "reviewer"):
def __init__(self, id: str = "reviewer"):
# Create a domain specific agent that evaluates and refines content.
self.agent = chat_client.create_agent(
self.agent = AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer."
),
@@ -105,16 +105,17 @@ class Reviewer(Executor):
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 = AzureOpenAIChatClient(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()
workflow = (
WorkflowBuilder()
.register_executor(Writer, name="writer")
.register_executor(Reviewer, name="reviewer")
.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 workflow output.
@@ -5,6 +5,7 @@ from typing import Never
from agent_framework import (
AgentExecutorResponse,
ChatAgent,
Executor,
HostedCodeInterpreterTool,
WorkflowBuilder,
@@ -70,21 +71,39 @@ class Evaluator(Executor):
await ctx.yield_output(f"Correctness: {correctness}, Consumption: {consumption}")
def create_coding_agent(client: AzureAIAgentClient) -> ChatAgent:
"""Create an AI agent with code interpretation capabilities.
This agent can generate and execute Python code to solve problems.
Args:
client: The AzureAIAgentClient used to create the agent
Returns:
A ChatAgent configured with coding instructions and tools
"""
return client.create_agent(
name="CodingAgent",
instructions=("You are a helpful assistant that can write and execute Python code to solve problems."),
tools=HostedCodeInterpreterTool(),
)
async def main():
async with (
AzureCliCredential() as credential,
AzureAIAgentClient(async_credential=credential) as chat_client,
):
# Create an agent with code interpretation capabilities
agent = chat_client.create_agent(
name="CodingAgent",
instructions=("You are a helpful assistant that can write and execute Python code to solve problems."),
tools=HostedCodeInterpreterTool(),
)
# Build a workflow: Agent generates code -> Evaluator assesses results
# The agent will be wrapped in a special agent executor which produces AgentExecutorResponse
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, Evaluator(id="evaluator")).build()
workflow = (
WorkflowBuilder()
.register_agent(lambda: create_coding_agent(chat_client), name="coding_agent")
.register_executor(lambda: Evaluator(id="evaluator"), name="evaluator")
.set_start_executor("coding_agent")
.add_edge("coding_agent", "evaluator")
.build()
)
# Execute the workflow with a specific coding task
results = await workflow.run(
@@ -81,7 +81,7 @@ class ReviewerWithHumanInTheLoop(Executor):
@response_handler
async def accept_human_review(
self,
original_request: ReviewRequest,
original_request: HumanReviewRequest,
response: ReviewResponse,
ctx: WorkflowContext[ReviewResponse],
) -> None:
@@ -97,20 +97,25 @@ async def main() -> None:
print("Starting Workflow Agent with Human-in-the-Loop Demo")
print("=" * 50)
# Create executors for the workflow.
print("Creating chat client and executors...")
mini_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
worker = Worker(id="sub-worker", chat_client=mini_chat_client)
reviewer = ReviewerWithHumanInTheLoop(worker_id=worker.id)
print("Building workflow with Worker-Reviewer cycle...")
# Build a workflow with bidirectional communication between Worker and Reviewer,
# and escalation paths for human review.
agent = (
WorkflowBuilder()
.add_edge(worker, reviewer) # Worker sends requests to Reviewer
.add_edge(reviewer, worker) # Reviewer sends feedback to Worker
.set_start_executor(worker)
.register_executor(
lambda: Worker(
id="sub-worker",
chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()),
),
name="worker",
)
.register_executor(
lambda: ReviewerWithHumanInTheLoop(worker_id="sub-worker"),
name="reviewer",
)
.add_edge("worker", "reviewer") # Worker sends requests to Reviewer
.add_edge("reviewer", "worker") # Reviewer sends feedback to Worker
.set_start_executor("worker")
.build()
.as_agent() # Convert workflow into an agent interface
)
@@ -195,19 +195,20 @@ 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(model_id="gpt-4.1-nano")
chat_client = OpenAIChatClient(model_id="gpt-4.1")
reviewer = Reviewer(id="reviewer", chat_client=chat_client)
worker = Worker(id="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)
.register_executor(
lambda: Worker(id="worker", chat_client=OpenAIChatClient(model_id="gpt-4.1-nano")),
name="worker",
)
.register_executor(
lambda: Reviewer(id="reviewer", chat_client=OpenAIChatClient(model_id="gpt-4.1")),
name="reviewer",
)
.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
)
@@ -10,7 +10,6 @@ from typing import Any, override
# `agent_framework.builtin` chat client or mock the writer executor. We keep the
# concrete import here so readers can see an end-to-end configuration.
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
ChatMessage,
@@ -173,25 +172,25 @@ class ReviewGateway(Executor):
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
"""Assemble the workflow graph used by both the initial run and resume."""
# The Azure client is created once so our agent executor can issue calls to the hosted
# model. The agent id is stable across runs which keeps checkpoints deterministic.
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
agent = chat_client.create_agent(instructions="Write concise, warm release notes that sound human and helpful.")
writer = AgentExecutor(agent, id="writer")
gateway = ReviewGateway(id="review_gateway", writer_id=writer.id)
prepare = BriefPreparer(id="prepare_brief", agent_id=writer.id)
# Wire the workflow DAG. Edges mirror the numbered steps described in the
# module docstring. Because `WorkflowBuilder` is declarative, reading these
# edges is often the quickest way to understand execution order.
workflow_builder = (
WorkflowBuilder(max_iterations=6)
.set_start_executor(prepare)
.add_edge(prepare, writer)
.add_edge(writer, gateway)
.add_edge(gateway, writer) # revisions loop
.register_agent(
lambda: AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions="Write concise, warm release notes that sound human and helpful.",
# The agent name is stable across runs which keeps checkpoints deterministic.
name="writer",
),
name="writer",
)
.register_executor(lambda: ReviewGateway(id="review_gateway", writer_id="writer"), name="review_gateway")
.register_executor(lambda: BriefPreparer(id="prepare_brief", agent_id="writer"), name="prepare_brief")
.set_start_executor("prepare_brief")
.add_edge("prepare_brief", "writer")
.add_edge("writer", "review_gateway")
.add_edge("review_gateway", "writer") # revisions loop
.with_checkpointing(checkpoint_storage=checkpoint_storage)
)
@@ -99,16 +99,14 @@ class WorkerExecutor(Executor):
async def main():
# Create workflow executors
start_executor = StartExecutor(id="start")
worker_executor = WorkerExecutor(id="worker")
# Build workflow with checkpointing enabled
workflow_builder = (
WorkflowBuilder()
.set_start_executor(start_executor)
.add_edge(start_executor, worker_executor)
.add_edge(worker_executor, worker_executor) # Self-loop for iterative processing
.register_executor(lambda: StartExecutor(id="start"), name="start")
.register_executor(lambda: WorkerExecutor(id="worker"), name="worker")
.set_start_executor("start")
.add_edge("start", "worker")
.add_edge("worker", "worker") # Self-loop for iterative processing
)
checkpoint_storage = InMemoryCheckpointStorage()
workflow_builder = workflow_builder.with_checkpointing(checkpoint_storage=checkpoint_storage)
@@ -292,16 +292,16 @@ class LaunchCoordinator(Executor):
def build_sub_workflow() -> WorkflowExecutor:
writer = DraftWriter()
router = DraftReviewRouter()
finaliser = DraftFinaliser()
"""Assemble the sub-workflow used by the parent workflow executor."""
sub_workflow = (
WorkflowBuilder()
.set_start_executor(writer)
.add_edge(writer, router)
.add_edge(router, finaliser)
.add_edge(finaliser, writer) # permits revision loops
.register_executor(DraftWriter, name="writer")
.register_executor(DraftReviewRouter, name="router")
.register_executor(DraftFinaliser, name="finaliser")
.set_start_executor("writer")
.add_edge("writer", "router")
.add_edge("router", "finaliser")
.add_edge("finaliser", "writer") # permits revision loops
.build()
)
@@ -309,14 +309,14 @@ def build_sub_workflow() -> WorkflowExecutor:
def build_parent_workflow(storage: FileCheckpointStorage) -> Workflow:
coordinator = LaunchCoordinator()
sub_executor = build_sub_workflow()
"""Assemble the parent workflow that embeds the sub-workflow."""
return (
WorkflowBuilder()
.set_start_executor(coordinator)
.add_edge(coordinator, sub_executor)
.add_edge(sub_executor, coordinator)
.register_executor(LaunchCoordinator, name="coordinator")
.register_executor(build_sub_workflow, name="sub_executor")
.set_start_executor("coordinator")
.add_edge("coordinator", "sub_executor")
.add_edge("sub_executor", "coordinator")
.with_checkpointing(storage)
.build()
)
@@ -8,7 +8,6 @@ from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowExecutor,
handler,
)
@@ -46,13 +45,6 @@ class TextProcessingResult:
char_count: int
class AllTasksCompleted(WorkflowEvent):
"""Event triggered when all processing tasks are complete."""
def __init__(self, results: list[TextProcessingResult]):
super().__init__(results)
# Sub-workflow executor
class TextProcessor(Executor):
"""Processes text strings - counts words and characters."""
@@ -113,7 +105,11 @@ class TextProcessingOrchestrator(Executor):
await ctx.send_message(request, target_id="text_processor_workflow")
@handler
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext) -> None:
async def collect_result(
self,
result: TextProcessingResult,
ctx: WorkflowContext[Never, list[TextProcessingResult]],
) -> None:
"""Collect results from sub-workflows."""
print(f"📥 Collected result from {result.task_id}")
self.results.append(result)
@@ -121,48 +117,54 @@ class TextProcessingOrchestrator(Executor):
# Check if all results are collected
if len(self.results) == self.expected_count:
print("\n🎉 All tasks completed!")
await ctx.add_event(AllTasksCompleted(self.results))
await ctx.yield_output(self.results)
def get_summary(self) -> dict[str, Any]:
"""Get a summary of all processing results."""
total_words = sum(result.word_count for result in self.results)
total_chars = sum(result.char_count for result in self.results)
avg_words = total_words / len(self.results) if self.results else 0
avg_chars = total_chars / len(self.results) if self.results else 0
return {
"total_texts": len(self.results),
"total_words": total_words,
"total_characters": total_chars,
"average_words_per_text": round(avg_words, 2),
"average_characters_per_text": round(avg_chars, 2),
}
def get_result_summary(results: list[TextProcessingResult]) -> dict[str, Any]:
"""Get a summary of all processing results."""
total_words = sum(result.word_count for result in results)
total_chars = sum(result.char_count for result in results)
avg_words = total_words / len(results) if results else 0
avg_chars = total_chars / len(results) if results else 0
return {
"total_texts": len(results),
"total_words": total_words,
"total_characters": total_chars,
"average_words_per_text": round(avg_words, 2),
"average_characters_per_text": round(avg_chars, 2),
}
def create_sub_workflow() -> WorkflowExecutor:
"""Create the text processing sub-workflow."""
print("🚀 Setting up sub-workflow...")
processing_workflow = (
WorkflowBuilder()
.register_executor(TextProcessor, name="text_processor")
.set_start_executor("text_processor")
.build()
)
return WorkflowExecutor(processing_workflow, id="text_processor_workflow")
async def main():
"""Main function to run the basic sub-workflow example."""
print("🚀 Setting up sub-workflow...")
# Step 1: Create the text processing sub-workflow
text_processor = TextProcessor()
processing_workflow = WorkflowBuilder().set_start_executor(text_processor).build()
print("🔧 Setting up parent workflow...")
# Step 2: Create the parent workflow
orchestrator = TextProcessingOrchestrator()
workflow_executor = WorkflowExecutor(processing_workflow, id="text_processor_workflow")
# Step 1: Create the parent workflow
main_workflow = (
WorkflowBuilder()
.set_start_executor(orchestrator)
.add_edge(orchestrator, workflow_executor)
.add_edge(workflow_executor, orchestrator)
.register_executor(TextProcessingOrchestrator, name="text_orchestrator")
.register_executor(create_sub_workflow, name="text_processor_workflow")
.set_start_executor("text_orchestrator")
.add_edge("text_orchestrator", "text_processor_workflow")
.add_edge("text_processor_workflow", "text_orchestrator")
.build()
)
# Step 3: Test data - various text strings
# Step 2: Test data - various text strings
test_texts = [
"Hello world! This is a simple test.",
"Python is a powerful programming language used for many applications.",
@@ -175,15 +177,17 @@ async def main():
print(f"\n🧪 Testing with {len(test_texts)} text strings")
print("=" * 60)
# Step 4: Run the workflow
await main_workflow.run(test_texts)
# Step 3: Run the workflow
result = await main_workflow.run(test_texts)
# Step 5: Display results
# Step 4: Display results
print("\n📊 Processing Results:")
print("=" * 60)
# Sort results by task_id for consistent display
sorted_results = sorted(orchestrator.results, key=lambda r: r.task_id)
task_results = result.get_outputs()
assert len(task_results) == 1
sorted_results = sorted(task_results[0], key=lambda r: r.task_id)
for result in sorted_results:
preview = result.text[:30] + "..." if len(result.text) > 30 else result.text
@@ -191,7 +195,7 @@ async def main():
print(f"{result.task_id}: '{preview}' -> {result.word_count} words, {result.char_count} chars")
# Step 6: Display summary
summary = orchestrator.get_summary()
summary = get_result_summary(sorted_results)
print("\n📈 Summary:")
print("=" * 60)
print(f"📄 Total texts processed: {summary['total_texts']}")
@@ -169,19 +169,18 @@ def build_resource_request_distribution_workflow() -> Workflow:
elif len(self._responses) > self._request_count:
raise ValueError("Received more responses than expected")
orchestrator = RequestDistribution("orchestrator")
resource_requester = ResourceRequester("resource_requester")
policy_checker = PolicyChecker("policy_checker")
result_collector = ResultCollector("result_collector")
return (
WorkflowBuilder()
.set_start_executor(orchestrator)
.add_edge(orchestrator, resource_requester)
.add_edge(orchestrator, policy_checker)
.add_edge(resource_requester, result_collector)
.add_edge(policy_checker, result_collector)
.add_edge(orchestrator, result_collector) # For request count
.register_executor(lambda: RequestDistribution("orchestrator"), name="orchestrator")
.register_executor(lambda: ResourceRequester("resource_requester"), name="resource_requester")
.register_executor(lambda: PolicyChecker("policy_checker"), name="policy_checker")
.register_executor(lambda: ResultCollector("result_collector"), name="result_collector")
.set_start_executor("orchestrator")
.add_edge("orchestrator", "resource_requester")
.add_edge("orchestrator", "policy_checker")
.add_edge("resource_requester", "result_collector")
.add_edge("policy_checker", "result_collector")
.add_edge("orchestrator", "result_collector") # For request count
.build()
)
@@ -288,29 +287,27 @@ class PolicyEngine(Executor):
async def main() -> None:
# Create executors in the main workflow
sub_workflow = build_resource_request_distribution_workflow()
resource_allocator = ResourceAllocator("resource_allocator")
policy_engine = PolicyEngine("policy_engine")
# Create the WorkflowExecutor for the sub-workflow
# Setting allow_direct_output=True to let the sub-workflow output directly.
# This is because the sub-workflow is the both the entry point and the exit
# point of the main workflow.
sub_workflow_executor = WorkflowExecutor(
sub_workflow,
"sub_workflow_executor",
allow_direct_output=True,
)
# Build the main workflow
main_workflow = (
WorkflowBuilder()
.set_start_executor(sub_workflow_executor)
.add_edge(sub_workflow_executor, resource_allocator)
.add_edge(resource_allocator, sub_workflow_executor)
.add_edge(sub_workflow_executor, policy_engine)
.add_edge(policy_engine, sub_workflow_executor)
.register_executor(lambda: ResourceAllocator("resource_allocator"), name="resource_allocator")
.register_executor(lambda: PolicyEngine("policy_engine"), name="policy_engine")
.register_executor(
lambda: WorkflowExecutor(
build_resource_request_distribution_workflow(),
"sub_workflow_executor",
# Setting allow_direct_output=True to let the sub-workflow output directly.
# This is because the sub-workflow is the both the entry point and the exit
# point of the main workflow.
allow_direct_output=True,
),
name="sub_workflow_executor",
)
.set_start_executor("sub_workflow_executor")
.add_edge("sub_workflow_executor", "resource_allocator")
.add_edge("resource_allocator", "sub_workflow_executor")
.add_edge("sub_workflow_executor", "policy_engine")
.add_edge("policy_engine", "sub_workflow_executor")
.build()
)
@@ -154,15 +154,14 @@ def build_email_address_validation_workflow() -> Workflow:
)
# Build the workflow
sanitizer = EmailSanitizer(id="email_sanitizer")
format_validator = EmailFormatValidator(id="email_format_validator")
domain_validator = DomainValidator(id="domain_validator")
return (
WorkflowBuilder()
.set_start_executor(sanitizer)
.add_edge(sanitizer, format_validator)
.add_edge(format_validator, domain_validator)
.register_executor(lambda: EmailSanitizer(id="email_sanitizer"), name="email_sanitizer")
.register_executor(lambda: EmailFormatValidator(id="email_format_validator"), name="email_format_validator")
.register_executor(lambda: DomainValidator(id="domain_validator"), name="domain_validator")
.set_start_executor("email_sanitizer")
.add_edge("email_sanitizer", "email_format_validator")
.add_edge("email_format_validator", "domain_validator")
.build()
)
@@ -270,21 +269,22 @@ async def main() -> None:
# A list of approved domains
approved_domains = {"example.com", "company.com"}
# Create executors in the main workflow
orchestrator = SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains)
email_delivery = EmailDelivery(id="email_delivery")
# Create the sub-workflow for email address validation
validation_workflow = build_email_address_validation_workflow()
validation_workflow_executor = WorkflowExecutor(validation_workflow, id="email_validation_workflow")
# Build the main workflow
workflow = (
WorkflowBuilder()
.set_start_executor(orchestrator)
.add_edge(orchestrator, validation_workflow_executor)
.add_edge(validation_workflow_executor, orchestrator)
.add_edge(orchestrator, email_delivery)
.register_executor(
lambda: SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains),
name="smart_email_orchestrator",
)
.register_executor(lambda: EmailDelivery(id="email_delivery"), name="email_delivery")
.register_executor(
lambda: WorkflowExecutor(build_email_address_validation_workflow(), id="email_validation_workflow"),
name="email_validation_workflow",
)
.set_start_executor("smart_email_orchestrator")
.add_edge("smart_email_orchestrator", "email_validation_workflow")
.add_edge("email_validation_workflow", "smart_email_orchestrator")
.add_edge("smart_email_orchestrator", "email_delivery")
.build()
)
@@ -5,9 +5,9 @@ import os
from typing import Any
from agent_framework import ( # Core chat primitives used to build requests
AgentExecutor, # Wraps an LLM agent that can be invoked inside a workflow
AgentExecutorRequest, # Input message bundle for an AgentExecutor
AgentExecutorResponse, # Output from an AgentExecutor
AgentExecutorResponse,
ChatAgent, # Output from an AgentExecutor
ChatMessage,
Role,
WorkflowBuilder, # Fluent builder for wiring executors and edges
@@ -128,38 +128,35 @@ async def to_email_assistant_request(
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
async def main() -> None:
# Create agents
def create_spam_detector_agent() -> ChatAgent:
"""Helper to create a spam detection agent."""
# AzureCliCredential uses your current az login. This avoids embedding secrets in code.
chat_client = AzureOpenAIChatClient(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,
return AzureOpenAIChatClient(credential=AzureCliCredential()).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."
),
id="spam_detection_agent",
name="spam_detection_agent",
response_format=DetectionResult,
)
# 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,
def create_email_assistant_agent() -> ChatAgent:
"""Helper to create an email assistant agent."""
# AzureCliCredential uses your current az login. This avoids embedding secrets in code.
return AzureOpenAIChatClient(credential=AzureCliCredential()).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."
),
id="email_assistant_agent",
name="email_assistant_agent",
response_format=EmailResponse,
)
async def main() -> None:
# Build the workflow graph.
# Start at the spam detector.
# If not spam, hop to a transformer that creates a new AgentExecutorRequest,
@@ -167,13 +164,18 @@ async def main() -> None:
# If spam, go directly to the spam handler and finalize.
workflow = (
WorkflowBuilder()
.set_start_executor(spam_detection_agent)
.register_agent(create_spam_detector_agent, name="spam_detection_agent")
.register_agent(create_email_assistant_agent, name="email_assistant_agent")
.register_executor(lambda: to_email_assistant_request, name="to_email_assistant_request")
.register_executor(lambda: handle_email_response, name="send_email")
.register_executor(lambda: handle_spam_classifier_response, name="handle_spam")
.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)
.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", "send_email")
# Spam path: send to spam handler
.add_edge(spam_detection_agent, handle_spam_classifier_response, condition=get_condition(True))
.add_edge("spam_detection_agent", "handle_spam", condition=get_condition(True))
.build()
)
@@ -9,9 +9,9 @@ from typing import Literal
from uuid import uuid4
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
ChatAgent,
ChatMessage,
Role,
WorkflowBuilder,
@@ -181,40 +181,38 @@ async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[Never,
await ctx.add_event(DatabaseEvent(f"Email {analysis.email_id} saved to database."))
def create_email_analysis_agent() -> ChatAgent:
"""Creates the email analysis agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).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)."
),
name="email_analysis_agent",
response_format=AnalysisResultAgent,
)
def create_email_assistant_agent() -> ChatAgent:
"""Creates the email assistant agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=("You are an email assistant that helps users draft responses to emails with professionalism."),
name="email_assistant_agent",
response_format=EmailResponse,
)
def create_email_summary_agent() -> ChatAgent:
"""Creates the email summary agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=("You are an assistant that helps users summarize emails."),
name="email_summary_agent",
response_format=EmailSummaryModel,
)
async def main() -> None:
# Agents
chat_client = AzureOpenAIChatClient(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]
@@ -228,24 +226,39 @@ async def main() -> None:
return targets
return [handle_uncertain_id]
workflow = (
workflow_builder = (
WorkflowBuilder()
.set_start_executor(store_email)
.add_edge(store_email, email_analysis_agent)
.add_edge(email_analysis_agent, to_analysis_result)
.register_agent(create_email_analysis_agent, name="email_analysis_agent")
.register_agent(create_email_assistant_agent, name="email_assistant_agent")
.register_agent(create_email_summary_agent, name="email_summary_agent")
.register_executor(lambda: store_email, name="store_email")
.register_executor(lambda: to_analysis_result, name="to_analysis_result")
.register_executor(lambda: submit_to_email_assistant, name="submit_to_email_assistant")
.register_executor(lambda: finalize_and_send, name="finalize_and_send")
.register_executor(lambda: summarize_email, name="summarize_email")
.register_executor(lambda: merge_summary, name="merge_summary")
.register_executor(lambda: handle_spam, name="handle_spam")
.register_executor(lambda: handle_uncertain, name="handle_uncertain")
.register_executor(lambda: database_access, name="database_access")
)
workflow = (
workflow_builder.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],
"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)
.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)
.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)
.add_edge("merge_summary", "database_access")
.build()
)
@@ -61,20 +61,18 @@ class ReverseTextExecutor(Executor):
async def main() -> None:
"""Build a two step sequential workflow and run it with streaming to observe events."""
# Step 1: Create executor instances.
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
# Step 2: Build the workflow graph.
# Step 1: 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)
.register_executor(lambda: UpperCaseExecutor(id="upper_case_executor"), name="upper_case_executor")
.register_executor(lambda: ReverseTextExecutor(id="reverse_text_executor"), name="reverse_text_executor")
.add_edge("upper_case_executor", "reverse_text_executor")
.set_start_executor("upper_case_executor")
.build()
)
# Step 3: Stream events for a single input.
# Step 2: Stream events for a single input.
# The stream will include executor invoke and completion events, plus workflow outputs.
outputs: list[str] = []
async for event in workflow.run_stream("hello world"):
@@ -52,11 +52,18 @@ async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
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.
# Step 1: 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()
workflow = (
WorkflowBuilder()
.register_executor(lambda: to_upper_case, name="upper_case_executor")
.register_executor(lambda: reverse_text, name="reverse_text_executor")
.add_edge("upper_case_executor", "reverse_text_executor")
.set_start_executor("upper_case_executor")
.build()
)
# Step 3: Run the workflow and stream events in real time.
# Step 2: Run the workflow and stream events in real time.
async for event in workflow.run_stream("hello world"):
# You will see executor invoke and completion events as the workflow progresses.
print(f"Event: {event}")
@@ -4,16 +4,15 @@ import asyncio
from enum import Enum
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
ChatAgent,
ChatMessage,
Executor,
ExecutorCompletedEvent,
Role,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
handler,
)
from agent_framework.azure import AzureOpenAIChatClient
@@ -49,9 +48,9 @@ class NumberSignal(Enum):
class GuessNumberExecutor(Executor):
"""An executor that guesses a number."""
def __init__(self, bound: tuple[int, int], id: str | None = None):
def __init__(self, bound: tuple[int, int], id: str):
"""Initialize the executor with a target number."""
super().__init__(id=id or "guess_number")
super().__init__(id=id)
self._lower = bound[0]
self._upper = bound[1]
@@ -116,43 +115,37 @@ class ParseJudgeResponse(Executor):
await ctx.send_message(NumberSignal.BELOW)
def create_judge_agent() -> ChatAgent:
"""Create a judge agent that evaluates guesses."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=("You strictly respond with one of: MATCHED, ABOVE, BELOW based on the given target and guess."),
name="judge_agent",
)
async def main():
"""Main function to run the workflow."""
# Step 1: Create the executors.
guess_number_executor = GuessNumberExecutor((1, 100))
# Agent judge setup
chat_client = AzureOpenAIChatClient(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.
# Step 1: Build the workflow with the defined edges.
# This time we are creating a loop in the workflow.
workflow = (
WorkflowBuilder()
.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)
.register_executor(lambda: GuessNumberExecutor((1, 100), "guess_number"), name="guess_number")
.register_agent(create_judge_agent, name="judge_agent")
.register_executor(lambda: SubmitToJudgeAgent(judge_agent_id="judge_agent", target=30), name="submit_judge")
.register_executor(lambda: ParseJudgeResponse(id="parse_judge"), name="parse_judge")
.add_edge("guess_number", "submit_judge")
.add_edge("submit_judge", "judge_agent")
.add_edge("judge_agent", "parse_judge")
.add_edge("parse_judge", "guess_number")
.set_start_executor("guess_number")
.build()
)
# Step 3: Run the workflow and print the events.
# Step 2: Run the workflow and print the events.
iterations = 0
async for event in workflow.run_stream(NumberSignal.INIT):
if isinstance(event, ExecutorCompletedEvent) and event.executor_id == guess_number_executor.id:
if isinstance(event, ExecutorCompletedEvent) and event.executor_id == "guess_number":
iterations += 1
elif isinstance(event, WorkflowOutputEvent):
print(f"Final result: {event.data}")
print(f"Event: {event}")
# This is essentially a binary search, so the number of iterations should be logarithmic.
@@ -7,10 +7,10 @@ from typing import Any, Literal
from uuid import uuid4
from agent_framework import ( # Core chat primitives used to form LLM requests
AgentExecutor, # Wraps an agent so it can run inside a workflow
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorResponse, # Result returned by an AgentExecutor
Case, # Case entry for a switch-case edge group
Case,
ChatAgent, # Case entry for a switch-case edge group
ChatMessage,
Default, # Default branch when no cases match
Role,
@@ -152,51 +152,56 @@ async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[Neve
raise RuntimeError("This executor should only handle Uncertain messages.")
def create_spam_detection_agent() -> ChatAgent:
"""Create and return the spam detection agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).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)."
),
name="spam_detection_agent",
response_format=DetectionResultAgent,
)
def create_email_assistant_agent() -> ChatAgent:
"""Create and return the email assistant agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=("You are an email assistant that helps users draft responses to emails with professionalism."),
name="email_assistant_agent",
response_format=EmailResponse,
)
async def main():
"""Main function to run the workflow."""
chat_client = AzureOpenAIChatClient(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)
.register_agent(create_spam_detection_agent, name="spam_detection_agent")
.register_agent(create_email_assistant_agent, name="email_assistant_agent")
.register_executor(lambda: store_email, name="store_email")
.register_executor(lambda: to_detection_result, name="to_detection_result")
.register_executor(lambda: submit_to_email_assistant, name="submit_to_email_assistant")
.register_executor(lambda: finalize_and_send, name="finalize_and_send")
.register_executor(lambda: handle_spam, name="handle_spam")
.register_executor(lambda: handle_uncertain, name="handle_uncertain")
.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,
"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),
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)
.add_edge("submit_to_email_assistant", "email_assistant_agent")
.add_edge("email_assistant_agent", "finalize_and_send")
.build()
)
@@ -7,6 +7,7 @@ from typing import Annotated, Never
from agent_framework import (
AgentExecutorResponse,
ChatAgent,
ChatMessage,
Executor,
FunctionApprovalRequestContent,
@@ -210,10 +211,9 @@ async def conclude_workflow(
await ctx.yield_output(email_response.agent_run_response.text)
async def main() -> None:
# Create the agent and executors
chat_client = OpenAIChatClient()
email_writer = chat_client.create_agent(
def create_email_writer_agent() -> ChatAgent:
"""Create the Email Writer agent with tools that require approval."""
return OpenAIChatClient().create_agent(
name="Email Writer",
instructions=("You are an excellent email assistant. You respond to incoming emails."),
# tools with `approval_mode="always_require"` will trigger approval requests
@@ -225,14 +225,21 @@ async def main() -> None:
get_my_information,
],
)
email_preprocessor = EmailPreprocessor(special_email_addresses={"mike@contoso.com"})
async def main() -> None:
# Build the workflow
workflow = (
WorkflowBuilder()
.set_start_executor(email_preprocessor)
.add_edge(email_preprocessor, email_writer)
.add_edge(email_writer, conclude_workflow)
.register_agent(create_email_writer_agent, name="email_writer")
.register_executor(
lambda: EmailPreprocessor(special_email_addresses={"mike@contoso.com"}),
name="email_preprocessor",
)
.register_executor(lambda: conclude_workflow, name="conclude_workflow")
.set_start_executor("email_preprocessor")
.add_edge("email_preprocessor", "email_writer")
.add_edge("email_writer", "conclude_workflow")
.build()
)
@@ -5,7 +5,8 @@ from dataclasses import dataclass
from agent_framework import (
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorResponse, # Result returned by an AgentExecutor
AgentExecutorResponse,
ChatAgent, # Result returned by an AgentExecutor
ChatMessage, # Chat message structure
Executor, # Base class for workflow executors
RequestInfoEvent, # Event emitted when human input is requested
@@ -142,11 +143,9 @@ class TurnManager(Executor):
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 = AzureOpenAIChatClient(credential=AzureCliCredential())
agent = chat_client.create_agent(
def create_guessing_agent() -> ChatAgent:
"""Create the guessing agent with instructions to guess a number between 1 and 10."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
name="GuessingAgent",
instructions=(
"You guess a number between 1 and 10. "
@@ -154,19 +153,22 @@ async def main() -> None:
'You MUST return ONLY a JSON object exactly matching this schema: {"guess": <integer 1..10>}. '
"No explanations or additional text."
),
# Structured output enforced via Pydantic model.
# response_format enforces that the model produces JSON compatible with GuessOutput.
response_format=GuessOutput,
)
# TurnManager coordinates and gathers human replies while AgentExecutor runs the model.
turn_manager = TurnManager(id="turn_manager")
async def main() -> None:
"""Run the human-in-the-loop guessing game workflow."""
# Build a simple loop: TurnManager <-> AgentExecutor.
workflow = (
WorkflowBuilder()
.set_start_executor(turn_manager)
.add_edge(turn_manager, agent) # Ask agent to make/adjust a guess
.add_edge(agent, turn_manager) # Agent's response comes back to coordinator
.register_agent(create_guessing_agent, name="guessing_agent")
.register_executor(lambda: TurnManager(id="turn_manager"), name="turn_manager")
.set_start_executor("turn_manager")
.add_edge("turn_manager", "guessing_agent") # Ask agent to make/adjust a guess
.add_edge("guessing_agent", "turn_manager") # Agent's response comes back to coordinator
).build()
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
@@ -72,22 +72,20 @@ class Aggregator(Executor):
async def main() -> None:
# 1) Create the executors
dispatcher = Dispatcher(id="dispatcher")
average = Average(id="average")
summation = Sum(id="summation")
aggregator = Aggregator(id="aggregator")
# 2) Build a simple fan out and fan in workflow
# 1) Build a simple fan out and fan in workflow
workflow = (
WorkflowBuilder()
.set_start_executor(dispatcher)
.add_fan_out_edges(dispatcher, [average, summation])
.add_fan_in_edges([average, summation], aggregator)
.register_executor(lambda: Dispatcher(id="dispatcher"), name="dispatcher")
.register_executor(lambda: Average(id="average"), name="average")
.register_executor(lambda: Sum(id="summation"), name="summation")
.register_executor(lambda: Aggregator(id="aggregator"), name="aggregator")
.set_start_executor("dispatcher")
.add_fan_out_edges("dispatcher", ["average", "summation"])
.add_fan_in_edges(["average", "summation"], "aggregator")
.build()
)
# 3) Run the workflow
# 2) Run the workflow
output: list[int | float] | None = None
async for event in workflow.run_stream([random.randint(1, 100) for _ in range(10)]):
if isinstance(event, WorkflowOutputEvent):
@@ -4,10 +4,10 @@ import asyncio
from dataclasses import dataclass
from agent_framework import ( # Core chat primitives to build LLM requests
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
AgentRunEvent,
ChatAgent, # Tracing event for agent execution steps
ChatMessage, # Chat message structure
Executor, # Base class for custom Python executors
Role, # Enum of chat roles (user, assistant, system)
@@ -16,7 +16,7 @@ from agent_framework import ( # Core chat primitives to build LLM requests
WorkflowOutputEvent, # Event emitted when workflow yields output
handler, # Decorator to mark an Executor method as invokable
)
from agent_framework.azure import AzureOpenAIChatClient # Client wrapper for Azure OpenAI chat models
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from typing_extensions import Never
@@ -42,20 +42,11 @@ Prerequisites:
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=id or "dispatch_to_experts")
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,
)
await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True))
@dataclass
@@ -70,10 +61,6 @@ class AggregatedInsights:
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=id or "aggregate_insights")
self._expert_ids = expert_ids
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
# Map responses to text by executor id for a simple, predictable demo.
@@ -104,49 +91,51 @@ class AggregateInsights(Executor):
await ctx.yield_output(consolidated)
def create_researcher_agent() -> ChatAgent:
"""Creates a research domain expert agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name="researcher",
)
def create_marketer_agent() -> ChatAgent:
"""Creates a marketing domain expert agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name="marketer",
)
def create_legal_agent() -> ChatAgent:
"""Creates a legal/compliance domain expert agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name="legal",
)
async def main() -> None:
# 1) Create agent executors for domain experts
chat_client = AzureOpenAIChatClient(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
# 1) 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
.register_agent(create_researcher_agent, name="researcher")
.register_agent(create_marketer_agent, name="marketer")
.register_agent(create_legal_agent, name="legal")
.register_executor(lambda: DispatchToExperts(id="dispatcher"), name="dispatcher")
.register_executor(lambda: AggregateInsights(id="aggregator"), name="aggregator")
.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()
)
@@ -259,27 +259,50 @@ class CompletionExecutor(Executor):
async def main():
"""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(
[map_operation.id for map_operation in map_operations],
id="split_data_executor",
# Step 1: Create the workflow builder and register executors.
workflow_builder = (
WorkflowBuilder()
.register_executor(lambda: Map(id="map_executor_0"), name="map_executor_0")
.register_executor(lambda: Map(id="map_executor_1"), name="map_executor_1")
.register_executor(lambda: Map(id="map_executor_2"), name="map_executor_2")
.register_executor(
lambda: Split(["map_executor_0", "map_executor_1", "map_executor_2"], id="split_data_executor"),
name="split_data_executor",
)
.register_executor(lambda: Reduce(id="reduce_executor_0"), name="reduce_executor_0")
.register_executor(lambda: Reduce(id="reduce_executor_1"), name="reduce_executor_1")
.register_executor(lambda: Reduce(id="reduce_executor_2"), name="reduce_executor_2")
.register_executor(lambda: Reduce(id="reduce_executor_3"), name="reduce_executor_3")
.register_executor(
lambda: Shuffle(
["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"],
id="shuffle_executor",
),
name="shuffle_executor",
)
.register_executor(lambda: CompletionExecutor(id="completion_executor"), name="completion_executor")
)
reduce_operations = [Reduce(id=f"reduce_executor_{i}") for i in range(4)]
shuffle_operation = Shuffle(
[reduce_operation.id for reduce_operation in reduce_operations],
id="shuffle_executor",
)
completion_executor = CompletionExecutor(id="completion_executor")
# 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) # 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
workflow_builder.set_start_executor("split_data_executor")
.add_fan_out_edges(
"split_data_executor",
["map_executor_0", "map_executor_1", "map_executor_2"],
) # Split -> many mappers
.add_fan_in_edges(
["map_executor_0", "map_executor_1", "map_executor_2"],
"shuffle_executor",
) # All mappers -> shuffle
.add_fan_out_edges(
"shuffle_executor",
["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"],
) # Shuffle -> many reducers
.add_fan_in_edges(
["reduce_executor_0", "reduce_executor_1", "reduce_executor_2", "reduce_executor_3"],
"completion_executor",
) # All reducers -> completion
.build()
)
@@ -1,14 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from uuid import uuid4
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
ChatAgent,
ChatMessage,
Role,
WorkflowBuilder,
@@ -154,28 +155,35 @@ async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, st
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 = AzureOpenAIChatClient(credential=AzureCliCredential())
spam_detection_agent = chat_client.create_agent(
def create_spam_detection_agent() -> ChatAgent:
"""Creates a spam detection agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).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,
# response_format enforces structured JSON from each agent.
name="spam_detection_agent",
)
email_assistant_agent = chat_client.create_agent(
def create_email_assistant_agent() -> ChatAgent:
"""Creates an email assistant agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).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 enforces structured JSON from each agent.
response_format=EmailResponse,
name="email_assistant_agent",
)
async def main() -> None:
"""Build and run the shared state with agents and conditional routing workflow."""
# Build the workflow graph with conditional edges.
# Flow:
# store_email -> spam_detection_agent -> to_detection_result -> branch:
@@ -183,25 +191,28 @@ async def main() -> None:
# 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)
.register_agent(create_spam_detection_agent, name="spam_detection_agent")
.register_agent(create_email_assistant_agent, name="email_assistant_agent")
.register_executor(lambda: store_email, name="store_email")
.register_executor(lambda: to_detection_result, name="to_detection_result")
.register_executor(lambda: submit_to_email_assistant, name="submit_to_email_assistant")
.register_executor(lambda: finalize_and_send, name="finalize_and_send")
.register_executor(lambda: handle_spam, name="handle_spam")
.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.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()
current_file = Path(__file__)
resources_path = current_file.parent.parent / "resources" / "spam.txt"
if resources_path.exists():
email = resources_path.read_text(encoding="utf-8")
else:
print("Unable to find resource file, using default text.")
email = "You are a WINNER! Click here for a free lottery offer!!!"
@@ -4,10 +4,10 @@ import asyncio
from dataclasses import dataclass
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunEvent,
ChatAgent,
ChatMessage,
Executor,
Role,
@@ -39,19 +39,11 @@ Prerequisites:
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=id or "dispatch_to_experts")
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,
)
await ctx.send_message(AgentExecutorRequest(messages=[initial_message], should_respond=True))
@dataclass
@@ -66,10 +58,6 @@ class AggregatedInsights:
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=id or "aggregate_insights")
self._expert_ids = expert_ids
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
# Map responses to text by executor id for a simple, predictable demo.
@@ -100,53 +88,57 @@ class AggregateInsights(Executor):
await ctx.yield_output(consolidated)
def create_researcher_agent() -> ChatAgent:
"""Creates a research domain expert agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name="researcher",
)
def create_marketer_agent() -> ChatAgent:
"""Creates a marketing domain expert agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name="marketer",
)
def create_legal_agent() -> ChatAgent:
"""Creates a legal domain expert agent."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).create_agent(
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name="legal",
)
async def main() -> None:
# 1) Create agent executors for domain experts
chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
"""Build and run the concurrent workflow with visualization."""
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
# 1) 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)
.register_agent(create_researcher_agent, name="researcher")
.register_agent(create_marketer_agent, name="marketer")
.register_agent(create_legal_agent, name="legal")
.register_executor(lambda: DispatchToExperts(id="dispatcher"), name="dispatcher")
.register_executor(lambda: AggregateInsights(id="aggregator"), name="aggregator")
.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
# 1.5) Generate workflow visualization
print("Generating workflow visualization...")
viz = WorkflowViz(workflow)
# Print out the mermaid string.
@@ -162,7 +154,7 @@ async def main() -> None:
svg_file = viz.export(format="svg")
print(f"SVG file saved to: {svg_file}")
# 3) Run with a single prompt
# 2) Run with a single prompt
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.