[BREAKING] Python: Remove workflow register factory methods. Update tests and samples (#3781)

* Remove workflow register factory methods. Update tests and samples

* Address Copilot feedback
This commit is contained in:
Evan Mattson
2026-02-10 22:16:17 +00:00
committed by GitHub
parent f407f726a7
commit a4c9e43afb
46 changed files with 650 additions and 3660 deletions
@@ -29,8 +29,7 @@ parallel workflow with:
- a default aggregator that combines all agent conversations and completes the workflow
Notes:
- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`,
or as factories returning SupportsAgentRun or Executor via `participant_factories=[...]`.
- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`.
- A custom aggregator can be provided as:
- an Executor instance (it should handle list[AgentExecutorResponse],
yield output), or
@@ -187,11 +186,8 @@ class ConcurrentBuilder:
r"""High-level builder for concurrent agent workflows.
- `participants=[...]` accepts a list of SupportsAgentRun (recommended) or Executor.
- `participant_factories=[...]` accepts a list of factories for SupportsAgentRun (recommended)
or Executor factories
- `build()` wires: dispatcher -> fan-out -> participants -> fan-in -> aggregator.
- `with_aggregator(...)` overrides the default aggregator with an Executor or callback.
- `register_aggregator(...)` accepts a factory for an Executor as custom aggregator.
Usage:
@@ -202,9 +198,6 @@ class ConcurrentBuilder:
# Minimal: use default aggregator (returns list[ChatMessage])
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).build()
# With agent factories
workflow = ConcurrentBuilder(participant_factories=[create_agent1, create_agent2, create_agent3]).build()
# Custom aggregator via callback (sync or async). The callback receives
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
@@ -215,20 +208,6 @@ class ConcurrentBuilder:
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).with_aggregator(summarize).build()
# Custom aggregator via a factory
class MyAggregator(Executor):
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(" | ".join(r.agent_response.messages[-1].text for r in results))
workflow = (
ConcurrentBuilder(participant_factories=[create_agent1, create_agent2, create_agent3])
.register_aggregator(lambda: MyAggregator(id="my_aggregator"))
.build()
)
# Enable checkpoint persistence so runs can resume
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3], checkpoint_storage=storage).build()
@@ -239,58 +218,29 @@ class ConcurrentBuilder:
def __init__(
self,
*,
participants: Sequence[SupportsAgentRun | Executor] | None = None,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None,
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
intermediate_outputs: bool = False,
) -> None:
"""Initialize the ConcurrentBuilder.
Args:
participants: Optional sequence of agent or executor instances to run in parallel.
participant_factories: Optional sequence of callables returning agent or executor instances.
participants: Sequence of agent or executor instances to run in parallel.
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
intermediate_outputs: If True, enables intermediate outputs from agent participants
before aggregation.
"""
self._participants: list[SupportsAgentRun | Executor] = []
self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = []
self._aggregator: Executor | None = None
self._aggregator_factory: Callable[[], Executor] | None = None
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._intermediate_outputs: bool = intermediate_outputs
if participants is None and participant_factories is None:
raise ValueError("Either participants or participant_factories must be provided.")
if participant_factories is not None:
self._set_participant_factories(participant_factories)
if participants is not None:
self._set_participants(participants)
def _set_participant_factories(
self,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]],
) -> None:
"""Set participant factories (internal)."""
if self._participants:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participant_factories:
raise ValueError("participant_factories already set.")
if not participant_factories:
raise ValueError("participant_factories cannot be empty")
self._participant_factories = list(participant_factories)
self._set_participants(participants)
def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None:
"""Set participants (internal)."""
if self._participant_factories:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participants:
raise ValueError("participants already set.")
@@ -315,39 +265,6 @@ class ConcurrentBuilder:
self._participants = list(participants)
def register_aggregator(self, aggregator_factory: Callable[[], Executor]) -> "ConcurrentBuilder":
r"""Define a custom aggregator for this concurrent workflow.
Accepts a factory (callable) that returns an Executor instance. The executor
should handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`.
Args:
aggregator_factory: Callable that returns an Executor instance
Example:
.. code-block:: python
class MyCustomExecutor(Executor): ...
wf = (
ConcurrentBuilder()
.register_participants([create_researcher, create_marketer, create_legal])
.register_aggregator(lambda: MyCustomExecutor(id="my_aggregator"))
.build()
)
"""
if self._aggregator is not None:
raise ValueError(
"Cannot mix .with_aggregator(...) and .register_aggregator(...) in the same builder instance."
)
if self._aggregator_factory is not None:
raise ValueError("register_aggregator() has already been called on this builder instance.")
self._aggregator_factory = aggregator_factory
return self
def with_aggregator(
self,
aggregator: Executor
@@ -393,11 +310,6 @@ class ConcurrentBuilder:
wf = ConcurrentBuilder(participants=[a1, a2, a3]).with_aggregator(summarize).build()
"""
if self._aggregator_factory is not None:
raise ValueError(
"Cannot mix .with_aggregator(...) and .register_aggregator(...) in the same builder instance."
)
if self._aggregator is not None:
raise ValueError("with_aggregator() has already been called on this builder instance.")
@@ -445,19 +357,10 @@ class ConcurrentBuilder:
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants and not self._participant_factories:
raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.")
# We don't need to check if both are set since that is handled in the respective methods
if not self._participants:
raise ValueError("No participants provided. Pass participants to the constructor.")
participants: list[Executor | SupportsAgentRun] = []
if self._participant_factories:
# Resolve the participant factories now. This doesn't break the factory pattern
# since the Sequential builder still creates new instances per workflow build.
for factory in self._participant_factories:
p = factory()
participants.append(p)
else:
participants = self._participants
participants: list[Executor | SupportsAgentRun] = self._participants
executors: list[Executor] = []
for p in participants:
@@ -502,15 +405,7 @@ class ConcurrentBuilder:
"""
# Internal nodes
dispatcher = _DispatchToAllParticipants(id="dispatcher")
aggregator = (
self._aggregator
if self._aggregator is not None
else (
self._aggregator_factory()
if self._aggregator_factory is not None
else _AggregateAgentConversations(id="aggregator")
)
)
aggregator = self._aggregator if self._aggregator is not None else _AggregateAgentConversations(id="aggregator")
# Resolve participants and participant factories to executors
participants: list[Executor] = self._resolve_participants()
@@ -526,8 +526,7 @@ class GroupChatBuilder:
def __init__(
self,
*,
participants: Sequence[SupportsAgentRun | Executor] | None = None,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None,
participants: Sequence[SupportsAgentRun | Executor],
# Orchestrator config (exactly one required)
orchestrator_agent: ChatAgent | Callable[[], ChatAgent] | None = None,
orchestrator: BaseGroupChatOrchestrator | Callable[[], BaseGroupChatOrchestrator] | None = None,
@@ -542,8 +541,7 @@ class GroupChatBuilder:
"""Initialize the GroupChatBuilder.
Args:
participants: Optional sequence of agent or executor instances for the group chat.
participant_factories: Optional sequence of callables returning agent or executor instances.
participants: Sequence of agent or executor instances for the group chat.
orchestrator_agent: An instance of ChatAgent or a callable that produces one to manage the group chat.
orchestrator: An instance of BaseGroupChatOrchestrator or a callable that produces one to manage the
group chat.
@@ -557,7 +555,6 @@ class GroupChatBuilder:
intermediate_outputs: If True, enables intermediate outputs from agent participants.
"""
self._participants: dict[str, SupportsAgentRun | Executor] = {}
self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = []
# Orchestrator related members
self._orchestrator: BaseGroupChatOrchestrator | None = None
@@ -578,13 +575,7 @@ class GroupChatBuilder:
# Intermediate outputs
self._intermediate_outputs = intermediate_outputs
if participants is None and participant_factories is None:
raise ValueError("Either participants or participant_factories must be provided.")
if participant_factories is not None:
self._set_participant_factories(participant_factories)
if participants is not None:
self._set_participants(participants)
self._set_participants(participants)
# Set orchestrator if provided
if any(x is not None for x in [orchestrator_agent, orchestrator, selection_func]):
@@ -645,27 +636,8 @@ class GroupChatBuilder:
else:
self._orchestrator_factory = orchestrator_agent or orchestrator
def _set_participant_factories(
self,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]],
) -> None:
"""Set participant factories (internal)."""
if self._participants:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participant_factories:
raise ValueError("participant_factories already set.")
if not participant_factories:
raise ValueError("participant_factories cannot be empty")
self._participant_factories = list(participant_factories)
def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None:
"""Set participants (internal)."""
if self._participant_factories:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participants:
raise ValueError("participants already set.")
@@ -874,17 +846,10 @@ class GroupChatBuilder:
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants and not self._participant_factories:
raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.")
# We don't need to check if both are set since that is handled in the respective methods
if not self._participants:
raise ValueError("No participants provided. Pass participants to the constructor.")
participants: list[Executor | SupportsAgentRun] = []
if self._participant_factories:
for factory in self._participant_factories:
participant = factory()
participants.append(participant)
else:
participants = list(self._participants.values())
participants: list[Executor | SupportsAgentRun] = list(self._participants.values())
executors: list[Executor] = []
for participant in participants:
@@ -32,7 +32,7 @@ Key properties:
import inspect
import logging
import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from typing import Any, cast
@@ -575,7 +575,6 @@ class HandoffBuilder:
*,
name: str | None = None,
participants: Sequence[SupportsAgentRun] | None = None,
participant_factories: Mapping[str, Callable[[], SupportsAgentRun]] | None = None,
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
termination_condition: TerminationCondition | None = None,
@@ -584,8 +583,7 @@ class HandoffBuilder:
The builder starts in an unconfigured state and requires you to call:
1. `.participants([...])` - Register agents
2. or `.participant_factories({...})` - Register agent factories
3. `.build()` - Construct the final Workflow
2. `.build()` - Construct the final Workflow
Optional configuration methods allow you to customize context management,
termination logic, and persistence.
@@ -596,9 +594,6 @@ class HandoffBuilder:
participants: Optional list of agents that will participate in the handoff workflow.
You can also call `.participants([...])` later. Each participant must have a
unique identifier (`.name` is preferred if set, otherwise `.id` is used).
participant_factories: Optional mapping of factory names to callables that produce agents when invoked.
This allows for lazy instantiation and state isolation per workflow instance
created by this builder.
description: Optional human-readable description explaining the workflow's
purpose. Useful for documentation and observability.
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
@@ -610,10 +605,7 @@ class HandoffBuilder:
# Participant related members
self._participants: dict[str, SupportsAgentRun] = {}
self._participant_factories: dict[str, Callable[[], SupportsAgentRun]] = {}
self._start_id: str | None = None
if participant_factories:
self.register_participants(participant_factories)
if participants:
self.participants(participants)
@@ -635,68 +627,6 @@ class HandoffBuilder:
termination_condition
)
def register_participants(
self, participant_factories: Mapping[str, Callable[[], SupportsAgentRun]]
) -> "HandoffBuilder":
"""Register factories that produce agents for the handoff workflow.
Each factory is a callable that returns an SupportsAgentRun instance.
Factories are invoked when building the workflow, allowing for lazy instantiation
and state isolation per workflow instance.
Args:
participant_factories: Mapping of factory names to callables that return SupportsAgentRun
instances. Each produced participant must have a unique identifier
(`.name` is preferred if set, otherwise `.id` is used).
Returns:
Self for method chaining.
Raises:
ValueError: If participant_factories is empty or `.participants(...)` or `.register_participants(...)`
has already been called.
Example:
.. code-block:: python
from agent_framework import ChatAgent
from agent_framework_orchestrations import HandoffBuilder
def create_triage() -> ChatAgent:
return ...
def create_refund_agent() -> ChatAgent:
return ...
def create_billing_agent() -> ChatAgent:
return ...
factories = {
"triage": create_triage,
"refund": create_refund_agent,
"billing": create_billing_agent,
}
# Handoff will be created automatically unless specified otherwise
# The default creates a mesh topology where all agents can handoff to all others
builder = HandoffBuilder().register_participants(factories)
builder.with_start_agent("triage")
"""
if self._participants:
raise ValueError("Cannot mix .participants() and .register_participants() in the same builder instance.")
if self._participant_factories:
raise ValueError("register_participants() has already been called on this builder instance.")
if not participant_factories:
raise ValueError("participant_factories cannot be empty")
self._participant_factories = dict(participant_factories)
return self
def participants(self, participants: Sequence[SupportsAgentRun]) -> "HandoffBuilder":
"""Register the agents that will participate in the handoff workflow.
@@ -708,8 +638,8 @@ class HandoffBuilder:
Self for method chaining.
Raises:
ValueError: If participants is empty, contains duplicates, or `.participants()` or
`.register_participants()` has already been called.
ValueError: If participants is empty, contains duplicates, or `.participants()`
has already been called.
TypeError: If participants are not SupportsAgentRun instances.
Example:
@@ -727,9 +657,6 @@ class HandoffBuilder:
builder = HandoffBuilder().participants([triage, refund, billing])
builder.with_start_agent(triage)
"""
if self._participant_factories:
raise ValueError("Cannot mix .participants() and .register_participants() in the same builder instance.")
if self._participants:
raise ValueError("participants have already been assigned")
@@ -755,8 +682,8 @@ class HandoffBuilder:
def add_handoff(
self,
source: str | SupportsAgentRun,
targets: Sequence[str] | Sequence[SupportsAgentRun],
source: SupportsAgentRun,
targets: Sequence[SupportsAgentRun],
*,
description: str | None = None,
) -> "HandoffBuilder":
@@ -768,16 +695,8 @@ class HandoffBuilder:
to all others by default (mesh topology).
Args:
source: The agent that can initiate the handoff. Can be:
- Factory name (str): If using participant factories
- SupportsAgentRun instance: The actual agent object
- Cannot mix factory names and instances across source and targets
targets: One or more target agents that the source can hand off to. Can be:
- Factory name (str): If using participant factories
- SupportsAgentRun instance: The actual agent object
- Single target: ["billing_agent"] or [agent_instance]
- Multiple targets: ["billing_agent", "support_agent"] or [agent1, agent2]
- Cannot mix factory names and instances across source and targets
source: The agent that can initiate the handoff.
targets: One or more target agents that the source can hand off to.
description: Optional custom description for the handoff. If not provided, the description
of the target agent(s) will be used. If the target agent has no description,
no description will be set for the handoff tool, which is not recommended.
@@ -789,25 +708,10 @@ class HandoffBuilder:
Self for method chaining.
Raises:
ValueError: 1) If source or targets are not in the participants list, or if
participants(...) hasn't been called yet.
2) If source or targets are factory names (str) but participant_factories(...)
hasn't been called yet, or if they are not in the participant_factories list.
TypeError: If mixing factory names (str) and SupportsAgentRun/Executor instances
ValueError: If source or targets are not in the participants list, or if
participants(...) hasn't been called yet.
Examples:
Single target (using factory name):
.. code-block:: python
builder.add_handoff("triage_agent", "billing_agent")
Multiple targets (using factory names):
.. code-block:: python
builder.add_handoff("triage_agent", ["billing_agent", "support_agent", "escalation_agent"])
Multiple targets (using agent instances):
.. code-block:: python
@@ -830,96 +734,54 @@ class HandoffBuilder:
- Handoff tools are automatically registered for each source agent
- If a source agent is configured multiple times via add_handoff, targets are merged
"""
if isinstance(source, str) and all(isinstance(t, str) for t in targets):
# Both source and targets are factory names
if not self._participant_factories:
raise ValueError("Call participant_factories(...) before add_handoff(...)")
if not self._participants:
raise ValueError("Call participants(...) before add_handoff(...)")
if source not in self._participant_factories:
raise ValueError(f"Source factory name '{source}' is not in the participant_factories list")
# Resolve source agent ID
source_id = self._resolve_to_id(source)
if source_id not in self._participants:
raise ValueError(f"Source agent '{source}' is not in the participants list")
for target in targets:
if target not in self._participant_factories:
raise ValueError(f"Target factory name '{target}' is not in the participant_factories list")
# Resolve all target IDs
target_ids: list[str] = []
for target in targets:
target_id = self._resolve_to_id(target)
if target_id not in self._participants:
raise ValueError(f"Target agent '{target}' is not in the participants list")
target_ids.append(target_id)
# Merge with existing handoff configuration for this source
if source in self._handoff_config:
# Add new targets to existing list, avoiding duplicates
for t in targets:
if t in self._handoff_config[source]:
logger.warning(f"Handoff from '{source}' to '{t}' is already configured; overwriting.")
self._handoff_config[source].add(HandoffConfiguration(target=t, description=description))
else:
self._handoff_config[source] = set()
for t in targets:
self._handoff_config[source].add(HandoffConfiguration(target=t, description=description))
return self
# Merge with existing handoff configuration for this source
if source_id not in self._handoff_config:
self._handoff_config[source_id] = set()
if isinstance(source, (SupportsAgentRun)) and all(isinstance(t, SupportsAgentRun) for t in targets):
# Both source and targets are instances
if not self._participants:
raise ValueError("Call participants(...) before add_handoff(...)")
for t in target_ids:
config = HandoffConfiguration(target=t, description=description)
if config in self._handoff_config[source_id]:
logger.warning(f"Handoff from '{source_id}' to '{t}' is already configured; overwriting.")
# Remove old config so the new one (with updated description) takes effect
self._handoff_config[source_id].discard(config)
self._handoff_config[source_id].add(config)
# Resolve source agent ID
source_id = self._resolve_to_id(source)
if source_id not in self._participants:
raise ValueError(f"Source agent '{source}' is not in the participants list")
return self
# Resolve all target IDs
target_ids: list[str] = []
for target in targets:
target_id = self._resolve_to_id(target)
if target_id not in self._participants:
raise ValueError(f"Target agent '{target}' is not in the participants list")
target_ids.append(target_id)
# Merge with existing handoff configuration for this source
if source_id in self._handoff_config:
# Add new targets to existing list, avoiding duplicates
for t in target_ids:
if t in self._handoff_config[source_id]:
logger.warning(f"Handoff from '{source_id}' to '{t}' is already configured; overwriting.")
self._handoff_config[source_id].add(HandoffConfiguration(target=t, description=description))
else:
self._handoff_config[source_id] = set()
for t in target_ids:
self._handoff_config[source_id].add(HandoffConfiguration(target=t, description=description))
return self
raise TypeError(
"Cannot mix factory names (str) and SupportsAgentRun instances across source and targets in add_handoff()"
)
def with_start_agent(self, agent: str | SupportsAgentRun) -> "HandoffBuilder":
def with_start_agent(self, agent: SupportsAgentRun) -> "HandoffBuilder":
"""Set the agent that will initiate the handoff workflow.
If not specified, the first registered participant will be used as the starting agent.
Args:
agent: The agent that will start the workflow. Can be:
- Factory name (str): If using participant factories
- SupportsAgentRun instance: The actual agent object
agent: The agent that will start the workflow.
Returns:
Self for method chaining.
"""
if isinstance(agent, str):
if self._participant_factories:
if agent not in self._participant_factories:
raise ValueError(f"Start agent factory name '{agent}' is not in the participant_factories list")
else:
raise ValueError("Call register_participants(...) before with_start_agent(...)")
self._start_id = agent
elif isinstance(agent, SupportsAgentRun):
resolved_id = self._resolve_to_id(agent)
if self._participants:
if resolved_id not in self._participants:
raise ValueError(f"Start agent '{resolved_id}' is not in the participants list")
else:
raise ValueError("Call participants(...) before with_start_agent(...)")
self._start_id = resolved_id
resolved_id = self._resolve_to_id(agent)
if self._participants:
if resolved_id not in self._participants:
raise ValueError(f"Start agent '{resolved_id}' is not in the participants list")
else:
raise TypeError("Start agent must be a factory name (str) or an SupportsAgentRun instance")
raise ValueError("Call participants(...) before with_start_agent(...)")
self._start_id = resolved_id
return self
@@ -1090,48 +952,21 @@ class HandoffBuilder:
# region Internal Helper Methods
def _resolve_agents(self) -> dict[str, SupportsAgentRun]:
"""Resolve participant factories into agent instances.
If agent instances were provided directly via participants(...), those are
returned as-is. If participant factories were provided via participant_factories(...),
those are invoked to create the agent instances.
"""Resolve participant instances into agent instances.
Returns:
Map of executor IDs or factory names to `SupportsAgentRun` instances
Map of executor IDs to `SupportsAgentRun` instances
"""
if not self._participants and not self._participant_factories:
raise ValueError("No participants provided. Call .participants() or .register_participants() first.")
# We don't need to check if both are set since that is handled in the respective methods
if not self._participants:
raise ValueError("No participants provided. Call .participants() first.")
if self._participants:
return self._participants
return self._participants
if self._participant_factories:
# Invoke each factory to create participant instances
factory_names_to_agents: dict[str, SupportsAgentRun] = {}
for factory_name, factory in self._participant_factories.items():
instance = factory()
if isinstance(instance, SupportsAgentRun):
resolved_id = self._resolve_to_id(instance)
else:
raise TypeError(f"Participants must be SupportsAgentRun instances. Got {type(instance).__name__}.")
if resolved_id in factory_names_to_agents:
raise ValueError(f"Duplicate participant name '{resolved_id}' detected")
# Map executors by factory name (not executor.id) because handoff configs reference factory names
# This allows users to configure handoffs using the factory names they provided
factory_names_to_agents[factory_name] = instance
return factory_names_to_agents
raise ValueError("No executors or participant_factories have been configured")
def _resolve_handoffs(self, agents: Mapping[str, SupportsAgentRun]) -> dict[str, list[HandoffConfiguration]]:
"""Handoffs may be specified using factory names or instances; resolve to executor IDs.
def _resolve_handoffs(self, agents: dict[str, SupportsAgentRun]) -> dict[str, list[HandoffConfiguration]]:
"""Resolve handoff configurations to executor IDs.
Args:
agents: Map of agent IDs or factory names to `SupportsAgentRun` instances
agents: Map of agent IDs to `SupportsAgentRun` instances
Returns:
Map of executor IDs to list of HandoffConfiguration instances
@@ -1145,14 +980,14 @@ class HandoffBuilder:
if not source_agent:
raise ValueError(
f"Handoff source agent '{source_id}' not found. "
"Please make sure source has been added as either a participant or participant_factory."
"Please make sure source has been added as a participant."
)
for handoff_config in handoff_configurations:
target_agent = agents.get(handoff_config.target_id)
if not target_agent:
raise ValueError(
f"Handoff target agent '{handoff_config.target_id}' not found for source '{source_id}'. "
"Please make sure target has been added as either a participant or participant_factory."
"Please make sure target has been added as a participant."
)
updated_handoff_configurations.setdefault(self._resolve_to_id(source_agent), []).append(
@@ -1184,7 +1019,7 @@ class HandoffBuilder:
"""Resolve agents into HandoffAgentExecutors.
Args:
agents: Map of agent IDs or factory names to `SupportsAgentRun` instances
agents: Map of agent IDs to `SupportsAgentRun` instances
handoffs: Map of executor IDs to list of HandoffConfiguration instances
Returns:
@@ -1374,8 +1374,7 @@ class MagenticBuilder:
def __init__(
self,
*,
participants: Sequence[SupportsAgentRun | Executor] | None = None,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None,
participants: Sequence[SupportsAgentRun | Executor],
# Manager config (exactly one required)
manager: MagenticManagerBase | None = None,
manager_factory: Callable[[], MagenticManagerBase] | None = None,
@@ -1401,8 +1400,7 @@ class MagenticBuilder:
"""Initialize the Magentic workflow builder.
Args:
participants: Optional sequence of agent or executor instances for the workflow.
participant_factories: Optional sequence of callables returning agent or executor instances.
participants: Sequence of agent or executor instances for the workflow.
manager: Pre-configured manager instance (subclass of MagenticManagerBase).
manager_factory: Callable that returns a new MagenticManagerBase instance.
manager_agent: Agent instance for creating a StandardMagenticManager.
@@ -1423,7 +1421,6 @@ class MagenticBuilder:
intermediate_outputs: If True, enables intermediate outputs from agent participants.
"""
self._participants: dict[str, SupportsAgentRun | Executor] = {}
self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = []
# Manager related members
self._manager: MagenticManagerBase | None = None
@@ -1437,13 +1434,7 @@ class MagenticBuilder:
# Intermediate outputs
self._intermediate_outputs = intermediate_outputs
if participants is None and participant_factories is None:
raise ValueError("Either participants or participant_factories must be provided.")
if participant_factories is not None:
self._set_participant_factories(participant_factories)
if participants is not None:
self._set_participants(participants)
self._set_participants(participants)
# Set manager if provided
if any(x is not None for x in [manager, manager_factory, manager_agent, manager_agent_factory]):
@@ -1465,27 +1456,8 @@ class MagenticBuilder:
max_round_count=max_round_count,
)
def _set_participant_factories(
self,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]],
) -> None:
"""Set participant factories (internal)."""
if self._participants:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participant_factories:
raise ValueError("participant_factories already set.")
if not participant_factories:
raise ValueError("participant_factories cannot be empty")
self._participant_factories = list(participant_factories)
def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None:
"""Set participants (internal)."""
if self._participant_factories:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participants:
raise ValueError("participants already set.")
@@ -1750,17 +1722,10 @@ class MagenticBuilder:
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants and not self._participant_factories:
raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.")
# We don't need to check if both are set since that is handled in the respective methods
if not self._participants:
raise ValueError("No participants provided. Pass participants to the constructor.")
participants: list[Executor | SupportsAgentRun] = []
if self._participant_factories:
for factory in self._participant_factories:
participant = factory()
participants.append(participant)
else:
participants = list(self._participants.values())
participants: list[Executor | SupportsAgentRun] = list(self._participants.values())
executors: list[Executor] = []
for participant in participants:
@@ -4,8 +4,7 @@
This module provides a high-level, agent-focused API to assemble a sequential
workflow where:
- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`,
or as factories returning SupportsAgentRun or Executor via `participant_factories=[...]`
- Participants are provided as SupportsAgentRun or Executor instances via `participants=[...]`
- A shared conversation context (list[ChatMessage]) is passed along the chain
- Agents append their assistant messages to the context
- Custom executors can transform or summarize and return a refined context
@@ -38,7 +37,7 @@ confusion and to mirror how the concurrent builder uses explicit dispatcher/aggr
""" # noqa: E501
import logging
from collections.abc import Callable, Sequence
from collections.abc import Sequence
from typing import Any
from agent_framework import ChatMessage, SupportsAgentRun
@@ -110,8 +109,6 @@ class SequentialBuilder:
r"""High-level builder for sequential agent/executor workflows with shared context.
- `participants=[...]` accepts a list of SupportsAgentRun (recommended) or Executor instances
- `participant_factories=[...]` accepts a list of factories for SupportsAgentRun (recommended)
or Executor factories
- Executors must define a handler that consumes list[ChatMessage] and sends out a list[ChatMessage]
- The workflow wires participants in order, passing a list[ChatMessage] down the chain
- Agents append their assistant messages to the conversation
@@ -127,11 +124,6 @@ class SequentialBuilder:
# With agent instances
workflow = SequentialBuilder(participants=[agent1, agent2, summarizer_exec]).build()
# With agent factories
workflow = SequentialBuilder(
participant_factories=[create_agent1, create_agent2, create_summarizer_exec]
).build()
# Enable checkpoint persistence
workflow = SequentialBuilder(participants=[agent1, agent2], checkpoint_storage=storage).build()
@@ -149,55 +141,27 @@ class SequentialBuilder:
def __init__(
self,
*,
participants: Sequence[SupportsAgentRun | Executor] | None = None,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None,
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
intermediate_outputs: bool = False,
) -> None:
"""Initialize the SequentialBuilder.
Args:
participants: Optional sequence of agent or executor instances to run sequentially.
participant_factories: Optional sequence of callables returning agent or executor instances.
participants: Sequence of agent or executor instances to run sequentially.
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
intermediate_outputs: If True, enables intermediate outputs from agent participants.
"""
self._participants: list[SupportsAgentRun | Executor] = []
self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = []
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._intermediate_outputs: bool = intermediate_outputs
if participants is None and participant_factories is None:
raise ValueError("Either participants or participant_factories must be provided.")
if participant_factories is not None:
self._set_participant_factories(participant_factories)
if participants is not None:
self._set_participants(participants)
def _set_participant_factories(
self,
participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]],
) -> None:
"""Set participant factories (internal)."""
if self._participants:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participant_factories:
raise ValueError("participant_factories already set.")
if not participant_factories:
raise ValueError("participant_factories cannot be empty")
self._participant_factories = list(participant_factories)
self._set_participants(participants)
def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None:
"""Set participants (internal)."""
if self._participant_factories:
raise ValueError("Cannot provide both participants and participant_factories.")
if self._participants:
raise ValueError("participants already set.")
@@ -256,19 +220,10 @@ class SequentialBuilder:
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants and not self._participant_factories:
raise ValueError("No participants provided. Pass participants or participant_factories to the constructor.")
# We don't need to check if both are set since that is handled in the respective methods
if not self._participants:
raise ValueError("No participants provided. Pass participants to the constructor.")
participants: list[Executor | SupportsAgentRun] = []
if self._participant_factories:
# Resolve the participant factories now. This doesn't break the factory pattern
# since the Sequential builder still creates new instances per workflow build.
for factory in self._participant_factories:
p = factory()
participants.append(p)
else:
participants = self._participants
participants: list[Executor | SupportsAgentRun] = self._participants
executors: list[Executor] = []
for p in participants:
@@ -49,47 +49,6 @@ def test_concurrent_builder_rejects_duplicate_executors() -> None:
ConcurrentBuilder(participants=[a, b])
def test_concurrent_builder_rejects_duplicate_executors_from_factories() -> None:
"""Test that duplicate executor IDs from factories are detected at build time."""
def create_dup1() -> Executor:
return _FakeAgentExec("dup", "A")
def create_dup2() -> Executor:
return _FakeAgentExec("dup", "B") # same executor id
builder = ConcurrentBuilder(participant_factories=[create_dup1, create_dup2])
with pytest.raises(ValueError, match="Duplicate executor ID 'dup' detected in workflow."):
builder.build()
def test_concurrent_builder_rejects_mixed_participants_and_factories() -> None:
"""Test that passing both participants and participant_factories to the constructor raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
ConcurrentBuilder(
participants=[_FakeAgentExec("a", "A")],
participant_factories=[lambda: _FakeAgentExec("b", "B")],
)
def test_concurrent_builder_rejects_both_participants_and_factories() -> None:
"""Test that passing both participants and participant_factories raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
ConcurrentBuilder(
participants=[_FakeAgentExec("a", "A")],
participant_factories=[lambda: _FakeAgentExec("b", "B")],
)
def test_concurrent_builder_rejects_both_factories_and_participants() -> None:
"""Test that passing both participant_factories and participants raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
ConcurrentBuilder(
participant_factories=[lambda: _FakeAgentExec("a", "A")],
participants=[_FakeAgentExec("b", "B")],
)
async def test_concurrent_default_aggregator_emits_single_user_and_assistants() -> None:
# Three synthetic agent executors
e1 = _FakeAgentExec("agentA", "Alpha")
@@ -231,79 +190,6 @@ async def test_concurrent_with_aggregator_executor_instance() -> None:
assert output == "One & Two"
async def test_concurrent_with_aggregator_executor_factory() -> None:
"""Test with_aggregator using an Executor factory."""
class CustomAggregator(Executor):
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
texts: list[str] = []
for r in results:
msgs: list[ChatMessage] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
await ctx.yield_output(" | ".join(sorted(texts)))
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
wf = (
ConcurrentBuilder(participants=[e1, e2])
.register_aggregator(lambda: CustomAggregator(id="custom_aggregator"))
.build()
)
completed = False
output: str | None = None
async for ev in wf.run("prompt: factory test", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
assert isinstance(output, str)
assert output == "One | Two"
async def test_concurrent_with_aggregator_executor_factory_with_default_id() -> None:
"""Test with_aggregator using an Executor class directly as factory (with default __init__ parameters)."""
class CustomAggregator(Executor):
def __init__(self, id: str = "default_aggregator") -> None:
super().__init__(id)
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
texts: list[str] = []
for r in results:
msgs: list[ChatMessage] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
await ctx.yield_output(" | ".join(sorted(texts)))
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
wf = ConcurrentBuilder(participants=[e1, e2]).register_aggregator(CustomAggregator).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: factory test", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = cast(str, ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
assert isinstance(output, str)
assert output == "One | Two"
def test_concurrent_builder_rejects_multiple_calls_to_with_aggregator() -> None:
"""Test that multiple calls to .with_aggregator() raises an error."""
@@ -318,20 +204,6 @@ def test_concurrent_builder_rejects_multiple_calls_to_with_aggregator() -> None:
)
def test_concurrent_builder_rejects_multiple_calls_to_register_aggregator() -> None:
"""Test that multiple calls to .register_aggregator() raises an error."""
class CustomAggregator(Executor):
pass
with pytest.raises(ValueError, match=r"register_aggregator\(\) has already been called"):
(
ConcurrentBuilder(participants=[_FakeAgentExec("a", "A")])
.register_aggregator(lambda: CustomAggregator(id="agg1"))
.register_aggregator(lambda: CustomAggregator(id="agg2"))
)
async def test_concurrent_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
@@ -455,11 +327,6 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
def test_concurrent_builder_rejects_empty_participant_factories() -> None:
with pytest.raises(ValueError):
ConcurrentBuilder(participant_factories=[])
async def test_concurrent_builder_reusable_after_build_with_participants() -> None:
"""Test that the builder can be reused to build multiple identical workflows with participants()."""
e1 = _FakeAgentExec("agentA", "One")
@@ -471,74 +338,3 @@ async def test_concurrent_builder_reusable_after_build_with_participants() -> No
assert builder._participants[0] is e1 # type: ignore
assert builder._participants[1] is e2 # type: ignore
assert builder._participant_factories == [] # type: ignore
async def test_concurrent_builder_reusable_after_build_with_factories() -> None:
"""Test that the builder can be reused to build multiple workflows with register_participants()."""
call_count = 0
def create_agent_executor_a() -> Executor:
nonlocal call_count
call_count += 1
return _FakeAgentExec("agentA", "One")
def create_agent_executor_b() -> Executor:
nonlocal call_count
call_count += 1
return _FakeAgentExec("agentB", "Two")
builder = ConcurrentBuilder(participant_factories=[create_agent_executor_a, create_agent_executor_b])
# Build the first workflow
wf1 = builder.build()
assert builder._participants == [] # type: ignore
assert len(builder._participant_factories) == 2 # type: ignore
assert call_count == 2
# Build the second workflow
wf2 = builder.build()
assert call_count == 4
# Verify that the two workflows have different executor instances
assert wf1.executors["agentA"] is not wf2.executors["agentA"]
assert wf1.executors["agentB"] is not wf2.executors["agentB"]
async def test_concurrent_with_register_participants() -> None:
"""Test workflow creation using register_participants with factories."""
def create_agent1() -> Executor:
return _FakeAgentExec("agentA", "Alpha")
def create_agent2() -> Executor:
return _FakeAgentExec("agentB", "Beta")
def create_agent3() -> Executor:
return _FakeAgentExec("agentC", "Gamma")
wf = ConcurrentBuilder(participant_factories=[create_agent1, create_agent2, create_agent3]).build()
completed = False
output: list[ChatMessage] | None = None
async for ev in wf.run("test prompt", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = cast(list[ChatMessage], ev.data)
if completed and output is not None:
break
assert completed
assert output is not None
messages: list[ChatMessage] = output
# Expect one user message + one assistant message per participant
assert len(messages) == 1 + 3
assert messages[0].role == "user"
assert "test prompt" in messages[0].text
assistant_texts = {m.text for m in messages[1:]}
assert assistant_texts == {"Alpha", "Beta", "Gamma"}
assert all(m.role == "assistant" for m in messages[1:])
@@ -240,12 +240,9 @@ class TestGroupChatBuilder:
builder.build()
def test_build_without_participants_raises_error(self) -> None:
"""Test that constructing without participants raises ValueError."""
with pytest.raises(
ValueError,
match=r"Either participants or participant_factories must be provided\.",
):
GroupChatBuilder()
"""Test that constructing with empty participants raises ValueError."""
with pytest.raises(ValueError):
GroupChatBuilder(participants=[])
def test_duplicate_manager_configuration_raises_error(self) -> None:
"""Test that configuring multiple orchestrator options raises ValueError."""
@@ -775,150 +772,6 @@ def test_group_chat_builder_with_request_info_returns_self():
assert result2 is builder2
# region Participant Factory Tests
def test_group_chat_builder_rejects_empty_participant_factories():
"""Test that GroupChatBuilder rejects empty participant_factories list."""
def selector(state: GroupChatState) -> str:
return list(state.participants.keys())[0]
with pytest.raises(ValueError, match=r"participant_factories cannot be empty"):
GroupChatBuilder(participant_factories=[])
with pytest.raises(
ValueError,
match=r"Either participants or participant_factories must be provided\.",
):
GroupChatBuilder()
def test_group_chat_builder_rejects_mixing_participants_and_factories():
"""Test that passing both participants and participant_factories to the constructor raises an error."""
alpha = StubAgent("alpha", "reply from alpha")
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
GroupChatBuilder(
participants=[alpha],
participant_factories=[lambda: StubAgent("beta", "reply from beta")],
)
def test_group_chat_builder_rejects_both_factories_and_participants():
"""Test that passing both participant_factories and participants raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
GroupChatBuilder(
participant_factories=[lambda: StubAgent("alpha", "reply from alpha")],
participants=[StubAgent("beta", "reply from beta")],
)
def test_group_chat_builder_rejects_both_participants_and_factories():
"""Test that passing both participants and participant_factories raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
GroupChatBuilder(
participants=[StubAgent("alpha", "reply from alpha")],
participant_factories=[lambda: StubAgent("beta", "reply from beta")],
)
async def test_group_chat_with_participant_factories():
"""Test workflow creation using participant_factories."""
call_count = 0
def create_alpha() -> StubAgent:
nonlocal call_count
call_count += 1
return StubAgent("alpha", "reply from alpha")
def create_beta() -> StubAgent:
nonlocal call_count
call_count += 1
return StubAgent("beta", "reply from beta")
selector = make_sequence_selector()
workflow = GroupChatBuilder(
participant_factories=[create_alpha, create_beta],
max_rounds=2,
selection_func=selector,
).build()
# Factories should be called during build
assert call_count == 2
outputs: list[WorkflowEvent] = []
async for event in workflow.run("coordinate task", stream=True):
if event.type == "output":
outputs.append(event)
assert len(outputs) == 1
async def test_group_chat_participant_factories_reusable_builder():
"""Test that the builder can be reused to build multiple workflows with factories."""
call_count = 0
def create_alpha() -> StubAgent:
nonlocal call_count
call_count += 1
return StubAgent("alpha", "reply from alpha")
def create_beta() -> StubAgent:
nonlocal call_count
call_count += 1
return StubAgent("beta", "reply from beta")
selector = make_sequence_selector()
builder = GroupChatBuilder(participant_factories=[create_alpha, create_beta], max_rounds=2, selection_func=selector)
# Build first workflow
wf1 = builder.build()
assert call_count == 2
# Build second workflow
wf2 = builder.build()
assert call_count == 4
# Verify that the two workflows have different agent instances
assert wf1.executors["alpha"] is not wf2.executors["alpha"]
assert wf1.executors["beta"] is not wf2.executors["beta"]
async def test_group_chat_participant_factories_with_checkpointing():
"""Test checkpointing with participant_factories."""
storage = InMemoryCheckpointStorage()
def create_alpha() -> StubAgent:
return StubAgent("alpha", "reply from alpha")
def create_beta() -> StubAgent:
return StubAgent("beta", "reply from beta")
selector = make_sequence_selector()
workflow = GroupChatBuilder(
participant_factories=[create_alpha, create_beta],
checkpoint_storage=storage,
max_rounds=2,
selection_func=selector,
).build()
outputs: list[WorkflowEvent] = []
async for event in workflow.run("checkpoint test", stream=True):
if event.type == "output":
outputs.append(event)
assert outputs, "Should have workflow output"
checkpoints = await storage.list_checkpoints()
assert checkpoints, "Checkpoints should be created during workflow execution"
# endregion
# region Orchestrator Factory Tests
@@ -1129,77 +982,4 @@ def test_group_chat_orchestrator_factory_invalid_return_type():
GroupChatBuilder(participants=[alpha], orchestrator_agent=invalid_factory).build()
def test_group_chat_with_both_participant_and_orchestrator_factories():
"""Test workflow creation using both participant_factories and orchestrator_factory."""
participant_factory_call_count = 0
agent_factory_call_count = 0
def create_alpha() -> StubAgent:
nonlocal participant_factory_call_count
participant_factory_call_count += 1
return StubAgent("alpha", "reply from alpha")
def create_beta() -> StubAgent:
nonlocal participant_factory_call_count
participant_factory_call_count += 1
return StubAgent("beta", "reply from beta")
def agent_factory() -> ChatAgent:
nonlocal agent_factory_call_count
agent_factory_call_count += 1
return cast(ChatAgent, StubManagerAgent())
workflow = GroupChatBuilder(
participant_factories=[create_alpha, create_beta],
orchestrator_agent=agent_factory,
).build()
# All factories should be called during build
assert participant_factory_call_count == 2
assert agent_factory_call_count == 1
# Verify all executors are present in the workflow
assert "alpha" in workflow.executors
assert "beta" in workflow.executors
assert "manager_agent" in workflow.executors
async def test_group_chat_factories_reusable_for_multiple_workflows():
"""Test that both factories are reused correctly for multiple workflow builds."""
participant_factory_call_count = 0
agent_factory_call_count = 0
def create_alpha() -> StubAgent:
nonlocal participant_factory_call_count
participant_factory_call_count += 1
return StubAgent("alpha", "reply from alpha")
def create_beta() -> StubAgent:
nonlocal participant_factory_call_count
participant_factory_call_count += 1
return StubAgent("beta", "reply from beta")
def agent_factory() -> ChatAgent:
nonlocal agent_factory_call_count
agent_factory_call_count += 1
return cast(ChatAgent, StubManagerAgent())
builder = GroupChatBuilder(participant_factories=[create_alpha, create_beta], orchestrator_agent=agent_factory)
# Build first workflow
wf1 = builder.build()
assert participant_factory_call_count == 2
assert agent_factory_call_count == 1
# Build second workflow
wf2 = builder.build()
assert participant_factory_call_count == 4
assert agent_factory_call_count == 2
# Verify that the workflows have different agent and orchestrator instances
assert wf1.executors["alpha"] is not wf2.executors["alpha"]
assert wf1.executors["beta"] is not wf2.executors["beta"]
assert wf1.executors["manager_agent"] is not wf2.executors["manager_agent"]
# endregion
@@ -229,10 +229,8 @@ def test_build_fails_without_start_agent():
def test_build_fails_without_participants():
"""Verify that build() raises ValueError when no participants are provided."""
with pytest.raises(
ValueError, match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first."
):
HandoffBuilder().build()
with pytest.raises(ValueError):
HandoffBuilder(participants=[]).build()
async def test_handoff_async_termination_condition() -> None:
@@ -349,162 +347,6 @@ async def test_context_provider_preserved_during_handoff():
)
# region Participant Factory Tests
def test_handoff_builder_rejects_empty_participant_factories():
"""Test that HandoffBuilder rejects empty participant_factories dictionary."""
# Empty factories are rejected immediately when calling participant_factories()
with pytest.raises(ValueError, match=r"participant_factories cannot be empty"):
HandoffBuilder().register_participants({})
with pytest.raises(
ValueError, match=r"No participants provided\. Call \.participants\(\) or \.register_participants\(\) first\."
):
HandoffBuilder(participant_factories={}).build()
def test_handoff_builder_rejects_mixing_participants_and_factories():
"""Test that mixing participants and participant_factories in __init__ raises an error."""
triage = MockHandoffAgent(name="triage")
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(participants=[triage], participant_factories={"triage": lambda: triage})
def test_handoff_builder_rejects_mixing_participants_and_participant_factories_methods():
"""Test that mixing .participants() and .participant_factories() raises an error."""
triage = MockHandoffAgent(name="triage")
# Case 1: participants first, then participant_factories
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(participants=[triage]).register_participants({
"specialist": lambda: MockHandoffAgent(name="specialist")
})
# Case 2: participant_factories first, then participants
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(participant_factories={"triage": lambda: triage}).participants([
MockHandoffAgent(name="specialist")
])
# Case 3: participants(), then participant_factories()
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder().participants([triage]).register_participants({
"specialist": lambda: MockHandoffAgent(name="specialist")
})
# Case 4: participant_factories(), then participants()
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder().register_participants({"triage": lambda: triage}).participants([
MockHandoffAgent(name="specialist")
])
# Case 5: mix during initialization
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(
participants=[triage], participant_factories={"specialist": lambda: MockHandoffAgent(name="specialist")}
)
def test_handoff_builder_rejects_multiple_calls_to_participant_factories():
"""Test that multiple calls to .participant_factories() raises an error."""
with pytest.raises(
ValueError, match=r"register_participants\(\) has already been called on this builder instance."
):
(
HandoffBuilder()
.register_participants({"agent1": lambda: MockHandoffAgent(name="agent1")})
.register_participants({"agent2": lambda: MockHandoffAgent(name="agent2")})
)
def test_handoff_builder_rejects_multiple_calls_to_participants():
"""Test that multiple calls to .participants() raises an error."""
with pytest.raises(ValueError, match="participants have already been assigned"):
(
HandoffBuilder()
.participants([MockHandoffAgent(name="agent1")])
.participants([MockHandoffAgent(name="agent2")])
)
def test_handoff_builder_rejects_instance_coordinator_with_factories():
"""Test that using an agent instance for set_coordinator when using factories raises an error."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage")
def create_specialist() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist")
# Create an agent instance
coordinator_instance = MockHandoffAgent(name="coordinator")
with pytest.raises(ValueError, match=r"Call participants\(\.\.\.\) before with_start_agent\(\.\.\.\)"):
(
HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist}
).with_start_agent(coordinator_instance) # Instance, not factory name
)
def test_handoff_builder_rejects_factory_name_coordinator_with_instances():
"""Test that using a factory name for set_coordinator when using instances raises an error."""
triage = MockHandoffAgent(name="triage")
specialist = MockHandoffAgent(name="specialist")
with pytest.raises(ValueError, match=r"Call register_participants\(...\) before with_start_agent\(...\)"):
(
HandoffBuilder(participants=[triage, specialist]).with_start_agent(
"triage"
) # String factory name, not instance
)
def test_handoff_builder_rejects_mixed_types_in_add_handoff_source():
"""Test that add_handoff rejects factory name source with instance-based participants."""
triage = MockHandoffAgent(name="triage")
specialist = MockHandoffAgent(name="specialist")
with pytest.raises(TypeError, match="Cannot mix factory names \\(str\\) and SupportsAgentRun.*instances"):
(
HandoffBuilder(participants=[triage, specialist])
.with_start_agent(triage)
.add_handoff("triage", [specialist]) # String source with instance participants
)
def test_handoff_builder_accepts_all_factory_names_in_add_handoff():
"""Test that add_handoff accepts all factory names when using participant_factories."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage")
def create_specialist_a() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist_a")
def create_specialist_b() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist_b")
# This should work - all strings with participant_factories
builder = (
HandoffBuilder(
participant_factories={
"triage": create_triage,
"specialist_a": create_specialist_a,
"specialist_b": create_specialist_b,
}
)
.with_start_agent("triage")
.add_handoff("triage", ["specialist_a", "specialist_b"])
)
workflow = builder.build()
assert "triage" in workflow.executors
assert "specialist_a" in workflow.executors
assert "specialist_b" in workflow.executors
def test_handoff_builder_accepts_all_instances_in_add_handoff():
"""Test that add_handoff accepts all instances when using participants."""
triage = MockHandoffAgent(name="triage", handoff_to="specialist_a")
@@ -522,260 +364,3 @@ def test_handoff_builder_accepts_all_instances_in_add_handoff():
assert "triage" in workflow.executors
assert "specialist_a" in workflow.executors
assert "specialist_b" in workflow.executors
async def test_handoff_with_participant_factories():
"""Test workflow creation using participant_factories."""
call_count = 0
def create_triage() -> MockHandoffAgent:
nonlocal call_count
call_count += 1
return MockHandoffAgent(name="triage", handoff_to="specialist")
def create_specialist() -> MockHandoffAgent:
nonlocal call_count
call_count += 1
return MockHandoffAgent(name="specialist")
workflow = (
HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist},
termination_condition=lambda conv: sum(1 for m in conv if m.role == "user") >= 2,
)
.with_start_agent("triage")
.build()
)
# Factories should be called during build
assert call_count == 2
events = await _drain(workflow.run("Need help", stream=True))
requests = [ev for ev in events if ev.type == "request_info"]
assert requests
# Follow-up message
events = await _drain(
workflow.run(stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="More details")]})
)
outputs = [ev for ev in events if ev.type == "output"]
assert outputs
async def test_handoff_participant_factories_reusable_builder():
"""Test that the builder can be reused to build multiple workflows with factories."""
call_count = 0
def create_triage() -> MockHandoffAgent:
nonlocal call_count
call_count += 1
return MockHandoffAgent(name="triage", handoff_to="specialist")
def create_specialist() -> MockHandoffAgent:
nonlocal call_count
call_count += 1
return MockHandoffAgent(name="specialist")
builder = HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist}
).with_start_agent("triage")
# Build first workflow
wf1 = builder.build()
assert call_count == 2
# Build second workflow
wf2 = builder.build()
assert call_count == 4
# Verify that the two workflows have different agent instances
assert wf1.executors["triage"] is not wf2.executors["triage"]
assert wf1.executors["specialist"] is not wf2.executors["specialist"]
async def test_handoff_with_participant_factories_and_add_handoff():
"""Test that .add_handoff() works correctly with participant_factories."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage", handoff_to="specialist_a")
def create_specialist_a() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist_a", handoff_to="specialist_b")
def create_specialist_b() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist_b")
workflow = (
HandoffBuilder(
participant_factories={
"triage": create_triage,
"specialist_a": create_specialist_a,
"specialist_b": create_specialist_b,
},
termination_condition=lambda conv: sum(1 for m in conv if m.role == "user") >= 3,
)
.with_start_agent("triage")
.add_handoff("triage", ["specialist_a", "specialist_b"])
.add_handoff("specialist_a", ["specialist_b"])
.build()
)
# Start conversation - triage hands off to specialist_a
events = await _drain(workflow.run("Initial request", stream=True))
requests = [ev for ev in events if ev.type == "request_info"]
assert requests
# Verify specialist_a executor exists and was called
assert "specialist_a" in workflow.executors
# Second user message - specialist_a hands off to specialist_b
events = await _drain(
workflow.run(
stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="Need escalation")]}
)
)
requests = [ev for ev in events if ev.type == "request_info"]
assert requests
# Verify specialist_b executor exists
assert "specialist_b" in workflow.executors
async def test_handoff_participant_factories_with_checkpointing():
"""Test checkpointing with participant_factories."""
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
storage = InMemoryCheckpointStorage()
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage", handoff_to="specialist")
def create_specialist() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist")
workflow = (
HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist},
checkpoint_storage=storage,
termination_condition=lambda conv: sum(1 for m in conv if m.role == "user") >= 2,
)
.with_start_agent("triage")
.build()
)
# Run workflow and capture output
events = await _drain(workflow.run("checkpoint test", stream=True))
requests = [ev for ev in events if ev.type == "request_info"]
assert requests
events = await _drain(
workflow.run(stream=True, responses={requests[-1].request_id: [ChatMessage(role="user", text="follow up")]})
)
outputs = [ev for ev in events if ev.type == "output"]
assert outputs, "Should have workflow output after termination condition is met"
# List checkpoints - just verify they were created
checkpoints = await storage.list_checkpoints()
assert checkpoints, "Checkpoints should be created during workflow execution"
def test_handoff_set_coordinator_with_factory_name():
"""Test that set_coordinator accepts factory name as string."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage")
def create_specialist() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist")
builder = HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist}
).with_start_agent("triage")
workflow = builder.build()
assert "triage" in workflow.executors
def test_handoff_add_handoff_with_factory_names():
"""Test that add_handoff accepts factory names as strings."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage", handoff_to="specialist_a")
def create_specialist_a() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist_a")
def create_specialist_b() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist_b")
builder = (
HandoffBuilder(
participant_factories={
"triage": create_triage,
"specialist_a": create_specialist_a,
"specialist_b": create_specialist_b,
}
)
.with_start_agent("triage")
.add_handoff("triage", ["specialist_a", "specialist_b"])
)
workflow = builder.build()
assert "triage" in workflow.executors
assert "specialist_a" in workflow.executors
assert "specialist_b" in workflow.executors
async def test_handoff_participant_factories_autonomous_mode():
"""Test autonomous mode with participant_factories."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage", handoff_to="specialist")
def create_specialist() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist")
workflow = (
HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist})
.with_start_agent("triage")
.with_autonomous_mode(agents=["specialist"], turn_limits={"specialist": 1})
.build()
)
events = await _drain(workflow.run("Issue", stream=True))
requests = [ev for ev in events if ev.type == "request_info"]
assert requests and len(requests) == 1
assert requests[0].source_executor_id == "specialist"
def test_handoff_participant_factories_invalid_coordinator_name():
"""Test that set_coordinator raises error for non-existent factory name."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage")
with pytest.raises(
ValueError, match="Start agent factory name 'nonexistent' is not in the participant_factories list"
):
(HandoffBuilder(participant_factories={"triage": create_triage}).with_start_agent("nonexistent").build())
def test_handoff_participant_factories_invalid_handoff_target():
"""Test that add_handoff raises error for non-existent target factory name."""
def create_triage() -> MockHandoffAgent:
return MockHandoffAgent(name="triage")
def create_specialist() -> MockHandoffAgent:
return MockHandoffAgent(name="specialist")
with pytest.raises(ValueError, match="Target factory name 'nonexistent' is not in the participant_factories list"):
(
HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist})
.with_start_agent("triage")
.add_handoff("triage", ["nonexistent"])
.build()
)
# endregion Participant Factory Tests
@@ -890,121 +890,6 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
)
# endregion
# region Participant Factory Tests
def test_magentic_builder_rejects_empty_participant_factories():
"""Test that MagenticBuilder rejects empty participant_factories list."""
with pytest.raises(ValueError, match=r"participant_factories cannot be empty"):
MagenticBuilder(participant_factories=[])
with pytest.raises(
ValueError,
match=r"Either participants or participant_factories must be provided\.",
):
MagenticBuilder()
def test_magentic_builder_rejects_mixing_participants_and_factories():
"""Test that passing both participants and participant_factories to the constructor raises an error."""
agent = StubAgent("agentA", "reply from agentA")
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
MagenticBuilder(
participants=[agent],
participant_factories=[lambda: StubAgent("agentB", "reply")],
)
def test_magentic_builder_rejects_both_factories_and_participants():
"""Test that passing both participant_factories and participants raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
MagenticBuilder(
participant_factories=[lambda: StubAgent("agentA", "reply from agentA")],
participants=[StubAgent("agentB", "reply from agentB")],
)
def test_magentic_builder_rejects_both_participants_and_factories():
"""Test that passing both participants and participant_factories raises an error."""
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
MagenticBuilder(
participants=[StubAgent("agentA", "reply from agentA")],
participant_factories=[lambda: StubAgent("agentB", "reply from agentB")],
)
async def test_magentic_with_participant_factories():
"""Test workflow creation using participant_factories."""
call_count = 0
def create_agent() -> StubAgent:
nonlocal call_count
call_count += 1
return StubAgent("agentA", "reply from agentA")
manager = FakeManager()
workflow = MagenticBuilder(participant_factories=[create_agent], manager=manager).build()
# Factory should be called during build
assert call_count == 1
outputs: list[WorkflowEvent] = []
async for event in workflow.run("test task", stream=True):
if event.type == "output":
outputs.append(event)
assert len(outputs) == 1
async def test_magentic_participant_factories_reusable_builder():
"""Test that the builder can be reused to build multiple workflows with factories."""
call_count = 0
def create_agent() -> StubAgent:
nonlocal call_count
call_count += 1
return StubAgent("agentA", "reply from agentA")
builder = MagenticBuilder(participant_factories=[create_agent], manager=FakeManager())
# Build first workflow
wf1 = builder.build()
assert call_count == 1
# Build second workflow
wf2 = builder.build()
assert call_count == 2
# Verify that the two workflows have different agent instances
assert wf1.executors["agentA"] is not wf2.executors["agentA"]
async def test_magentic_participant_factories_with_checkpointing():
"""Test checkpointing with participant_factories."""
storage = InMemoryCheckpointStorage()
def create_agent() -> StubAgent:
return StubAgent("agentA", "reply from agentA")
manager = FakeManager()
workflow = MagenticBuilder(
participant_factories=[create_agent], checkpoint_storage=storage, manager=manager
).build()
outputs: list[WorkflowEvent] = []
async for event in workflow.run("checkpoint test", stream=True):
if event.type == "output":
outputs.append(event)
assert outputs, "Should have workflow output"
checkpoints = await storage.list_checkpoints()
assert checkpoints, "Checkpoints should be created during workflow execution"
# endregion
# region Manager Factory Tests
@@ -1112,66 +997,6 @@ async def test_magentic_manager_factory_reusable_builder():
assert orchestrator1 is not orchestrator2
def test_magentic_with_both_participant_and_manager_factories():
"""Test workflow creation using both participant_factories and manager_factory."""
participant_factory_call_count = 0
manager_factory_call_count = 0
def create_agent() -> StubAgent:
nonlocal participant_factory_call_count
participant_factory_call_count += 1
return StubAgent("agentA", "reply from agentA")
def manager_factory() -> MagenticManagerBase:
nonlocal manager_factory_call_count
manager_factory_call_count += 1
return FakeManager()
workflow = MagenticBuilder(participant_factories=[create_agent], manager_factory=manager_factory).build()
# All factories should be called during build
assert participant_factory_call_count == 1
assert manager_factory_call_count == 1
# Verify executor is present in the workflow
assert "agentA" in workflow.executors
async def test_magentic_factories_reusable_for_multiple_workflows():
"""Test that both factories are reused correctly for multiple workflow builds."""
participant_factory_call_count = 0
manager_factory_call_count = 0
def create_agent() -> StubAgent:
nonlocal participant_factory_call_count
participant_factory_call_count += 1
return StubAgent("agentA", "reply from agentA")
def manager_factory() -> MagenticManagerBase:
nonlocal manager_factory_call_count
manager_factory_call_count += 1
return FakeManager()
builder = MagenticBuilder(participant_factories=[create_agent], manager_factory=manager_factory)
# Build first workflow
wf1 = builder.build()
assert participant_factory_call_count == 1
assert manager_factory_call_count == 1
# Build second workflow
wf2 = builder.build()
assert participant_factory_call_count == 2
assert manager_factory_call_count == 2
# Verify that the workflows have different agent and orchestrator instances
assert wf1.executors["agentA"] is not wf2.executors["agentA"]
orchestrator1 = next(e for e in wf1.executors.values() if isinstance(e, MagenticOrchestrator))
orchestrator2 = next(e for e in wf2.executors.values() if isinstance(e, MagenticOrchestrator))
assert orchestrator1 is not orchestrator2
def test_magentic_agent_factory_with_standard_manager_options():
"""Test that agent_factory properly passes through standard manager options."""
factory_call_count = 0
@@ -71,22 +71,6 @@ def test_sequential_builder_rejects_empty_participants() -> None:
SequentialBuilder(participants=[])
def test_sequential_builder_rejects_empty_participant_factories() -> None:
with pytest.raises(ValueError):
SequentialBuilder(participant_factories=[])
def test_sequential_builder_rejects_mixing_participants_and_factories() -> None:
"""Test that passing both participants and participant_factories to the constructor raises an error."""
a1 = _EchoAgent(id="agent1", name="A1")
with pytest.raises(ValueError, match="Cannot provide both participants and participant_factories"):
SequentialBuilder(
participants=[a1],
participant_factories=[lambda: _EchoAgent(id="agent2", name="A2")],
)
def test_sequential_builder_validation_rejects_invalid_executor() -> None:
"""Test that adding an invalid executor to the builder raises an error."""
with pytest.raises(TypeCompatibilityError):
@@ -121,37 +105,6 @@ async def test_sequential_agents_append_to_context() -> None:
assert "A2 reply" in msgs[2].text
async def test_sequential_register_participants_with_agent_factories() -> None:
"""Test that register_participants works with agent factories."""
def create_agent1() -> _EchoAgent:
return _EchoAgent(id="agent1", name="A1")
def create_agent2() -> _EchoAgent:
return _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participant_factories=[create_agent1, create_agent2]).build()
completed = False
output: list[ChatMessage] | None = None
async for ev in wf.run("hello factories", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = ev.data
if completed and output is not None:
break
assert completed
assert output is not None
assert isinstance(output, list)
msgs: list[ChatMessage] = output
assert len(msgs) == 3
assert msgs[0].role == "user" and "hello factories" in msgs[0].text
assert msgs[1].role == "assistant" and "A1 reply" in msgs[1].text
assert msgs[2].role == "assistant" and "A2 reply" in msgs[2].text
async def test_sequential_with_custom_executor_summary() -> None:
a1 = _EchoAgent(id="agent1", name="A1")
summarizer = _SummarizerExec(id="summarizer")
@@ -178,37 +131,6 @@ async def test_sequential_with_custom_executor_summary() -> None:
assert msgs[2].role == "assistant" and msgs[2].text.startswith("Summary of users:")
async def test_sequential_register_participants_mixed_agents_and_executors() -> None:
"""Test register_participants with both agent and executor factories."""
def create_agent() -> _EchoAgent:
return _EchoAgent(id="agent1", name="A1")
def create_summarizer() -> _SummarizerExec:
return _SummarizerExec(id="summarizer")
wf = SequentialBuilder(participant_factories=[create_agent, create_summarizer]).build()
completed = False
output: list[ChatMessage] | None = None
async for ev in wf.run("topic Y", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = ev.data
if completed and output is not None:
break
assert completed
assert output is not None
msgs: list[ChatMessage] = output
# Expect: [user, A1 reply, summary]
assert len(msgs) == 3
assert msgs[0].role == "user" and "topic Y" in msgs[0].text
assert msgs[1].role == "assistant" and "A1 reply" in msgs[1].text
assert msgs[2].role == "assistant" and msgs[2].text.startswith("Summary of users:")
async def test_sequential_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
@@ -325,92 +247,6 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
async def test_sequential_register_participants_with_checkpointing() -> None:
"""Test that checkpointing works with register_participants."""
storage = InMemoryCheckpointStorage()
def create_agent1() -> _EchoAgent:
return _EchoAgent(id="agent1", name="A1")
def create_agent2() -> _EchoAgent:
return _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participant_factories=[create_agent1, create_agent2], checkpoint_storage=storage).build()
baseline_output: list[ChatMessage] | None = None
async for ev in wf.run("checkpoint with factories", stream=True):
if ev.type == "output":
baseline_output = ev.data
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
wf_resume = SequentialBuilder(
participant_factories=[create_agent1, create_agent2], checkpoint_storage=storage
).build()
resumed_output: list[ChatMessage] | None = None
async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if ev.type == "output":
resumed_output = ev.data
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_output is not None
assert [m.role for m in resumed_output] == [m.role for m in baseline_output]
assert [m.text for m in resumed_output] == [m.text for m in baseline_output]
async def test_sequential_register_participants_factories_called_on_build() -> None:
"""Test that factories are called during build(), not during register_participants()."""
call_count = 0
def create_agent() -> _EchoAgent:
nonlocal call_count
call_count += 1
return _EchoAgent(id=f"agent{call_count}", name=f"A{call_count}")
builder = SequentialBuilder(participant_factories=[create_agent, create_agent])
# Factories should not be called yet
assert call_count == 0
wf = builder.build()
# Now factories should have been called
assert call_count == 2
# Run the workflow to ensure it works
completed = False
output: list[ChatMessage] | None = None
async for ev in wf.run("test factories timing", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
output = ev.data # type: ignore[assignment]
if completed and output is not None:
break
assert completed
assert output is not None
msgs: list[ChatMessage] = output
# Should have user message + 2 agent replies
assert len(msgs) == 3
async def test_sequential_builder_reusable_after_build_with_participants() -> None:
"""Test that the builder can be reused to build multiple identical workflows with participants()."""
a1 = _EchoAgent(id="agent1", name="A1")
@@ -423,30 +259,3 @@ async def test_sequential_builder_reusable_after_build_with_participants() -> No
assert builder._participants[0] is a1 # type: ignore
assert builder._participants[1] is a2 # type: ignore
assert builder._participant_factories == [] # type: ignore
async def test_sequential_builder_reusable_after_build_with_factories() -> None:
"""Test that the builder can be reused to build multiple workflows with register_participants()."""
call_count = 0
def create_agent1() -> _EchoAgent:
nonlocal call_count
call_count += 1
return _EchoAgent(id="agent1", name="A1")
def create_agent2() -> _EchoAgent:
nonlocal call_count
call_count += 1
return _EchoAgent(id="agent2", name="A2")
builder = SequentialBuilder(participant_factories=[create_agent1, create_agent2])
# Build first workflow - factories should be called
builder.build()
assert call_count == 2
assert builder._participants == [] # type: ignore
assert len(builder._participant_factories) == 2 # type: ignore
assert builder._participant_factories[0] is create_agent1 # type: ignore
assert builder._participant_factories[1] is create_agent2 # type: ignore