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

* Remove workflow register factory methods. Update tests and samples

* Address Copilot feedback
This commit is contained in:
Evan Mattson
2026-02-11 07:16:17 +09:00
committed by GitHub
Unverified
parent f407f726a7
commit a4c9e43afb
46 changed files with 650 additions and 3660 deletions
@@ -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