[BREAKING] Python: Add factory pattern to handoff orchestration builder (#2844)

* WIP: Factory pattern to handoff

* Add factory pattern to concurrent orchestration builder; Next: tests and sample verification

* Add tests and improve comments

* Fix mypy

* Simplify handoff_simple.py

* Simplify handoff_autonoumous.py and bug fix

* Update readme

* Address Copilot comments
This commit is contained in:
Tao Chen
2025-12-16 15:38:33 -08:00
committed by GitHub
Unverified
parent 2bde58f915
commit 8fca71e5ad
11 changed files with 1428 additions and 418 deletions
@@ -105,6 +105,11 @@ class AgentExecutor(Executor):
return [AgentRunResponse]
return []
@property
def description(self) -> str | None:
"""Get the description of the underlying agent."""
return self._agent.description
@handler
async def run(
self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]
File diff suppressed because it is too large Load Diff
@@ -47,15 +47,13 @@ def wrap_participant(participant: AgentProtocol | Executor, *, executor_id: str
"""Represent `participant` as an `Executor`."""
if isinstance(participant, Executor):
return participant
if not isinstance(participant, AgentProtocol):
raise TypeError(
f"Participants must implement AgentProtocol or be Executor instances. Got {type(participant).__name__}."
)
name = getattr(participant, "name", None)
if executor_id is None:
if not name:
raise ValueError("Agent participants must expose a stable 'name' attribute.")
executor_id = str(name)
executor_id = executor_id or participant.display_name
return AgentExecutor(participant, id=executor_id)
@@ -154,6 +154,9 @@ class SequentialBuilder:
"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")
@@ -171,6 +174,9 @@ class SequentialBuilder:
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
)
if self._participants:
raise ValueError("participants() has already been called on this builder instance.")
if not participants:
raise ValueError("participants cannot be empty")
@@ -162,6 +162,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
exclude={
"type",
"instructions", # included as system message
"allow_multiple_tool_calls", # handled separately
}
)
@@ -174,6 +175,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
if web_search_options:
options_dict["web_search_options"] = web_search_options
options_dict["tools"] = self._chat_to_tool_spec(chat_options.tools)
if chat_options.allow_multiple_tool_calls is not None:
options_dict["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls
if not options_dict.get("tools", None):
options_dict.pop("tools", None)
options_dict.pop("parallel_tool_calls", None)
@@ -223,7 +223,7 @@ async def test_handoff_preserves_complex_additional_properties(complex_metadata:
workflow = (
HandoffBuilder(participants=[triage, specialist])
.set_coordinator("triage")
.set_coordinator(triage)
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role == Role.USER) >= 2)
.build()
)
@@ -286,7 +286,7 @@ async def test_tool_call_handoff_detection_with_text_hint():
triage = _RecordingAgent(name="triage", handoff_to="specialist", text_handoff=True)
specialist = _RecordingAgent(name="specialist")
workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator("triage").build()
workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator(triage).build()
await _drain(workflow.run_stream("Package arrived broken"))
@@ -301,7 +301,7 @@ async def test_autonomous_interaction_mode_yields_output_without_user_request():
workflow = (
HandoffBuilder(participants=[triage, specialist])
.set_coordinator("triage")
.set_coordinator(triage)
.with_interaction_mode("autonomous", autonomous_turn_limit=1)
.build()
)
@@ -433,13 +433,13 @@ def test_build_fails_without_coordinator():
triage = _RecordingAgent(name="triage")
specialist = _RecordingAgent(name="specialist")
with pytest.raises(ValueError, match="coordinator must be defined before build"):
with pytest.raises(ValueError, match=r"Must call set_coordinator\(...\) before building the workflow."):
HandoffBuilder(participants=[triage, specialist]).build()
def test_build_fails_without_participants():
"""Verify that build() raises ValueError when no participants are provided."""
with pytest.raises(ValueError, match="No participants provided"):
with pytest.raises(ValueError, match="No participants or participant_factories have been configured."):
HandoffBuilder().build()
@@ -610,7 +610,7 @@ async def test_return_to_previous_enabled():
workflow = (
HandoffBuilder(participants=[triage, specialist_a, specialist_b])
.set_coordinator("triage")
.set_coordinator(triage)
.enable_return_to_previous(True)
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3)
.build()
@@ -643,7 +643,7 @@ def test_handoff_builder_sets_start_executor_once(monkeypatch: pytest.MonkeyPatc
workflow = (
HandoffBuilder(participants=[coordinator, specialist])
.set_coordinator("coordinator")
.set_coordinator(coordinator)
.with_termination_condition(lambda conv: len(conv) > 0)
.build()
)
@@ -703,7 +703,7 @@ async def test_handoff_builder_with_request_info():
# Build workflow with request info enabled
workflow = (
HandoffBuilder(participants=[coordinator, specialist])
.set_coordinator("coordinator")
.set_coordinator(coordinator)
.with_termination_condition(lambda conv: len([m for m in conv if m.role == Role.USER]) >= 1)
.with_request_info()
.build()
@@ -782,6 +782,497 @@ async def test_return_to_previous_state_serialization():
assert coordinator2._current_agent_id == "specialist_a", "Current agent should be restored from checkpoint" # type: ignore[reportPrivateUsage]
# 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().participant_factories({})
with pytest.raises(ValueError, match=r"No participants or participant_factories have been configured"):
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 = _RecordingAgent(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 = _RecordingAgent(name="triage")
# Case 1: participants first, then participant_factories
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(participants=[triage]).participant_factories({
"specialist": lambda: _RecordingAgent(name="specialist")
})
# Case 2: participant_factories first, then participants
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(participant_factories={"triage": lambda: triage}).participants([
_RecordingAgent(name="specialist")
])
# Case 3: participants(), then participant_factories()
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder().participants([triage]).participant_factories({
"specialist": lambda: _RecordingAgent(name="specialist")
})
# Case 4: participant_factories(), then participants()
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder().participant_factories({"triage": lambda: triage}).participants([
_RecordingAgent(name="specialist")
])
# Case 5: mix during initialization
with pytest.raises(ValueError, match="Cannot mix .participants"):
HandoffBuilder(
participants=[triage], participant_factories={"specialist": lambda: _RecordingAgent(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"participant_factories\(\) has already been called"):
(
HandoffBuilder()
.participant_factories({"agent1": lambda: _RecordingAgent(name="agent1")})
.participant_factories({"agent2": lambda: _RecordingAgent(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([_RecordingAgent(name="agent1")]).participants([_RecordingAgent(name="agent2")]))
def test_handoff_builder_rejects_duplicate_factories():
"""Test that multiple calls to participant_factories are rejected."""
factories = {
"triage": lambda: _RecordingAgent(name="triage"),
"specialist": lambda: _RecordingAgent(name="specialist"),
}
# Multiple calls to participant_factories should fail
builder = HandoffBuilder(participant_factories=factories)
with pytest.raises(ValueError, match=r"participant_factories\(\) has already been called"):
builder.participant_factories({"triage": lambda: _RecordingAgent(name="triage2")})
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() -> _RecordingAgent:
return _RecordingAgent(name="triage")
def create_specialist() -> _RecordingAgent:
return _RecordingAgent(name="specialist")
# Create an agent instance
coordinator_instance = _RecordingAgent(name="coordinator")
with pytest.raises(ValueError, match=r"Call participants\(\.\.\.\) before coordinator\(\.\.\.\)"):
(
HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist}
).set_coordinator(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 = _RecordingAgent(name="triage")
specialist = _RecordingAgent(name="specialist")
with pytest.raises(
ValueError, match="coordinator factory name 'triage' is not part of the participant_factories list"
):
(
HandoffBuilder(participants=[triage, specialist]).set_coordinator(
"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 = _RecordingAgent(name="triage")
specialist = _RecordingAgent(name="specialist")
with pytest.raises(TypeError, match="Cannot mix factory names \\(str\\) and AgentProtocol/Executor instances"):
(
HandoffBuilder(participants=[triage, specialist])
.set_coordinator(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() -> _RecordingAgent:
return _RecordingAgent(name="triage")
def create_specialist_a() -> _RecordingAgent:
return _RecordingAgent(name="specialist_a")
def create_specialist_b() -> _RecordingAgent:
return _RecordingAgent(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,
}
)
.set_coordinator("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 = _RecordingAgent(name="triage", handoff_to="specialist_a")
specialist_a = _RecordingAgent(name="specialist_a")
specialist_b = _RecordingAgent(name="specialist_b")
# This should work - all instances with participants
builder = (
HandoffBuilder(participants=[triage, specialist_a, specialist_b])
.set_coordinator(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_with_participant_factories():
"""Test workflow creation using participant_factories."""
call_count = 0
def create_triage() -> _RecordingAgent:
nonlocal call_count
call_count += 1
return _RecordingAgent(name="triage", handoff_to="specialist")
def create_specialist() -> _RecordingAgent:
nonlocal call_count
call_count += 1
return _RecordingAgent(name="specialist")
workflow = (
HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist})
.set_coordinator("triage")
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2)
.build()
)
# Factories should be called during build
assert call_count == 2
events = await _drain(workflow.run_stream("Need help"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Follow-up message
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "More details"}))
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
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() -> _RecordingAgent:
nonlocal call_count
call_count += 1
return _RecordingAgent(name="triage", handoff_to="specialist")
def create_specialist() -> _RecordingAgent:
nonlocal call_count
call_count += 1
return _RecordingAgent(name="specialist")
builder = HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist}
).set_coordinator("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() -> _RecordingAgent:
return _RecordingAgent(name="triage", handoff_to="specialist_a")
def create_specialist_a() -> _RecordingAgent:
return _RecordingAgent(name="specialist_a", handoff_to="specialist_b")
def create_specialist_b() -> _RecordingAgent:
return _RecordingAgent(name="specialist_b")
workflow = (
HandoffBuilder(
participant_factories={
"triage": create_triage,
"specialist_a": create_specialist_a,
"specialist_b": create_specialist_b,
}
)
.set_coordinator("triage")
.add_handoff("triage", ["specialist_a", "specialist_b"])
.add_handoff("specialist_a", "specialist_b")
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3)
.build()
)
# Start conversation - triage hands off to specialist_a
events = await _drain(workflow.run_stream("Initial request"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
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.send_responses_streaming({requests[-1].request_id: "Need escalation"}))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
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() -> _RecordingAgent:
return _RecordingAgent(name="triage", handoff_to="specialist")
def create_specialist() -> _RecordingAgent:
return _RecordingAgent(name="specialist")
workflow = (
HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist})
.set_coordinator("triage")
.with_checkpointing(storage)
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2)
.build()
)
# Run workflow and capture output
events = await _drain(workflow.run_stream("checkpoint test"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "follow up"}))
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
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() -> _RecordingAgent:
return _RecordingAgent(name="triage")
def create_specialist() -> _RecordingAgent:
return _RecordingAgent(name="specialist")
builder = HandoffBuilder(
participant_factories={"triage": create_triage, "specialist": create_specialist}
).set_coordinator("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() -> _RecordingAgent:
return _RecordingAgent(name="triage", handoff_to="specialist_a")
def create_specialist_a() -> _RecordingAgent:
return _RecordingAgent(name="specialist_a")
def create_specialist_b() -> _RecordingAgent:
return _RecordingAgent(name="specialist_b")
builder = (
HandoffBuilder(
participant_factories={
"triage": create_triage,
"specialist_a": create_specialist_a,
"specialist_b": create_specialist_b,
}
)
.set_coordinator("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() -> _RecordingAgent:
return _RecordingAgent(name="triage", handoff_to="specialist")
def create_specialist() -> _RecordingAgent:
return _RecordingAgent(name="specialist")
workflow = (
HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist})
.set_coordinator("triage")
.with_interaction_mode("autonomous", autonomous_turn_limit=2)
.build()
)
events = await _drain(workflow.run_stream("Issue"))
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
assert outputs, "Autonomous mode should yield output"
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert not requests, "Autonomous mode should not request user input"
async def test_handoff_participant_factories_with_request_info():
"""Test that .with_request_info() works with participant_factories."""
def create_triage() -> _RecordingAgent:
return _RecordingAgent(name="triage")
def create_specialist() -> _RecordingAgent:
return _RecordingAgent(name="specialist")
builder = (
HandoffBuilder(participant_factories={"triage": create_triage, "specialist": create_specialist})
.set_coordinator("triage")
.with_request_info(agents=["triage"])
)
workflow = builder.build()
assert "triage" in workflow.executors
def test_handoff_participant_factories_invalid_coordinator_name():
"""Test that set_coordinator raises error for non-existent factory name."""
def create_triage() -> _RecordingAgent:
return _RecordingAgent(name="triage")
with pytest.raises(
ValueError, match="coordinator factory name 'nonexistent' is not part of the participant_factories list"
):
(HandoffBuilder(participant_factories={"triage": create_triage}).set_coordinator("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() -> _RecordingAgent:
return _RecordingAgent(name="triage")
def create_specialist() -> _RecordingAgent:
return _RecordingAgent(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})
.set_coordinator("triage")
.add_handoff("triage", "nonexistent")
.build()
)
async def test_handoff_participant_factories_enable_return_to_previous():
"""Test return_to_previous works with participant_factories."""
def create_triage() -> _RecordingAgent:
return _RecordingAgent(name="triage", handoff_to="specialist_a")
def create_specialist_a() -> _RecordingAgent:
return _RecordingAgent(name="specialist_a", handoff_to="specialist_b")
def create_specialist_b() -> _RecordingAgent:
return _RecordingAgent(name="specialist_b")
workflow = (
HandoffBuilder(
participant_factories={
"triage": create_triage,
"specialist_a": create_specialist_a,
"specialist_b": create_specialist_b,
}
)
.set_coordinator("triage")
.add_handoff("triage", ["specialist_a", "specialist_b"])
.add_handoff("specialist_a", "specialist_b")
.enable_return_to_previous(True)
.with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 3)
.build()
)
# Start conversation - triage hands off to specialist_a
events = await _drain(workflow.run_stream("Initial request"))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Second user message - specialist_a hands off to specialist_b
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Need escalation"}))
requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)]
assert requests
# Third user message - should route back to specialist_b (return to previous)
events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Follow up"}))
outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)]
assert outputs or [ev for ev in events if isinstance(ev, RequestInfoEvent)]
# endregion Participant Factory Tests
async def test_handoff_user_input_request_checkpoint_excludes_conversation():
"""Test that HandoffUserInputRequest serialization excludes conversation to prevent duplication.