Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions python/packages/core/agent_framework/_workflows/_sequential.py
Comment thread
TaoChenOSU marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
""" # 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
Expand Down Expand Up @@ -71,11 +71,7 @@ async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[Cha
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:
# Make a copy to avoid mutation downstream
normalized = normalize_messages_input(messages)
await ctx.send_message(list(normalized))
Comment thread
TaoChenOSU marked this conversation as resolved.
Outdated
Expand Down Expand Up @@ -103,7 +99,8 @@ async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, l
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
Comment thread
TaoChenOSU marked this conversation as resolved.
- `register_participant([...])` accepts a list of factories for AgentProtocol (recommended) or Executor factories
- 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]
Expand All @@ -115,22 +112,50 @@ 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()
"""

def __init__(self) -> None:
self._participants: list[AgentProtocol | Executor] = []
self._participants_factories: list[Callable[[], AgentProtocol | Executor]] = []
self._checkpoint_storage: CheckpointStorage | None = None

def register_participants(
self,
participant_factories: list[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._participants_factories = 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._participants_factories:
raise ValueError(
"Cannot mix .participants([...]) and .register_participant() in the same builder instance."
Comment thread
TaoChenOSU marked this conversation as resolved.
Outdated
)

if not participants:
raise ValueError("participants cannot be empty")

Expand Down Expand Up @@ -168,8 +193,17 @@ def build(self) -> Workflow:
- 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._participants_factories:
raise ValueError(
"No participants or participant factories provided to the builder. "
"Use .participants([...]) or .register_participants([...])."
)

if self._participants and self._participants_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")
Expand All @@ -181,6 +215,12 @@ def build(self) -> Workflow:
# Start of the chain is the input normalizer
prior: Executor | AgentProtocol = input_conv

if self._participants_factories:
# Factory branch
for factory in self._participants_factories:
p = factory()
self._participants.append(p)

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)):
Comment thread
TaoChenOSU marked this conversation as resolved.
Outdated
Expand Down
62 changes: 0 additions & 62 deletions python/packages/core/agent_framework/_workflows/_typing_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -10,67 +9,6 @@
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.

Expand Down
168 changes: 167 additions & 1 deletion python/packages/core/tests/workflow/test_sequential.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ def test_sequential_builder_rejects_empty_participants() -> None:
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])


async def test_sequential_agents_append_to_context() -> None:
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
Expand Down Expand Up @@ -91,6 +109,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")
Expand All @@ -103,7 +152,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

Expand All @@ -117,6 +166,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()

Expand Down Expand Up @@ -229,3 +309,89 @@ 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
Loading