mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user