mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
6809510413
commit
f2ed5b55f6
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user