mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add factory pattern to sequential orchestration builder (#2710)
* Add factory pattern to sequential orchestration builder * Use temp list to avoid override * Add sample and some other fixes * Fix comments * Small fix * Update readme
This commit is contained in:
committed by
GitHub
Unverified
parent
3f4eeb00be
commit
523305ac62
@@ -37,7 +37,7 @@ confusion and to mirror how the concurrent builder uses explicit dispatcher/aggr
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage
|
||||
@@ -72,11 +72,7 @@ class _InputToConversation(Executor):
|
||||
await ctx.send_message(normalize_messages_input(message))
|
||||
|
||||
@handler
|
||||
async def from_messages(
|
||||
self,
|
||||
messages: list[str | ChatMessage],
|
||||
ctx: WorkflowContext[list[ChatMessage]],
|
||||
) -> None:
|
||||
async def from_messages(self, messages: list[str | ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
await ctx.send_message(normalize_messages_input(messages))
|
||||
|
||||
|
||||
@@ -102,7 +98,10 @@ class _EndWithConversation(Executor):
|
||||
class SequentialBuilder:
|
||||
r"""High-level builder for sequential agent/executor workflows with shared context.
|
||||
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor
|
||||
- `participants([...])` accepts a list of AgentProtocol (recommended) or Executor instances
|
||||
- `register_participants([...])` accepts a list of factories for AgentProtocol (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
|
||||
- Custom executors can transform/summarize and return a list[ChatMessage]
|
||||
@@ -114,8 +113,14 @@ class SequentialBuilder:
|
||||
|
||||
from agent_framework import SequentialBuilder
|
||||
|
||||
# With agent instances
|
||||
workflow = SequentialBuilder().participants([agent1, agent2, summarizer_exec]).build()
|
||||
|
||||
# With agent factories
|
||||
workflow = (
|
||||
SequentialBuilder().register_participants([create_agent1, create_agent2, create_summarizer_exec]).build()
|
||||
)
|
||||
|
||||
# Enable checkpoint persistence
|
||||
workflow = SequentialBuilder().participants([agent1, agent2]).with_checkpointing(storage).build()
|
||||
|
||||
@@ -133,16 +138,38 @@ class SequentialBuilder:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._participants: list[AgentProtocol | Executor] = []
|
||||
self._participant_factories: list[Callable[[], AgentProtocol | Executor]] = []
|
||||
self._checkpoint_storage: CheckpointStorage | None = None
|
||||
self._request_info_enabled: bool = False
|
||||
self._request_info_filter: set[str] | None = None
|
||||
|
||||
def register_participants(
|
||||
self,
|
||||
participant_factories: Sequence[Callable[[], AgentProtocol | Executor]],
|
||||
) -> "SequentialBuilder":
|
||||
"""Register participant factories for this sequential workflow."""
|
||||
if self._participants:
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
if not participant_factories:
|
||||
raise ValueError("participant_factories cannot be empty")
|
||||
|
||||
self._participant_factories = list(participant_factories)
|
||||
return self
|
||||
|
||||
def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "SequentialBuilder":
|
||||
"""Define the ordered participants for this sequential workflow.
|
||||
|
||||
Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances.
|
||||
Raises if empty or duplicates are provided for clarity.
|
||||
"""
|
||||
if self._participant_factories:
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
if not participants:
|
||||
raise ValueError("participants cannot be empty")
|
||||
|
||||
@@ -217,13 +244,22 @@ class SequentialBuilder:
|
||||
- _InputToConversation normalizes the initial input into list[ChatMessage]
|
||||
- For each participant in order:
|
||||
- If Agent (or AgentExecutor): pass conversation to the agent, then optionally
|
||||
route through human input interceptor, then convert response to conversation
|
||||
route through a request info interceptor, then convert response to conversation
|
||||
via _ResponseToConversation
|
||||
- Else (custom Executor): pass conversation directly to the executor
|
||||
- _EndWithConversation yields the final conversation and the workflow becomes idle
|
||||
"""
|
||||
if not self._participants:
|
||||
raise ValueError("No participants provided. Call .participants([...]) first.")
|
||||
if not self._participants and not self._participant_factories:
|
||||
raise ValueError(
|
||||
"No participants or participant factories provided to the builder. "
|
||||
"Use .participants([...]) or .ss([...])."
|
||||
)
|
||||
|
||||
if self._participants and self._participant_factories:
|
||||
# Defensive strategy: this should never happen due to checks in respective methods
|
||||
raise ValueError(
|
||||
"Cannot mix .participants([...]) and .register_participants() in the same builder instance."
|
||||
)
|
||||
|
||||
# Internal nodes
|
||||
input_conv = _InputToConversation(id="input-conversation")
|
||||
@@ -235,13 +271,17 @@ class SequentialBuilder:
|
||||
# Start of the chain is the input normalizer
|
||||
prior: Executor | AgentProtocol = input_conv
|
||||
|
||||
for p in self._participants:
|
||||
# Agent-like branch: either explicitly an AgentExecutor or any non-AgentExecutor
|
||||
if not (isinstance(p, Executor) and not isinstance(p, AgentExecutor)):
|
||||
# input conversation -> [human_input_interceptor] -> (agent) -> response -> conversation
|
||||
label: str
|
||||
label = p.id if isinstance(p, Executor) else getattr(p, "name", None) or p.__class__.__name__
|
||||
resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}")
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
for factory in self._participant_factories:
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
participants = self._participants
|
||||
|
||||
for p in participants:
|
||||
if isinstance(p, (AgentProtocol, AgentExecutor)):
|
||||
label = p.id if isinstance(p, AgentExecutor) else p.display_name
|
||||
|
||||
if self._request_info_enabled:
|
||||
# Insert request info interceptor BEFORE the agent
|
||||
@@ -254,13 +294,15 @@ class SequentialBuilder:
|
||||
else:
|
||||
builder.add_edge(prior, p)
|
||||
|
||||
resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}")
|
||||
builder.add_edge(p, resp_to_conv)
|
||||
prior = resp_to_conv
|
||||
elif isinstance(p, Executor):
|
||||
# Custom executor operates on list[ChatMessage]
|
||||
# If the executor doesn't handle list[ChatMessage] correctly, validation will fail
|
||||
builder.add_edge(prior, p)
|
||||
prior = p
|
||||
else: # pragma: no cover - defensive
|
||||
else:
|
||||
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
|
||||
|
||||
# Terminate with the final conversation
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from dataclasses import fields, is_dataclass
|
||||
from types import UnionType
|
||||
from typing import Any, TypeVar, Union, cast, get_args, get_origin
|
||||
|
||||
@@ -10,67 +9,6 @@ logger = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _coerce_to_type(value: Any, target_type: type[T]) -> T | None:
|
||||
"""Best-effort conversion of value into target_type.
|
||||
|
||||
Args:
|
||||
value: The value to convert (can be dict, dataclass, or object with __dict__)
|
||||
target_type: The target type to convert to
|
||||
|
||||
Returns:
|
||||
Instance of target_type if conversion succeeds, None otherwise
|
||||
"""
|
||||
if isinstance(value, target_type):
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
# Convert dataclass instances or objects with __dict__ into dict first
|
||||
value_as_dict: dict[str, Any]
|
||||
if not isinstance(value, dict):
|
||||
if is_dataclass(value):
|
||||
value_as_dict = {f.name: getattr(value, f.name) for f in fields(value)}
|
||||
else:
|
||||
value_dict = getattr(value, "__dict__", None)
|
||||
if isinstance(value_dict, dict):
|
||||
value_as_dict = cast(dict[str, Any], value_dict)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
value_as_dict = cast(dict[str, Any], value)
|
||||
|
||||
# Try to construct the target type from the dict
|
||||
ctor_kwargs: dict[str, Any] = dict(value_as_dict)
|
||||
|
||||
if is_dataclass(target_type):
|
||||
field_names = {f.name for f in fields(target_type)}
|
||||
ctor_kwargs = {k: v for k, v in value_as_dict.items() if k in field_names}
|
||||
|
||||
try:
|
||||
return target_type(**ctor_kwargs) # type: ignore[call-arg,return-value]
|
||||
except TypeError as exc:
|
||||
logger.debug(f"_coerce_to_type could not call {target_type.__name__}(**..): {exc}")
|
||||
except Exception as exc: # pragma: no cover - unexpected constructor failure
|
||||
logger.warning(
|
||||
f"_coerce_to_type encountered unexpected error calling {target_type.__name__} constructor: {exc}"
|
||||
)
|
||||
|
||||
# Fallback: try to create instance without __init__ and set attributes
|
||||
try:
|
||||
instance = object.__new__(target_type)
|
||||
except Exception as exc: # pragma: no cover - pathological type
|
||||
logger.debug(f"_coerce_to_type could not allocate {target_type.__name__} without __init__: {exc}")
|
||||
return None
|
||||
|
||||
for key, val in value_as_dict.items():
|
||||
try:
|
||||
setattr(instance, key, val)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
f"_coerce_to_type could not set {target_type.__name__}.{key} during fallback assignment: {exc}"
|
||||
)
|
||||
continue
|
||||
return instance # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
|
||||
"""Check if the data is an instance of the target type.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from agent_framework import (
|
||||
Role,
|
||||
SequentialBuilder,
|
||||
TextContent,
|
||||
TypeCompatibilityError,
|
||||
WorkflowContext,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
@@ -58,11 +59,43 @@ class _SummarizerExec(Executor):
|
||||
await ctx.send_message(list(conversation) + [summary])
|
||||
|
||||
|
||||
class _InvalidExecutor(Executor):
|
||||
"""Invalid executor that does not have a handler that accepts a list of chat messages"""
|
||||
|
||||
@handler
|
||||
async def summarize(self, conversation: list[str], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_sequential_builder_rejects_empty_participants() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SequentialBuilder().participants([])
|
||||
|
||||
|
||||
def test_sequential_builder_rejects_empty_participant_factories() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
SequentialBuilder().register_participants([])
|
||||
|
||||
|
||||
def test_sequential_builder_rejects_mixing_participants_and_factories() -> None:
|
||||
"""Test that mixing .participants() and .register_participants() raises an error."""
|
||||
a1 = _EchoAgent(id="agent1", name="A1")
|
||||
|
||||
# Try .participants() then .register_participants()
|
||||
with pytest.raises(ValueError, match="Cannot mix"):
|
||||
SequentialBuilder().participants([a1]).register_participants([lambda: _EchoAgent(id="agent2", name="A2")])
|
||||
|
||||
# Try .register_participants() then .participants()
|
||||
with pytest.raises(ValueError, match="Cannot mix"):
|
||||
SequentialBuilder().register_participants([lambda: _EchoAgent(id="agent1", name="A1")]).participants([a1])
|
||||
|
||||
|
||||
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):
|
||||
SequentialBuilder().participants([_EchoAgent(id="agent1", name="A1"), _InvalidExecutor(id="invalid")]).build()
|
||||
|
||||
|
||||
async def test_sequential_agents_append_to_context() -> None:
|
||||
a1 = _EchoAgent(id="agent1", name="A1")
|
||||
a2 = _EchoAgent(id="agent2", name="A2")
|
||||
@@ -91,6 +124,37 @@ 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().register_participants([create_agent1, create_agent2]).build()
|
||||
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("hello factories"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
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 == Role.USER and "hello factories" in msgs[0].text
|
||||
assert msgs[1].role == Role.ASSISTANT and "A1 reply" in msgs[1].text
|
||||
assert msgs[2].role == 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")
|
||||
@@ -103,7 +167,7 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
output = ev.data # type: ignore[assignment]
|
||||
output = ev.data
|
||||
if completed and output is not None:
|
||||
break
|
||||
|
||||
@@ -117,6 +181,37 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
assert msgs[2].role == 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().register_participants([create_agent, create_summarizer]).build()
|
||||
|
||||
completed = False
|
||||
output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("topic Y"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
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 == Role.USER and "topic Y" in msgs[0].text
|
||||
assert msgs[1].role == Role.ASSISTANT and "A1 reply" in msgs[1].text
|
||||
assert msgs[2].role == Role.ASSISTANT and msgs[2].text.startswith("Summary of users:")
|
||||
|
||||
|
||||
async def test_sequential_checkpoint_resume_round_trip() -> None:
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
@@ -229,3 +324,130 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
|
||||
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
|
||||
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().register_participants([create_agent1, create_agent2]).with_checkpointing(storage).build()
|
||||
|
||||
baseline_output: list[ChatMessage] | None = None
|
||||
async for ev in wf.run_stream("checkpoint with factories"):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
baseline_output = ev.data
|
||||
if isinstance(ev, WorkflowStatusEvent) 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().register_participants([create_agent1, create_agent2]).with_checkpointing(storage).build()
|
||||
)
|
||||
|
||||
resumed_output: list[ChatMessage] | None = None
|
||||
async for ev in wf_resume.run_stream(checkpoint_id=resume_checkpoint.checkpoint_id):
|
||||
if isinstance(ev, WorkflowOutputEvent):
|
||||
resumed_output = ev.data
|
||||
if isinstance(ev, WorkflowStatusEvent) 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().register_participants([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_stream("test factories timing"):
|
||||
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
|
||||
completed = True
|
||||
elif isinstance(ev, WorkflowOutputEvent):
|
||||
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")
|
||||
a2 = _EchoAgent(id="agent2", name="A2")
|
||||
|
||||
builder = SequentialBuilder().participants([a1, a2])
|
||||
|
||||
# Build first workflow
|
||||
builder.build()
|
||||
|
||||
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().register_participants([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