chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+121
View File
@@ -0,0 +1,121 @@
# Agent Framework Orchestrations
Orchestration patterns for Microsoft Agent Framework. This package provides high-level builders for common multi-agent workflow patterns.
## Installation
```bash
pip install agent-framework-orchestrations
```
## Orchestration Patterns
### SequentialBuilder
Chain agents/executors in sequence, passing conversation context along:
```python
from agent_framework.orchestrations import SequentialBuilder
workflow = SequentialBuilder(participants=[agent1, agent2, agent3]).build()
# Preserve agent1 and agent2 as visible progress, while the default builder output remains Workflow Output.
workflow = SequentialBuilder(
participants=[agent1, agent2, agent3],
intermediate_output_from=[agent1, agent2],
).build()
```
### ConcurrentBuilder
Fan-out to multiple agents in parallel, then aggregate results:
```python
from agent_framework.orchestrations import ConcurrentBuilder
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).build()
```
### HandoffBuilder
Decentralized agent routing where agents decide handoff targets:
```python
from agent_framework.orchestrations import HandoffBuilder
workflow = (
HandoffBuilder()
.participants([triage, billing, support])
.with_start_agent(triage)
.build()
)
```
### GroupChatBuilder
Orchestrator-directed multi-agent conversations:
```python
from agent_framework.orchestrations import GroupChatBuilder
workflow = GroupChatBuilder(
participants=[agent1, agent2],
selection_func=my_selector,
intermediate_output_from=[agent1, agent2],
).build()
```
### MagenticBuilder
Sophisticated multi-agent orchestration using the Magentic One pattern:
```python
from agent_framework.orchestrations import MagenticBuilder
workflow = MagenticBuilder(
participants=[researcher, writer, reviewer],
manager_agent=manager_agent,
intermediate_output_from=[researcher, writer, reviewer],
).build()
```
## Output Selection
Orchestration builders expose Workflow Output selection using participant names. The core rule is that `output_from`
is an allow-list for Workflow Output, not a routing rule for every other participant output. Unselected participant
payloads are hidden unless `intermediate_output_from` explicitly selects them as Intermediate Output.
- `output_from` designates participant emissions as Workflow Output (`type='output'` events).
- `intermediate_output_from` designates participant emissions as Intermediate Output (`type='intermediate'` events).
If neither list is provided, each builder uses its documented default Workflow Output contract. Sequential emits the
last participant; Concurrent, GroupChat, and Magentic emit their aggregator/orchestrator/manager output; Handoff emits
participants.
| Selection | Workflow Output | Intermediate Output | Hidden payloads |
| --- | --- | --- | --- |
| Omit both selections | Builder default Workflow Output contract | None | Builder-specific non-output participant payloads |
| `output_from="all"` | Every output-capable participant | None | None |
| `output_from=[writer]` | Only `writer` | None | All other participant payloads |
| `output_from=[writer], intermediate_output_from="all_other"` | Only `writer` | Every output-capable participant not selected by `output_from` | None |
| `intermediate_output_from="all_other"` | None, except builder-internal default output executors where applicable | Every output-capable participant | Builder-internal plumbing payloads |
| `output_from=[], intermediate_output_from="all_other"` | None, except builder-internal default output executors where applicable | Every output-capable participant | Builder-internal plumbing payloads |
| `output_from=[writer], intermediate_output_from=[researcher, reviewer]` | Only `writer` | `researcher` and `reviewer` | Any other participant payloads |
Invalid selections fail at construction or build time:
| Invalid selection | Why it fails |
| --- | --- |
| `output_from="all_other"` | `"all_other"` is only valid for `intermediate_output_from` |
| `intermediate_output_from="all"` | `"all"` is only valid for `output_from` |
| The same participant in both selections | One payload cannot be both Workflow Output and Intermediate Output |
| Duplicate participant selections | Duplicates are treated as configuration errors |
| Unknown participant selections | Typos and missing participants are rejected |
| `output_from=[], intermediate_output_from=[]` | Both explicit selections are empty |
When an orchestration is wrapped with `workflow.as_agent()`, Workflow Output becomes normal response text. Intermediate
Output becomes `text_reasoning` content so callers can inspect progress without changing `.text` behavior.
## Documentation
For more information, see the [Agent Framework documentation](https://aka.ms/agent-framework).
@@ -0,0 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.
"""Orchestration patterns for Microsoft Agent Framework.
This package provides high-level builders for common multi-agent workflow patterns:
- SequentialBuilder: Chain agents in sequence
- ConcurrentBuilder: Fan-out to multiple agents in parallel
- HandoffBuilder: Decentralized agent routing
- GroupChatBuilder: Orchestrator-directed multi-agent conversations
- MagenticBuilder: Magentic One pattern for sophisticated multi-agent orchestration
"""
import importlib.metadata
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
from ._base_group_chat_orchestrator import (
BaseGroupChatOrchestrator,
GroupChatRequestMessage,
GroupChatRequestSentEvent,
GroupChatResponseReceivedEvent,
TerminationCondition,
)
from ._concurrent import ConcurrentBuilder
from ._group_chat import (
AgentBasedGroupChatOrchestrator,
AgentOrchestrationOutput,
GroupChatBuilder,
GroupChatOrchestrator,
GroupChatSelectionFunction,
GroupChatState,
)
from ._handoff import (
HandoffAgentExecutor,
HandoffAgentUserRequest,
HandoffBuilder,
HandoffConfiguration,
HandoffSentEvent,
)
from ._magentic import (
MAGENTIC_MANAGER_NAME,
ORCH_MSG_KIND_INSTRUCTION,
ORCH_MSG_KIND_NOTICE,
ORCH_MSG_KIND_TASK_LEDGER,
ORCH_MSG_KIND_USER_TASK,
MagenticAgentExecutor,
MagenticBuilder,
MagenticContext,
MagenticManagerBase,
MagenticOrchestrator,
MagenticOrchestratorEvent,
MagenticOrchestratorEventType,
MagenticPlanReviewRequest,
MagenticPlanReviewResponse,
MagenticProgressLedger,
MagenticProgressLedgerItem,
MagenticResetSignal,
StandardMagenticManager,
)
from ._orchestration_request_info import AgentRequestInfoResponse
from ._orchestration_state import OrchestrationState
from ._orchestrator_helpers import clean_conversation_for_handoff, create_completion_message
from ._sequential import SequentialBuilder
__all__ = [
"MAGENTIC_MANAGER_NAME",
"ORCH_MSG_KIND_INSTRUCTION",
"ORCH_MSG_KIND_NOTICE",
"ORCH_MSG_KIND_TASK_LEDGER",
"ORCH_MSG_KIND_USER_TASK",
"AgentBasedGroupChatOrchestrator",
"AgentOrchestrationOutput",
"AgentRequestInfoResponse",
"BaseGroupChatOrchestrator",
"ConcurrentBuilder",
"GroupChatBuilder",
"GroupChatOrchestrator",
"GroupChatRequestMessage",
"GroupChatRequestSentEvent",
"GroupChatResponseReceivedEvent",
"GroupChatSelectionFunction",
"GroupChatState",
"HandoffAgentExecutor",
"HandoffAgentUserRequest",
"HandoffBuilder",
"HandoffConfiguration",
"HandoffSentEvent",
"MagenticAgentExecutor",
"MagenticBuilder",
"MagenticContext",
"MagenticManagerBase",
"MagenticOrchestrator",
"MagenticOrchestratorEvent",
"MagenticOrchestratorEventType",
"MagenticPlanReviewRequest",
"MagenticPlanReviewResponse",
"MagenticProgressLedger",
"MagenticProgressLedgerItem",
"MagenticResetSignal",
"OrchestrationState",
"SequentialBuilder",
"StandardMagenticManager",
"TerminationCondition",
"__version__",
"clean_conversation_for_handoff",
"create_completion_message",
]
@@ -0,0 +1,600 @@
# Copyright (c) Microsoft. All rights reserved.
"""Base class for group chat orchestrators that manages conversation flow and participant selection."""
import asyncio
import inspect
import logging
import sys
from abc import ABC
from collections import OrderedDict
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from typing import Any, ClassVar, TypeAlias
from agent_framework._types import AgentResponse, AgentResponseUpdate, Message
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._events import WorkflowEvent
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._workflow_context import WorkflowContext
from typing_extensions import Never
from ._orchestration_request_info import AgentApprovalExecutor
if sys.version_info >= (3, 12):
from typing import override # pragma: no cover
else:
from typing_extensions import override # pragma: no cover
logger = logging.getLogger(__name__)
@dataclass
class GroupChatRequestMessage:
"""Request envelope sent from the orchestrator to a participant."""
additional_instruction: str | None = None
metadata: dict[str, Any] | None = None
@dataclass
class GroupChatParticipantMessage:
"""Message envelop containing messages generated by a participant.
This message envelope is used to broadcast messages from one participant
to other participants in the group chat to keep them synchronized.
"""
messages: list[Message]
@dataclass
class GroupChatResponseMessage:
"""Response envelope emitted by participants back to the orchestrator."""
message: Message
TerminationCondition: TypeAlias = Callable[[list[Message]], bool | Awaitable[bool]]
GroupChatWorkflowContextOutT: TypeAlias = AgentExecutorRequest | GroupChatRequestMessage | GroupChatParticipantMessage
# region Group chat events
@dataclass
class GroupChatRequestSentEvent:
"""Data payload for group_chat request sent events."""
round_index: int
participant_name: str
@dataclass
class GroupChatResponseReceivedEvent:
"""Data payload for group_chat response received events."""
round_index: int
participant_name: str
# endregion
# region Participant registry
class ParticipantRegistry:
"""Simple registry for tracking group chat participants and their types and other properties."""
EMPTY_DESCRIPTION_PLACEHOLDER: ClassVar[str] = (
"<no description, use name to identify the purpose of this participant>"
)
def __init__(self, participants: Sequence[Executor]) -> None:
"""Initialize the registry and validate participant IDs.
Args:
participants: List of executors (agents or custom executors) to register
Raises:
ValueError: If there are duplicate or conflicting participant IDs
"""
self._agents: set[str] = set()
self._participants: OrderedDict[str, str] = OrderedDict()
self._resolve_participants(participants)
def _resolve_participants(self, participants: Sequence[Executor]) -> None:
"""Register participants and validate IDs."""
for participant in participants:
if participant.id in self._participants:
raise ValueError(f"Participant ID conflict: '{participant.id}' registered as both agent and executor.")
if isinstance(participant, AgentExecutor | AgentApprovalExecutor):
self._agents.add(participant.id)
self._participants[participant.id] = participant.description or self.EMPTY_DESCRIPTION_PLACEHOLDER
else:
self._participants[participant.id] = self.EMPTY_DESCRIPTION_PLACEHOLDER
def is_agent(self, name: str) -> bool:
"""Check if a participant is an agent (vs custom executor)."""
return name in self._agents
@property
def participants(self) -> OrderedDict[str, str]:
"""Get all registered participant names and descriptions in an ordered dictionary."""
return self._participants
# endregion
class BaseGroupChatOrchestrator(Executor, ABC):
"""Abstract base class for group chat orchestrators.
Provides shared functionality for participant registration, routing,
and round limit checking that is common across all group chat patterns.
Subclasses must implement pattern-specific orchestration logic while
inheriting the common participant management infrastructure.
"""
TERMINATION_CONDITION_MET_MESSAGE: ClassVar[str] = "The group chat has reached its termination condition."
MAX_ROUNDS_MET_MESSAGE: ClassVar[str] = "The group chat has reached the maximum number of rounds."
def __init__(
self,
id: str,
participant_registry: ParticipantRegistry,
*,
name: str | None = None,
max_rounds: int | None = None,
termination_condition: TerminationCondition | None = None,
) -> None:
"""Initialize base orchestrator.
Args:
id: Unique identifier for this orchestrator executor
participant_registry: Registry of group chat participants that tracks their types (agents
vs custom executors)
name: Optional display name for orchestrator messages
max_rounds: Optional maximum number of conversation rounds.
Must be equal to or greater than 1 if set. Number smaller than 1 will be coerced to 1.
termination_condition: Optional callable to determine conversation termination
"""
super().__init__(id)
self._name = name or id
self._max_rounds = max(1, max_rounds) if max_rounds is not None else None
self._termination_condition = termination_condition
self._round_index: int = 0
self._participant_registry = participant_registry
# Shared conversation state management
self._full_conversation: list[Message] = []
# region Handlers
@handler
async def handle_str(
self,
task: str,
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]],
) -> None:
"""Handler for string input as workflow entry point.
Wraps the string in a USER role Message and delegates to _handle_task_message.
Args:
task: Plain text task description from user
ctx: Workflow context
Usage:
workflow.run("Write a blog post about AI agents")
"""
await self._handle_messages([Message(role="user", contents=[task])], ctx)
@handler
async def handle_message(
self,
task: Message,
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]],
) -> None:
"""Handler for single Message input as workflow entry point.
Wraps the message in a list and delegates to _handle_task_message.
Args:
task: Message from user
ctx: Workflow context
Usage:
workflow.run(Message(role="user", contents=["Write a blog post about AI agents"]))
"""
await self._handle_messages([task], ctx)
@handler
async def handle_messages(
self,
task: list[Message],
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]],
) -> None:
"""Handler for list of ChatMessages as workflow entry point.
Delegates to _handle_task_message.
Args:
task: List of ChatMessages from user
ctx: Workflow context
Usage:
workflow.run([
Message(role="user", contents=["Write a blog post about AI agents"]),
Message(role="user", contents=["Make it engaging and informative."])
])
"""
if not task:
raise ValueError("At least one Message is required to start the group chat workflow.")
await self._handle_messages(task, ctx)
@handler
async def handle_participant_response(
self,
response: AgentExecutorResponse | GroupChatResponseMessage,
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]],
) -> None:
"""Handler for participant responses.
This method can be overridden by subclasses if specific response handling is needed.
Args:
response: Response from a participant
ctx: Workflow context
"""
await ctx.add_event(
WorkflowEvent(
"group_chat",
data=GroupChatResponseReceivedEvent(
round_index=self._round_index,
participant_name=ctx.source_executor_ids[0] if ctx.source_executor_ids else "unknown",
),
)
)
await self._handle_response(response, ctx)
# endregion
# region Handler methods subclasses must implement
async def _handle_messages(
self,
messages: list[Message],
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]],
) -> None:
"""Handle task messages from users as workflow entry point.
Subclasses must implement this method to define pattern-specific orchestration logic.
Args:
messages: Task messages from user
ctx: Workflow context
"""
raise NotImplementedError("_handle_messages must be implemented by subclasses.")
async def _handle_response(
self,
response: AgentExecutorResponse | GroupChatResponseMessage,
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[Message]],
) -> None:
"""Handle a participant response.
Subclasses must implement this method to define pattern-specific response handling logic.
Args:
response: Response from a participant
ctx: Workflow context
"""
raise NotImplementedError("_handle_response must be implemented by subclasses.")
# endregion
# Conversation state management (shared across all patterns)
def _append_messages(self, messages: Sequence[Message]) -> None:
"""Append messages to the conversation history.
Args:
messages: Messages to append
"""
self._full_conversation.extend(messages)
def _get_conversation(self) -> list[Message]:
"""Get a copy of the current conversation.
Returns:
Cloned conversation list
"""
return list(self._full_conversation)
def _process_participant_response(
self, response: AgentExecutorResponse | GroupChatResponseMessage
) -> list[Message]:
"""Extract Message from participant response.
Args:
response: Response from participant
Returns:
List of ChatMessages extracted from the response
"""
if isinstance(response, AgentExecutorResponse):
return response.agent_response.messages
if isinstance(response, GroupChatResponseMessage):
return [response.message]
raise TypeError(f"Unsupported response type: {type(response)}")
def _clear_conversation(self) -> None:
"""Clear the conversation history."""
self._full_conversation.clear()
def _increment_round(self) -> None:
"""Increment the round counter."""
self._round_index += 1
async def _check_termination(self) -> bool:
"""Check if conversation should terminate based on termination condition.
Supports both synchronous and asynchronous termination conditions.
Returns:
True if termination condition met, False otherwise
"""
if self._termination_condition is None:
return False
result = self._termination_condition(self._get_conversation())
if inspect.isawaitable(result):
result = await result
return result
async def _check_terminate_and_yield(
self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate]
) -> bool:
"""Check termination conditions and yield the completion message if met.
Args:
ctx: Workflow context for yielding output
Returns:
True if termination condition met and output yielded, False otherwise
"""
terminate = await self._check_termination()
if terminate:
completion_message = self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE)
self._append_messages([completion_message])
await self._yield_completion(ctx, completion_message)
return True
return False
async def _yield_completion(
self,
ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate],
completion_message: Message,
) -> None:
"""Yield a synthesized terminal completion message in the right shape for the run mode.
Mode-aware to mirror ``AgentExecutor`` semantics:
- Streaming (``ctx.is_streaming()``): yield a single ``AgentResponseUpdate`` so the
``output`` event stream stays uniformly per-chunk.
- Non-streaming: yield the full ``AgentResponse``.
"""
if ctx.is_streaming():
await ctx.yield_output(
AgentResponseUpdate(
contents=list(completion_message.contents),
role=completion_message.role,
author_name=completion_message.author_name,
message_id=completion_message.message_id,
)
)
else:
await ctx.yield_output(AgentResponse(messages=[completion_message]))
def _create_completion_message(self, message: str) -> Message:
"""Create a standardized completion message.
Args:
message: Completion text
Returns:
Message with completion content
"""
return Message(role="assistant", contents=[message], author_name=self._name)
# Participant routing (shared across all patterns)
async def _broadcast_messages_to_participants(
self,
messages: list[Message],
ctx: WorkflowContext[AgentExecutorRequest | GroupChatParticipantMessage],
participants: Sequence[str] | None = None,
) -> None:
"""Broadcast messages to participants.
This method sends the given messages to all registered participants
or a specified subset. This acts as a message broadcast mechanism for
participants in the group chat to stay synchronized.
Args:
messages: Messages to send
ctx: Workflow context for message broadcasting
participants: Optional list of participant names to route to.
If None, routes to all registered participants.
"""
target_participants = (
participants if participants is not None else list(self._participant_registry.participants)
)
async def _send_messages(target: str) -> None:
if self._participant_registry.is_agent(target):
# Send messages without requesting a response
await ctx.send_message(AgentExecutorRequest(messages=messages, should_respond=False), target_id=target)
else:
# Send messages wrapped in GroupChatParticipantMessage
await ctx.send_message(GroupChatParticipantMessage(messages=messages), target_id=target)
await asyncio.gather(*[_send_messages(p) for p in target_participants])
async def _send_request_to_participant(
self,
target: str,
ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage],
*,
additional_instruction: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Send a request to a participant.
This method handles the dual envelope pattern:
- AgentExecutors receive AgentExecutorRequest (messages only)
- Custom executors receive GroupChatRequestMessage (full context)
Args:
target: Name of the participant to route to
ctx: Workflow context for message routing
additional_instruction: Optional additional instruction for the participant.
This can be used to provide guidance to steer the participant's response.
metadata: Optional metadata dict
Raises:
ValueError: If participant is not registered
"""
if self._participant_registry.is_agent(target):
# AgentExecutors receive simple message list
messages: list[Message] = []
if additional_instruction:
messages.append(Message(role="user", contents=[additional_instruction]))
request = AgentExecutorRequest(messages=messages, should_respond=True)
await ctx.send_message(request, target_id=target)
await ctx.add_event(
WorkflowEvent(
"group_chat",
data=GroupChatRequestSentEvent(
round_index=self._round_index,
participant_name=target,
),
)
)
else:
# Custom executors receive full context envelope
request = GroupChatRequestMessage(additional_instruction=additional_instruction, metadata=metadata)
await ctx.send_message(request, target_id=target)
await ctx.add_event(
WorkflowEvent(
"group_chat",
data=GroupChatRequestSentEvent(
round_index=self._round_index,
participant_name=target,
),
)
)
# Round limit enforcement (shared across all patterns)
def _check_round_limit(self) -> bool:
"""Check if round limit has been reached.
Uses instance variables _round_index and _max_rounds.
Returns:
True if limit reached, False otherwise
"""
if self._max_rounds is None:
return False
if self._round_index >= self._max_rounds:
logger.warning(
"%s reached max_rounds=%s; forcing completion.",
self.__class__.__name__,
self._max_rounds,
)
return True
return False
async def _check_round_limit_and_yield(
self, ctx: WorkflowContext[Never, AgentResponse | AgentResponseUpdate]
) -> bool:
"""Check round limit and yield the max-rounds completion message if reached.
Args:
ctx: Workflow context for yielding output
Returns:
True if round limit reached and output yielded, False otherwise
"""
reach_max_rounds = self._check_round_limit()
if reach_max_rounds:
completion_message = self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE)
self._append_messages([completion_message])
await self._yield_completion(ctx, completion_message)
return True
return False
# State persistence (shared across all patterns)
# State persistence (shared across all patterns)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Capture current orchestrator state for checkpointing.
Default implementation uses OrchestrationState to serialize common state.
Subclasses can override this method or _snapshot_pattern_metadata() to add pattern-specific data.
Returns:
Serialized state dict
"""
from ._orchestration_state import OrchestrationState
state = OrchestrationState(
conversation=list(self._full_conversation),
round_index=self._round_index,
orchestrator_name=self._name,
metadata=self._snapshot_pattern_metadata(),
)
return state.to_dict()
def _snapshot_pattern_metadata(self) -> dict[str, Any]:
"""Serialize pattern-specific state.
Override this method to add pattern-specific checkpoint data.
Returns:
Dict with pattern-specific state (empty by default)
"""
return {}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore orchestrator state from checkpoint.
Default implementation uses OrchestrationState to deserialize common state.
Subclasses can override this method or _restore_pattern_metadata() to restore pattern-specific data.
Args:
state: Serialized state dict
"""
from ._orchestration_state import OrchestrationState
orch_state = OrchestrationState.from_dict(state)
self._full_conversation = list(orch_state.conversation)
self._round_index = orch_state.round_index
self._name = orch_state.orchestrator_name
self._restore_pattern_metadata(orch_state.metadata)
def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None:
"""Restore pattern-specific state.
Override this method to restore pattern-specific checkpoint data.
Args:
metadata: Pattern-specific state dict
"""
pass
@@ -0,0 +1,431 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
import logging
from collections.abc import Callable, Sequence
from typing import Any, Literal, cast
from agent_framework import AgentResponse, Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._checkpoint import CheckpointStorage
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._message_utils import normalize_messages_input
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
from typing_extensions import Never
from ._orchestration_request_info import AgentApprovalExecutor
from ._participant_output_config import (
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
logger = logging.getLogger(__name__)
"""Concurrent builder for agent-only fan-out/fan-in workflows.
This module provides a high-level, agent-focused API to quickly assemble a
parallel workflow with:
- a default dispatcher that broadcasts the input to all agent participants
- a default aggregator that combines all agent conversations and completes the workflow
Notes:
- Participants can be provided as SupportsAgentRun or Executor instances via `participants=[...]`.
- A custom aggregator can be provided as:
- an Executor instance (it should handle list[AgentExecutorResponse],
yield output), or
- a callback function with signature:
def cb(results: list[AgentExecutorResponse]) -> Any | None
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None
The callback is wrapped in _CallbackAggregator.
If the callback returns a non-None value, _CallbackAggregator yields that as output.
If it returns None, the callback may have already yielded an output via ctx, so no further action is taken.
"""
class _DispatchToAllParticipants(Executor):
"""Broadcasts input to all downstream participants (via fan-out edges)."""
@handler
async def from_request(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# No explicit target: edge routing delivers to all connected participants.
await ctx.send_message(request)
@handler
async def from_str(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
request = AgentExecutorRequest(messages=normalize_messages_input(prompt), should_respond=True)
await ctx.send_message(request)
@handler
async def from_message(self, message: Message, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
request = AgentExecutorRequest(messages=normalize_messages_input(message), should_respond=True)
await ctx.send_message(request)
@handler
async def from_messages(
self,
messages: list[str | Message],
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
request = AgentExecutorRequest(messages=normalize_messages_input(messages), should_respond=True)
await ctx.send_message(request)
class _AggregateAgentConversations(Executor):
"""Aggregates agent responses and completes with a single AgentResponse.
Emits an `AgentResponse` whose `messages` are the final assistant message from each
participant (one message per agent), in deterministic participant order matching
the fan-in `sources` configuration. The user prompt is intentionally not included —
that is part of the input, not the answer.
For each participant the final assistant message is sourced from
`r.agent_response.messages`, falling back to scanning `r.full_conversation` for
pathological executors that did not populate the response.
"""
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, AgentResponse]) -> None:
if not results:
logger.error("Concurrent aggregator received empty results list")
raise ValueError("Aggregation failed: no results provided")
def _is_role(msg: Any, role: str) -> bool:
r = getattr(msg, "role", None)
if r is None:
return False
r_str = str(r).lower() if isinstance(r, str) or hasattr(r, "__str__") else r
role_str = str(role).lower()
return r_str == role_str
assistant_replies: list[Message] = []
for r in results:
resp_messages = list(r.agent_response.messages)
logger.debug(
f"Aggregating executor {getattr(r, 'executor_id', '<unknown>')}: "
f"{len(resp_messages)} response msgs, {len(r.full_conversation)} conversation msgs"
)
# Pick the final assistant message from the response; fallback to conversation search
final_assistant = next((m for m in reversed(resp_messages) if _is_role(m, "assistant")), None)
if final_assistant is None:
final_assistant = next((m for m in reversed(r.full_conversation) if _is_role(m, "assistant")), None)
if final_assistant is not None:
assistant_replies.append(final_assistant)
else:
logger.warning(
f"No assistant reply found for executor {getattr(r, 'executor_id', '<unknown>')}; skipping"
)
if not assistant_replies:
logger.error(f"Aggregation failed: no assistant replies found across {len(results)} results")
raise RuntimeError("Aggregation failed: no assistant replies found")
await ctx.yield_output(AgentResponse(messages=assistant_replies))
class _CallbackAggregator(Executor):
"""Wraps a Python callback as an aggregator.
Accepts either an async or sync callback with one of the signatures:
- (results: list[AgentExecutorResponse]) -> Any | None
- (results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None
Notes:
- Async callbacks are awaited directly.
- Sync callbacks are executed via asyncio.to_thread to avoid blocking the event loop.
- If the callback returns a non-None value, it is yielded as an output.
"""
def __init__(self, callback: Callable[..., Any], id: str | None = None) -> None:
derived_id = getattr(callback, "__name__", "") or ""
if not derived_id or derived_id == "<lambda>":
derived_id = f"{type(self).__name__}_unnamed"
super().__init__(id or derived_id)
self._callback = callback
self._param_count = len(inspect.signature(callback).parameters)
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, Any]) -> None:
# Call according to provided signature, always non-blocking for sync callbacks
if self._param_count >= 2:
if inspect.iscoroutinefunction(self._callback):
ret = await self._callback(results, ctx)
else:
ret = await asyncio.to_thread(self._callback, results, ctx)
else:
if inspect.iscoroutinefunction(self._callback):
ret = await self._callback(results)
else:
ret = await asyncio.to_thread(self._callback, results)
# If the callback returned a value, finalize the workflow with it
if ret is not None:
await ctx.yield_output(ret)
class ConcurrentBuilder:
r"""High-level builder for concurrent agent workflows.
- `participants=[...]` accepts a list of SupportsAgentRun (recommended) or Executor.
- `build()` wires: dispatcher -> fan-out -> participants -> fan-in -> aggregator.
- `with_aggregator(...)` overrides the default aggregator with an Executor or callback.
Usage:
.. code-block:: python
from agent_framework_orchestrations import ConcurrentBuilder
# Minimal: use default aggregator (yields one AgentResponse with one assistant
# message per participant)
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).build()
# Custom aggregator via callback (sync or async). The callback receives
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
def summarize(results: list[AgentExecutorResponse]) -> str:
return " | ".join(r.agent_response.messages[-1].text for r in results)
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3]).with_aggregator(summarize).build()
# Enable checkpoint persistence so runs can resume
workflow = ConcurrentBuilder(participants=[agent1, agent2, agent3], checkpoint_storage=storage).build()
# Enable request info before aggregation
workflow = ConcurrentBuilder(participants=[agent1, agent2]).with_request_info().build()
"""
def __init__(
self,
*,
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the ConcurrentBuilder.
Args:
participants: Sequence of agent or executor instances to run in parallel.
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events alongside the aggregator. Pass ``"all"`` to select every
participant.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._participants: list[SupportsAgentRun | Executor] = []
self._aggregator: Executor | None = None
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
self._set_participants(participants)
def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None:
"""Set participants (internal)."""
if self._participants:
raise ValueError("participants already set.")
if not participants:
raise ValueError("participants cannot be empty")
# Defensive duplicate detection
seen_agent_ids: set[int] = set()
seen_executor_ids: set[str] = set()
for p in participants:
if isinstance(p, Executor):
if p.id in seen_executor_ids:
raise ValueError(f"Duplicate executor participant detected: id '{p.id}'")
seen_executor_ids.add(p.id)
elif isinstance(p, SupportsAgentRun):
pid = id(p)
if pid in seen_agent_ids:
raise ValueError("Duplicate agent participant detected (same agent instance provided twice)")
seen_agent_ids.add(pid)
else:
raise TypeError(f"participants must be SupportsAgentRun or Executor instances; got {type(p).__name__}")
self._participants = list(participants)
def with_aggregator(
self,
aggregator: Executor
| Callable[[list[AgentExecutorResponse]], Any]
| Callable[[list[AgentExecutorResponse], WorkflowContext[Never, Any]], Any],
) -> "ConcurrentBuilder":
r"""Override the default aggregator with an executor or a callback.
- Executor: must handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`
- Callback: sync or async callable with one of the signatures:
`(results: list[AgentExecutorResponse]) -> Any | None` or
`(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None`.
If the callback returns a non-None value, it becomes the workflow's output.
Args:
aggregator: Executor instance, or callback function
Example:
.. code-block:: python
# Executor-based aggregator
class CustomAggregator(Executor):
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext) -> None:
await ctx.yield_output(" | ".join(r.agent_response.messages[-1].text for r in results))
wf = ConcurrentBuilder(participants=[a1, a2, a3]).with_aggregator(CustomAggregator()).build()
# Callback-based aggregator (string result)
async def summarize(results: list[AgentExecutorResponse]) -> str:
return " | ".join(r.agent_response.messages[-1].text for r in results)
wf = ConcurrentBuilder(participants=[a1, a2, a3]).with_aggregator(summarize).build()
# Callback-based aggregator (yield result)
async def summarize(results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(" | ".join(r.agent_response.messages[-1].text for r in results))
wf = ConcurrentBuilder(participants=[a1, a2, a3]).with_aggregator(summarize).build()
"""
if self._aggregator is not None:
raise ValueError("with_aggregator() has already been called on this builder instance.")
if isinstance(aggregator, Executor):
self._aggregator = aggregator
elif callable(aggregator):
self._aggregator = _CallbackAggregator(aggregator)
else:
raise TypeError("aggregator must be an Executor or a callable")
return self
def with_request_info(
self,
*,
agents: Sequence[str | SupportsAgentRun] | None = None,
) -> "ConcurrentBuilder":
"""Enable request info after agent participant responses.
This enables human-in-the-loop (HIL) scenarios for the concurrent orchestration.
When enabled, the workflow pauses after each agent participant runs, emitting
a request_info event (type='request_info') that allows the caller to review the conversation and optionally
inject guidance for the agent participant to iterate. The caller provides input via
the standard response_handler/request_info pattern.
Simulated flow with HIL:
Input -> [Agent Participant <-> Request Info] -> [Agent Participant <-> Request Info] -> ...
Note: This is only available for agent participants. Executor participants can incorporate
request info handling in their own implementation if desired.
Args:
agents: Optional list of agents names or agent factories to enable request info for.
If None, enables HIL for all agent participants.
Returns:
Self for fluent chaining
"""
from ._orchestration_request_info import resolve_request_info_filter
self._request_info_enabled = True
self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None)
return self
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects."""
if not self._participants:
raise ValueError("No participants provided. Pass participants to the constructor.")
participants: list[Executor | SupportsAgentRun] = self._participants
executors: list[Executor] = []
for p in participants:
if isinstance(p, Executor):
executors.append(p)
elif isinstance(p, SupportsAgentRun):
if self._request_info_enabled and (
not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter
):
# Handle request info enabled agents
executors.append(AgentApprovalExecutor(p))
else:
executors.append(AgentExecutor(p))
else:
raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.")
return executors
def build(self) -> Workflow:
r"""Build and validate the concurrent workflow.
Wiring pattern:
- Dispatcher (internal) fans out the input to all `participants`
- Fan-in collects `AgentExecutorResponse` objects from all participants
- If request info is enabled, the orchestration emits a request info event with outputs from all participants
before sending the outputs to the aggregator
- Aggregator yields output and the workflow becomes idle. The output is either:
- AgentResponse (default aggregator: one assistant message per participant)
- custom payload from the provided aggregator
Returns:
Workflow: a ready-to-run workflow instance
Raises:
ValueError: if no participants were defined
Example:
.. code-block:: python
workflow = ConcurrentBuilder(participants=[agent1, agent2]).build()
"""
# Internal nodes
dispatcher = _DispatchToAllParticipants(id="dispatcher")
aggregator = self._aggregator if self._aggregator is not None else _AggregateAgentConversations(id="aggregator")
# Resolve participants and participant factories to executors
participants: list[Executor] = self._resolve_participants()
# Default: only the aggregator is terminal; participant outputs are hidden
# unless explicitly designated as terminal or intermediate.
designated, intermediate_designated = _resolve_participant_output_config(
participants=participants,
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
extra_output_executors=[aggregator],
)
builder = WorkflowBuilder(
start_executor=dispatcher,
checkpoint_storage=self._checkpoint_storage,
output_from=designated,
intermediate_output_from=intermediate_designated,
)
# Fan-out for parallel execution
builder.add_fan_out_edges(dispatcher, participants)
# Direct fan-in to aggregator
builder.add_fan_in_edges(participants, aggregator)
return builder.build()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Literal
from agent_framework._agents import SupportsAgentRun
from agent_framework._types import AgentResponse, Message
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._request_info_mixin import response_handler
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
from agent_framework._workflows._workflow_executor import WorkflowExecutor
def resolve_request_info_filter(agents: list[str | SupportsAgentRun] | None) -> set[str]:
"""Resolve a list of agent/executor references to a set of IDs for filtering.
Args:
agents: List of agent names (str), SupportsAgentRun instances, or Executor instances.
If None, returns None (meaning no filtering - pause for all).
Returns:
Set of executor/agent IDs to filter on, or None if no filtering.
"""
if agents is None:
return set()
result: set[str] = set()
for agent in agents:
if isinstance(agent, str):
result.add(agent)
elif isinstance(agent, SupportsAgentRun):
result.add(resolve_agent_id(agent))
else:
raise TypeError(f"Unsupported type for request_info filter: {type(agent).__name__}")
return result
@dataclass
class AgentRequestInfoResponse:
"""Response containing additional information requested from users for agents.
Attributes:
messages: list[Message]: Additional messages provided by users. If empty,
the agent response is approved as-is.
"""
messages: list[Message]
@staticmethod
def from_messages(messages: list[Message]) -> "AgentRequestInfoResponse":
"""Create an AgentRequestInfoResponse from a list of ChatMessages.
Args:
messages: List of Message instances provided by users.
Returns:
AgentRequestInfoResponse instance.
"""
return AgentRequestInfoResponse(messages=messages)
@staticmethod
def from_strings(texts: list[str]) -> "AgentRequestInfoResponse":
"""Create an AgentRequestInfoResponse from a list of string messages.
Args:
texts: List of text messages provided by users.
Returns:
AgentRequestInfoResponse instance.
"""
return AgentRequestInfoResponse(messages=[Message(role="user", contents=[text]) for text in texts])
@staticmethod
def approve() -> "AgentRequestInfoResponse":
"""Create an AgentRequestInfoResponse that approves the original agent response.
Returns:
AgentRequestInfoResponse instance with no additional messages.
"""
return AgentRequestInfoResponse(messages=[])
class AgentRequestInfoExecutor(Executor):
"""Executor for gathering request info from users to assist agents.
On approval (caller returned no follow-up messages), yields the original
``AgentExecutorResponse`` so downstream ``AgentExecutor`` participants can consume it
via their ``from_response`` handler — i.e., the inner workflow's output type matches the
chain currency used between Sequential participants.
"""
@handler
async def request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None:
"""Handle the agent's response and gather additional info from users."""
await ctx.request_info(agent_response, AgentRequestInfoResponse)
@response_handler
async def handle_request_info_response(
self,
original_request: AgentExecutorResponse,
response: AgentRequestInfoResponse,
ctx: WorkflowContext[AgentExecutorRequest, AgentExecutorResponse],
) -> None:
"""Process the additional info provided by users."""
if response.messages:
# User provided additional messages, further iterate on agent response
await ctx.send_message(AgentExecutorRequest(messages=response.messages, should_respond=True))
else:
# No additional info, approve original agent response
await ctx.yield_output(original_request)
class _TerminalAgentRequestInfoExecutor(Executor):
"""Sibling of ``AgentRequestInfoExecutor`` used when ``AgentApprovalExecutor`` is the workflow's terminator.
This exists because:
- The orchestration contract established is that every orchestration's terminal
``output`` event carries an ``AgentResponse``. That is the user-facing promise — e.g.,
``workflow.as_agent().run(prompt)`` returns an ``AgentResponse``.
- ``AgentRequestInfoExecutor`` yields ``AgentExecutorResponse`` because that is the chain
currency between Sequential participants: the next ``AgentExecutor`` consumes
``AgentExecutorResponse`` via its ``from_response`` handler. That is correct when
``AgentApprovalExecutor`` is *intermediate*.
- When ``AgentApprovalExecutor`` is the *terminator* (``allow_direct_output=True``), the
inner yield flows straight through ``WorkflowExecutor`` to the outer workflow's terminal
output. Yielding ``AgentExecutorResponse`` there would surface ``AgentExecutorResponse``
as the workflow's terminal output — violating the orchestration contract.
Used in place of ``AgentRequestInfoExecutor`` inside the terminator-mode inner workflow
built by ``AgentApprovalExecutor._build_workflow`` when ``allow_direct_output=True``.
Translation belongs here — at the source of the yield in the orchestrations package —
rather than at the ``WorkflowExecutor`` boundary in core, because core has no opinion
about the orchestration's ``AgentResponse`` contract.
Note: not a subclass of ``AgentRequestInfoExecutor``. The two classes have different
terminal yield contracts (``AgentExecutorResponse`` vs. ``AgentResponse``), and
``WorkflowContext``'s output type parameter is invariant — so a subclass override would
be type-incompatible. They are siblings sharing only a small ``request_info`` handler.
"""
@handler
async def request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None:
"""Handle the agent's response and gather additional info from users."""
await ctx.request_info(agent_response, AgentRequestInfoResponse)
@response_handler
async def handle_request_info_response(
self,
original_request: AgentExecutorResponse,
response: AgentRequestInfoResponse,
ctx: WorkflowContext[AgentExecutorRequest, AgentResponse],
) -> None:
"""Process the additional info provided by users; yield ``AgentResponse`` on approval."""
if response.messages:
# User provided additional messages, further iterate on agent response
await ctx.send_message(AgentExecutorRequest(messages=response.messages, should_respond=True))
else:
# No additional info, approve and surface the wrapped AgentResponse to the parent.
await ctx.yield_output(original_request.agent_response)
class AgentApprovalExecutor(WorkflowExecutor):
"""Executor for enabling scenarios requiring agent approval in an orchestration.
This executor wraps a sub workflow that contains two executors: an agent executor
and an request info executor. The agent executor provides intelligence generation,
while the request info executor gathers input from users to further iterate on the
agent's output or send the final response to down stream executors in the orchestration.
"""
def __init__(
self,
agent: SupportsAgentRun,
context_mode: Literal["full", "last_agent", "custom"] | None = None,
*,
allow_direct_output: bool = False,
) -> None:
"""Initialize the AgentApprovalExecutor.
Args:
agent: The agent protocol to use for generating responses.
context_mode: The mode for providing context to the agent.
allow_direct_output: When True, the inner agent's response is yielded as the
wrapping workflow's output (rather than forwarded as a message to a
downstream participant). Set this when this executor is the workflow's
terminator — so the user-approved final response surfaces as a workflow
``output`` event.
"""
self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode
self._description = agent.description
super().__init__(
workflow=self._build_workflow(agent, terminal=allow_direct_output),
id=resolve_agent_id(agent),
propagate_request=True,
allow_direct_output=allow_direct_output,
)
def _build_workflow(self, agent: SupportsAgentRun, *, terminal: bool) -> Workflow:
"""Build the internal workflow for the AgentApprovalExecutor.
Picks the right ``AgentRequestInfoExecutor`` variant for the role this approval flow
plays in the outer workflow:
- Intermediate (``terminal=False``): inner workflow yields ``AgentExecutorResponse``
so the next outer ``AgentExecutor`` participant can consume it via ``from_response``.
- Terminator (``terminal=True``): inner workflow yields ``AgentResponse`` so the outer
workflow's terminal output matches the orchestration contract.
"""
agent_executor = AgentExecutor(
agent,
context_mode=self._context_mode,
)
request_info_cls = _TerminalAgentRequestInfoExecutor if terminal else AgentRequestInfoExecutor
request_info_executor = request_info_cls(id="agent_request_info_executor")
# Both inner executors yield the inner workflow's terminal output (the agent
# during its turn; the _TerminalAgentRequestInfoExecutor after approval), so
# both must be designated for WorkflowExecutor.get_outputs() to surface them.
return (
WorkflowBuilder(
start_executor=agent_executor,
output_from=[agent_executor, request_info_executor],
)
# Create a loop between agent executor and request info executor
.add_edge(agent_executor, request_info_executor)
.add_edge(request_info_executor, agent_executor)
.build()
)
@property
def description(self) -> str | None:
"""Get a description of the underlying agent."""
return self._description
@@ -0,0 +1,93 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unified state management for group chat orchestrators.
Provides OrchestrationState dataclass for standardized checkpoint serialization
across GroupChat, Handoff, and Magentic patterns.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from agent_framework._types import Message
def _new_chat_message_list() -> list[Message]:
"""Factory function for typed empty Message list.
Satisfies the type checker.
"""
return []
def _new_metadata_dict() -> dict[str, Any]:
"""Factory function for typed empty metadata dict.
Satisfies the type checker.
"""
return {}
@dataclass
class OrchestrationState:
"""Unified state container for orchestrator checkpointing.
This dataclass standardizes checkpoint serialization across all three
group chat patterns while allowing pattern-specific extensions via metadata.
Common attributes cover shared orchestration concerns (task, conversation,
round tracking). Pattern-specific state goes in the metadata dict.
Attributes:
conversation: Full conversation history (all messages)
round_index: Number of coordination rounds completed (0 if not tracked)
metadata: Extensible dict for pattern-specific state
task: Optional primary task/question being orchestrated
"""
conversation: list[Message] = field(default_factory=_new_chat_message_list)
round_index: int = 0
orchestrator_name: str = ""
metadata: dict[str, Any] = field(default_factory=_new_metadata_dict)
task: Message | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize to dict for checkpointing.
Returns:
Dict with encoded conversation and metadata for persistence
"""
result: dict[str, Any] = {
"conversation": self.conversation,
"round_index": self.round_index,
"orchestrator_name": self.orchestrator_name,
"metadata": dict(self.metadata),
}
if self.task is not None:
result["task"] = self.task
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> OrchestrationState:
"""Deserialize from checkpointed dict.
Args:
data: Checkpoint data with encoded conversation
Returns:
Restored OrchestrationState instance
"""
task = None
if "task" in data:
decoded_tasks = [data["task"]]
task = decoded_tasks[0] if decoded_tasks else None
return cls(
conversation=data.get("conversation", []),
round_index=data.get("round_index", 0),
orchestrator_name=data.get("orchestrator_name", ""),
metadata=dict(data.get("metadata", {})),
task=task,
)
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared orchestrator utilities for group chat patterns.
This module provides simple, reusable functions for common orchestration tasks.
No inheritance required - just import and call.
"""
import logging
from agent_framework._types import Message
logger = logging.getLogger(__name__)
def clean_conversation_for_handoff(conversation: list[Message]) -> list[Message]:
"""Keep only plain text chat history for handoff routing.
Handoff executors must not replay prior tool-control artifacts (function calls,
tool outputs, approval payloads) into future model turns, or providers may reject
the next request due to unmatched tool-call state.
This helper builds a text-only copy of the conversation:
- Drops all non-text content from every message.
- Drops messages with no remaining text content.
- Preserves original roles and author names for retained text messages.
Args:
conversation: Full conversation history, including tool-control content
Returns:
Cleaned conversation history with only text content, suitable for handoff routing
"""
cleaned: list[Message] = []
for msg in conversation:
# Keep only plain text history for handoff routing. Tool-control content
# (function_call/function_result/approval payloads) is runtime-only and
# must not be replayed in future model turns.
text_parts = [content.text for content in msg.contents if content.type == "text" and content.text]
# TODO(@taochen): This is a simplified check that considers any non-text content as a tool call.
# We need to enhance this logic to specifically identify tool related contents.
if not text_parts:
continue
msg_copy = Message(
role=msg.role,
contents=[" ".join(text_parts)],
author_name=msg.author_name,
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
)
cleaned.append(msg_copy)
return cleaned
def create_completion_message(
*,
text: str | None = None,
author_name: str,
reason: str = "completed",
) -> Message:
"""Create a standardized completion message.
Simple helper to avoid duplicating completion message creation.
Args:
text: Message text, or None to generate default
author_name: Author/orchestrator name
reason: Reason for completion (for default text generation)
Returns:
Message with assistant role
"""
message_text = text or f"Conversation {reason}."
return Message(
role="assistant",
contents=[message_text],
author_name=author_name,
)
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft. All rights reserved.
"""Participant-oriented workflow output configuration helpers."""
from collections.abc import Sequence
from typing import Any, Literal
from agent_framework import SupportsAgentRun
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._executor import Executor
from typing_extensions import Sentinel
UNSET = Sentinel("UNSET")
_ALL_OUTPUTS: Literal["all"] = "all"
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
_ParticipantOutputSpecifier = str | SupportsAgentRun | Executor
_ParticipantOutputSelection = Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None
_ParticipantIntermediateOutputSelection = Sequence[_ParticipantOutputSpecifier] | Literal["all", "all_other"] | None
_WorkflowExecutorSpecifier = Executor | SupportsAgentRun
def _coalesce_output_from( # pyright: ignore[reportUnusedFunction]
*,
output_from: Any = UNSET,
) -> _ParticipantOutputSelection:
"""Resolve orchestration output selection to ``output_from``."""
if output_from is not UNSET:
return _coerce_output_from(output_from)
return None
def _coerce_output_from(output_from: Any) -> _ParticipantOutputSelection:
"""Coerce workflow-output participant selection while preserving the ``"all"`` literal."""
if output_from is None:
return None
if isinstance(output_from, str):
if output_from == _ALL_OUTPUTS:
return _ALL_OUTPUTS
if output_from == _ALL_OTHER_OUTPUTS:
raise ValueError("output_from='all_other' is invalid; use intermediate_output_from='all_other' instead.")
raise ValueError(f"Unsupported output_from literal {output_from!r}; use 'all' or a list of participants.")
return list(output_from)
def _coerce_intermediate_output_from( # pyright: ignore[reportUnusedFunction]
intermediate_output_from: Any,
) -> _ParticipantIntermediateOutputSelection:
"""Coerce intermediate-output participant selection while preserving ``"all_other"``."""
if intermediate_output_from is None:
return None
if isinstance(intermediate_output_from, str):
if intermediate_output_from == _ALL_OUTPUTS:
return _ALL_OUTPUTS
if intermediate_output_from == _ALL_OTHER_OUTPUTS:
return _ALL_OTHER_OUTPUTS
raise ValueError(
f"Unsupported intermediate_output_from literal {intermediate_output_from!r}; "
"use 'all', 'all_other', or a list of participants."
)
return list(intermediate_output_from)
def _resolve_participant_output_config( # pyright: ignore[reportUnusedFunction]
*,
participants: Sequence[Executor],
output_from: _ParticipantOutputSelection,
intermediate_output_from: _ParticipantIntermediateOutputSelection,
default_output_from: Sequence[Executor] = (),
extra_output_executors: Sequence[Executor] = (),
) -> tuple[list[_WorkflowExecutorSpecifier], list[_WorkflowExecutorSpecifier]]:
"""Resolve public participant output config into workflow executor config."""
explicit_config = output_from is not None or intermediate_output_from is not None
if explicit_config and not (output_from or intermediate_output_from):
raise ValueError("output_from and intermediate_output_from cannot both be empty.")
participants_by_id = {participant.id: participant for participant in participants}
known_participants = sorted(participants_by_id)
if output_from == _ALL_OUTPUTS:
output_designated = list(participants)
elif output_from is not None:
output_designated = _resolve_designated_participants(
output_from,
kind="output",
participants_by_id=participants_by_id,
known_participants=known_participants,
)
elif intermediate_output_from in (_ALL_OTHER_OUTPUTS, _ALL_OUTPUTS):
output_designated = []
else:
intermediate_designated = (
_resolve_designated_participants(
intermediate_output_from,
kind="intermediate",
participants_by_id=participants_by_id,
known_participants=known_participants,
)
if intermediate_output_from is not None
else []
)
# The caller-supplied default applies only to participants not explicitly designated as
# intermediate. Without this subtraction, builders that pre-populate a default output list
# (Handoff defaults to all participants, Sequential defaults to the last) would force
# an overlap error whenever a user passed `intermediate_output_from=[X]` for an X in
# the default set, contradicting the public docstring contract.
intermediate_ids = {participant.id for participant in intermediate_designated}
output_designated = [
participant for participant in default_output_from if participant.id not in intermediate_ids
]
if intermediate_output_from == _ALL_OUTPUTS:
intermediate_designated = list(participants)
elif intermediate_output_from == _ALL_OTHER_OUTPUTS:
output_ids = {participant.id for participant in output_designated}
intermediate_designated = [participant for participant in participants if participant.id not in output_ids]
elif intermediate_output_from is not None:
intermediate_designated = _resolve_designated_participants(
intermediate_output_from,
kind="intermediate",
participants_by_id=participants_by_id,
known_participants=known_participants,
)
else:
intermediate_designated = []
overlap = sorted(
{participant.id for participant in output_designated}.intersection(
participant.id for participant in intermediate_designated
)
)
if overlap:
raise ValueError(f"Participants cannot be both output and intermediate designated: {overlap}")
output_executors: list[_WorkflowExecutorSpecifier] = [*extra_output_executors, *output_designated]
intermediate_executors: list[_WorkflowExecutorSpecifier] = list(intermediate_designated)
return output_executors, intermediate_executors
def _resolve_designated_participants(
designations: Sequence[_ParticipantOutputSpecifier],
*,
kind: str,
participants_by_id: dict[str, Executor],
known_participants: Sequence[str],
) -> list[Executor]:
resolved: list[Executor] = []
seen: set[str] = set()
for designation in designations:
participant_id = _participant_id(designation)
if participant_id in seen:
raise ValueError(f"Duplicate {kind} participant '{participant_id}' in {kind}_participants.")
seen.add(participant_id)
try:
resolved.append(participants_by_id[participant_id])
except KeyError as exc:
raise ValueError(
f"Unknown {kind} participant '{participant_id}'. Known participants: {known_participants}"
) from exc
return resolved
def _participant_id(participant: _ParticipantOutputSpecifier) -> str:
if isinstance(participant, str):
return participant
if isinstance(participant, Executor):
return participant.id
return resolve_agent_id(participant)
@@ -0,0 +1,269 @@
# Copyright (c) Microsoft. All rights reserved.
"""Sequential builder for agent/executor workflows with shared conversation context.
Participants (SupportsAgentRun or Executor instances) run in order, sharing a
conversation along the chain. Agents append their assistant messages; custom executors
transform and return a refined `list[Message]`.
Wiring: input -> _InputToConversation -> participant1 -> ... -> participantN
The workflow's final `output` event is the last participant's `yield_output(...)`. For
agent terminators that is an `AgentResponse` (or per-chunk `AgentResponseUpdate`s when
streaming). For custom-executor terminators, the executor itself yields whatever it
produces — by convention an `AgentResponse` so downstream consumers see a uniform shape.
"""
import logging
from collections.abc import Sequence
from typing import Any, Literal, cast
from agent_framework import Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._checkpoint import CheckpointStorage
from agent_framework._workflows._executor import (
Executor,
handler,
)
from agent_framework._workflows._message_utils import normalize_messages_input
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
from ._orchestration_request_info import AgentApprovalExecutor
from ._participant_output_config import (
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
_ParticipantOutputSpecifier, # pyright: ignore[reportPrivateUsage]
_resolve_participant_output_config, # pyright: ignore[reportPrivateUsage]
)
logger = logging.getLogger(__name__)
class _InputToConversation(Executor):
"""Normalizes initial input into a list[Message] conversation."""
@handler
async def from_str(self, prompt: str, ctx: WorkflowContext[list[Message]]) -> None:
await ctx.send_message(normalize_messages_input(prompt))
@handler
async def from_message(self, message: Message, ctx: WorkflowContext[list[Message]]) -> None:
await ctx.send_message(normalize_messages_input(message))
@handler
async def from_messages(self, messages: list[str | Message], ctx: WorkflowContext[list[Message]]) -> None:
await ctx.send_message(normalize_messages_input(messages))
class SequentialBuilder:
r"""High-level builder for sequential agent/executor workflows with shared context.
- `participants=[...]` accepts a list of SupportsAgentRun (recommended) or Executor instances
- Executors must define a handler that consumes list[Message] and sends out a list[Message]
- The workflow wires participants in order, passing a list[Message] down the chain
- Agents append their assistant messages to the conversation
- Custom executors can transform/summarize and return a list[Message]
- The default Workflow Output is the conversation produced by the last participant
Usage:
.. code-block:: python
from agent_framework_orchestrations import SequentialBuilder
# With agent instances
workflow = SequentialBuilder(participants=[agent1, agent2, summarizer_exec]).build()
# Enable checkpoint persistence
workflow = SequentialBuilder(participants=[agent1, agent2], checkpoint_storage=storage).build()
# Enable request info for mid-workflow feedback (pauses before each agent)
workflow = SequentialBuilder(participants=[agent1, agent2]).with_request_info().build()
# Enable request info only for specific agents
workflow = (
SequentialBuilder(participants=[agent1, agent2, agent3])
.with_request_info(agents=[agent2]) # Only pause before agent2
.build()
)
"""
def __init__(
self,
*,
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
chain_only_agent_responses: bool = False,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the SequentialBuilder.
Args:
participants: Sequence of agent or executor instances to run sequentially.
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
chain_only_agent_responses: If True, only agent responses are chained between agents.
By default, the full conversation context is passed to the next agent. This also applies
to Executor -> Agent transitions if the executor sends `AgentExecutorResponse`.
output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``output`` events. Pass ``"all"`` to select every participant.
intermediate_output_from: Optional participant names or instances whose ``yield_output`` calls
surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant
not selected by ``output_from``. Unlisted participant outputs are hidden.
"""
self._participants: list[SupportsAgentRun | Executor] = []
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._chain_only_agent_responses: bool = chain_only_agent_responses
self._request_info_enabled: bool = False
self._request_info_filter: set[str] | None = None
self._output_from = _coalesce_output_from(output_from=output_from)
self._intermediate_output_from = _coerce_intermediate_output_from(intermediate_output_from)
self._set_participants(participants)
def _set_participants(self, participants: Sequence[SupportsAgentRun | Executor]) -> None:
"""Set participants (internal)."""
if self._participants:
raise ValueError("participants already set.")
if not participants:
raise ValueError("participants cannot be empty")
# Defensive duplicate detection
seen_agent_ids: set[int] = set()
seen_executor_ids: set[str] = set()
for p in participants:
if isinstance(p, Executor):
if p.id in seen_executor_ids:
raise ValueError(f"Duplicate executor participant detected: id '{p.id}'")
seen_executor_ids.add(p.id)
else:
# Treat non-Executor as agent-like (SupportsAgentRun). Structural checks can be brittle at runtime.
pid = id(p)
if pid in seen_agent_ids:
raise ValueError("Duplicate agent participant detected (same agent instance provided twice)")
seen_agent_ids.add(pid)
self._participants = list(participants)
def with_request_info(
self,
*,
agents: Sequence[str | SupportsAgentRun] | None = None,
) -> "SequentialBuilder":
"""Enable request info after agent participant responses.
This enables human-in-the-loop (HIL) scenarios for the sequential orchestration.
When enabled, the workflow pauses after each agent participant runs, emitting
a request_info event (type='request_info') that allows the caller to review the conversation and optionally
inject guidance for the agent participant to iterate. The caller provides input via
the standard response_handler/request_info pattern.
Simulated flow with HIL:
Input -> [Agent Participant <-> Request Info] -> [Agent Participant <-> Request Info] -> ...
Note: This is only available for agent participants. Executor participants can incorporate
request info handling in their own implementation if desired.
Args:
agents: Optional list of agents names or agent factories to enable request info for.
If None, enables HIL for all agent participants.
Returns:
Self for fluent chaining
"""
from ._orchestration_request_info import resolve_request_info_filter
self._request_info_enabled = True
self._request_info_filter = resolve_request_info_filter(list(agents) if agents else None)
return self
def _resolve_participants(self) -> list[Executor]:
"""Resolve participant instances into Executor objects.
Wraps `SupportsAgentRun` participants as `AgentExecutor` (or `AgentApprovalExecutor`
when request-info is enabled for that participant). The last participant, when wrapped
as `AgentApprovalExecutor`, is constructed with `allow_direct_output=True` so the
approved response surfaces as the workflow's output event instead of being forwarded
as a message that has nowhere to go.
"""
if not self._participants:
raise ValueError("No participants provided. Pass participants to the constructor.")
participants: list[Executor | SupportsAgentRun] = self._participants
context_mode: Literal["full", "last_agent", "custom"] | None = (
"last_agent" if self._chain_only_agent_responses else None
)
last_idx = len(participants) - 1
executors: list[Executor] = []
for idx, p in enumerate(participants):
if isinstance(p, Executor):
executors.append(p)
elif isinstance(p, SupportsAgentRun):
if self._request_info_enabled and (
not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter
):
# Handle request info enabled agents
executors.append(
AgentApprovalExecutor(
p,
context_mode=context_mode,
allow_direct_output=(idx == last_idx),
)
)
else:
executors.append(AgentExecutor(p, context_mode=context_mode))
else:
raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.")
return executors
def build(self) -> Workflow:
"""Build and validate the sequential workflow.
Wiring pattern:
- `_InputToConversation` normalizes the initial input into `list[Message]`.
- Each participant runs in order:
- `AgentExecutor`: receives the conversation / `AgentExecutorResponse` and
forwards an `AgentExecutorResponse` downstream.
- Custom `Executor`: receives `list[Message]` and forwards `list[Message]`.
If used as the terminator, it must call `ctx.yield_output(AgentResponse(...))`
instead of `ctx.send_message(...)` — its yield becomes the workflow's output.
- The last participant is selected as Workflow Output by default, so the
terminator's own `yield_output` is Workflow Output (`AgentResponse`,
or per-chunk `AgentResponseUpdate` when streaming).
"""
input_conv = _InputToConversation(id="input-conversation")
# Resolve participants and participant factories to executors
participants: list[Executor] = self._resolve_participants()
# Default: only the terminator is terminal. Explicit participant designation
# can surface selected earlier participant outputs as terminal or intermediate.
designated, intermediate_designated = _resolve_participant_output_config(
participants=participants,
output_from=self._output_from,
intermediate_output_from=self._intermediate_output_from,
default_output_from=[participants[-1]],
)
builder = WorkflowBuilder(
start_executor=input_conv,
checkpoint_storage=self._checkpoint_storage,
output_from=designated,
intermediate_output_from=intermediate_designated,
)
prior: Executor | SupportsAgentRun = input_conv
for p in participants:
builder.add_edge(prior, p)
prior = p
return builder.build()
@@ -0,0 +1,96 @@
[project]
name = "agent-framework-orchestrations"
description = "Orchestration patterns for Microsoft Agent Framework. Includes SequentialBuilder, ConcurrentBuilder, HandoffBuilder, GroupChatBuilder, and MagenticBuilder."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.9.0,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_orchestrations"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_orchestrations"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_orchestrations"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_orchestrations --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,375 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Awaitable
from typing import Any, Literal, cast, overload
import pytest
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Executor,
Message,
ResponseStream,
WorkflowContext,
WorkflowRunState,
handler,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework.orchestrations import ConcurrentBuilder
class _FakeAgentExec(Executor):
"""Test executor that mimics an agent by emitting an AgentExecutorResponse.
It takes the incoming AgentExecutorRequest, produces a single assistant message
with the configured reply text, and sends an AgentExecutorResponse that includes
full_conversation (the original user prompt followed by the assistant message).
"""
def __init__(self, id: str, reply_text: str) -> None:
super().__init__(id)
self._reply_text = reply_text
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
response = AgentResponse(messages=Message(role="assistant", contents=[self._reply_text]))
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
def test_concurrent_builder_rejects_empty_participants() -> None:
with pytest.raises(ValueError):
ConcurrentBuilder(participants=[])
def test_concurrent_builder_rejects_duplicate_executors() -> None:
a = _FakeAgentExec("dup", "A")
b = _FakeAgentExec("dup", "B") # same executor id
with pytest.raises(ValueError):
ConcurrentBuilder(participants=[a, b])
async def test_concurrent_default_aggregator_emits_assistants_only() -> None:
"""Default aggregator yields a single AgentResponse with one assistant message per participant.
The user prompt is intentionally not included — that belongs in the input, not the answer.
"""
e1 = _FakeAgentExec("agentA", "Alpha")
e2 = _FakeAgentExec("agentB", "Beta")
e3 = _FakeAgentExec("agentC", "Gamma")
wf = ConcurrentBuilder(participants=[e1, e2, e3]).build()
output_events = [ev for ev in await wf.run("prompt: hello world") if ev.type == "output"]
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
# Exactly one assistant message per participant; no user prompt.
assert len(response.messages) == 3
assert all(m.role == "assistant" for m in response.messages)
assert {m.text for m in response.messages} == {"Alpha", "Beta", "Gamma"}
async def test_concurrent_custom_aggregator_callback_is_used() -> None:
# Two synthetic agent executors for brevity
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
async def summarize(results: list[AgentExecutorResponse]) -> str:
texts: list[str] = []
for r in results:
msgs: list[Message] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
return " | ".join(sorted(texts))
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(summarize).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: custom", 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
# Custom aggregator returns a string payload
assert isinstance(output, str)
assert output == "One | Two"
async def test_concurrent_custom_aggregator_sync_callback_is_used() -> None:
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
# Sync callback with ctx parameter (should run via asyncio.to_thread)
def summarize_sync(results: list[AgentExecutorResponse], _ctx: WorkflowContext[Any]) -> str: # type: ignore[unused-argument]
texts: list[str] = []
for r in results:
msgs: list[Message] = r.agent_response.messages
texts.append(msgs[-1].text if msgs else "")
return " | ".join(sorted(texts))
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(summarize_sync).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: custom sync", 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_custom_aggregator_uses_callback_name_for_id() -> None:
e1 = _FakeAgentExec("agentA", "One")
e2 = _FakeAgentExec("agentB", "Two")
def summarize(results: list[AgentExecutorResponse]) -> str: # type: ignore[override]
return str(len(results))
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(summarize).build()
assert "summarize" in wf.executors
aggregator = wf.executors["summarize"]
assert aggregator.id == "summarize"
async def test_concurrent_with_aggregator_executor_instance() -> None:
"""Test with_aggregator using an Executor instance (not factory)."""
class CustomAggregator(Executor):
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any, str]) -> None:
texts: list[str] = []
for r in results:
msgs: list[Message] = 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")
aggregator_instance = CustomAggregator(id="instance_aggregator")
wf = ConcurrentBuilder(participants=[e1, e2]).with_aggregator(aggregator_instance).build()
completed = False
output: str | None = None
async for ev in wf.run("prompt: instance 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."""
def summarize(results: list[AgentExecutorResponse]) -> str: # type: ignore[override]
return str(len(results))
with pytest.raises(ValueError, match=r"with_aggregator\(\) has already been called"):
(
ConcurrentBuilder(participants=[_FakeAgentExec("a", "A")])
.with_aggregator(summarize)
.with_aggregator(summarize)
)
async def test_concurrent_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
participants = (
_FakeAgentExec("agentA", "Alpha"),
_FakeAgentExec("agentB", "Beta"),
_FakeAgentExec("agentC", "Gamma"),
)
wf = ConcurrentBuilder(participants=list(participants), checkpoint_storage=storage).build()
baseline_output: AgentResponse | None = None
async for ev in wf.run("checkpoint concurrent", stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[1]
resumed_participants = (
_FakeAgentExec("agentA", "Alpha"),
_FakeAgentExec("agentB", "Beta"),
_FakeAgentExec("agentC", "Gamma"),
)
wf_resume = ConcurrentBuilder(participants=list(resumed_participants), checkpoint_storage=storage).build()
resumed_output: AgentResponse | 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 # type: ignore[assignment]
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.messages] == [m.role for m in baseline_output.messages]
assert [m.text for m in resumed_output.messages] == [m.text for m in baseline_output.messages]
async def test_concurrent_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf = ConcurrentBuilder(participants=agents).build()
baseline_output: AgentResponse | None = None
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) >= 2, (
"Expected at least 2 checkpoints. The first one is after the start executor, "
"and the second one is after the first round of agent executions."
)
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[1]
resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf_resume = ConcurrentBuilder(participants=resumed_agents).build()
resumed_output: AgentResponse | None = None
async for ev in wf_resume.run(
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True
):
if ev.type == "output":
resumed_output = ev.data # type: ignore[assignment]
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.messages] == [m.role for m in baseline_output.messages]
async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf = ConcurrentBuilder(participants=agents, checkpoint_storage=buildtime_storage).build()
baseline_output: list[Message] | None = None
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
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_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")
e2 = _FakeAgentExec("agentB", "Two")
builder = ConcurrentBuilder(participants=[e1, e2])
builder.build()
assert builder._participants[0] is e1 # type: ignore
assert builder._participants[1] is e2 # type: ignore
class _EchoAgent(BaseAgent):
"""Simple agent that appends a single assistant message with its name."""
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])])
return _run()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,776 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for orchestration intermediate vs terminal output labeling.
Verifies that under the strict-output model:
- Sequential / Concurrent / GroupChat / Magentic designate their terminator,
aggregator, orchestrator, or manager as the sole output executor; per-step
yields from non-designated executors emit `type='intermediate'` events.
- Handoff designates ALL participants — every reply is `type='output'`.
- When wrapped via `workflow.as_agent()`, caller-facing workflow events surface
with their original content types.
"""
from __future__ import annotations
from collections.abc import AsyncIterable, Awaitable, Callable
from typing import Any, ClassVar, Literal, cast, overload
import pytest
from agent_framework import (
Agent,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Message,
ResponseStream,
)
from agent_framework.orchestrations import (
ConcurrentBuilder,
GroupChatBuilder,
GroupChatState,
HandoffBuilder,
MagenticBuilder,
MagenticContext,
MagenticManagerBase,
MagenticProgressLedger,
MagenticProgressLedgerItem,
SequentialBuilder,
)
def _as_handoff_agent(agent: Any) -> Agent:
return cast(Agent, agent)
def _as_handoff_agents(*agents: Any) -> list[Agent]:
return [_as_handoff_agent(agent) for agent in agents]
class _EchoAgent(BaseAgent):
"""Minimal non-streaming agent that returns a single assistant message."""
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[Content.from_text(text=f"{self.name} reply")], author_name=self.name
)
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"], author_name=self.name)])
return _run()
# ---------------------------------------------------------------------------
# Sequential
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sequential_default_only_terminator_is_output() -> None:
"""Default Sequential designates only the terminator; earlier participants are hidden."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c)).build()
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run("hello", stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
# Only the terminator (C) emits type='output'.
assert len(output_events) == 1
assert "C" in {ev.executor_id for ev in output_events}
assert not intermediate_events
@pytest.mark.asyncio
async def test_sequential_output_from_designates_workflow_output_participants() -> None:
"""Sequential output_from controls which participant yields surface as workflow output."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c), output_from=["A", "B", "C"]).build()
result = await workflow.run("hello")
outputs = result.get_outputs()
assert len(outputs) == 3
@pytest.mark.asyncio
async def test_sequential_intermediate_output_from_surface_as_intermediate() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c), intermediate_output_from=[a, "B"]).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("hello", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert output_executors == {"C"}
assert intermediate_executors == {"A", "B"}
@pytest.mark.asyncio
async def test_sequential_intermediate_can_demote_default_terminator() -> None:
"""Regression: marking the default output terminator as intermediate must not raise an overlap error.
Sequential's default output list is `[participants[-1]]`. Before the fix, designating that
same participant via `intermediate_output_from` triggered the
"Participants cannot be both output and intermediate designated" overlap rejection in
`_participant_output_config`, contradicting the public contract that
`intermediate_output_from` can be used independently of `output_from`.
"""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c), intermediate_output_from=["C"]).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("hello", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
# The default-final list ([C]) is implicitly narrowed by the intermediate designation,
# so no participant surfaces as terminal output and C surfaces as intermediate.
assert output_executors == set()
assert intermediate_executors == {"C"}
@pytest.mark.asyncio
async def test_sequential_get_outputs_returns_terminator_only() -> None:
"""WorkflowRunResult.get_outputs() returns only the terminator's yield."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b)).build()
result = await workflow.run("hi")
outputs = result.get_outputs()
assert len(outputs) == 1
# ---------------------------------------------------------------------------
# Concurrent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_default_only_aggregator_is_output() -> None:
"""Default Concurrent designates only the aggregator; participants are hidden."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=_as_handoff_agents(a, b)).build()
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run("hello", stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
# Aggregator is the only designated executor → only it emits type='output'.
assert len(output_events) == 1
assert not intermediate_events
@pytest.mark.asyncio
async def test_concurrent_output_from_designates_workflow_output_participants() -> None:
"""Concurrent output_from designates participant outputs alongside the aggregator."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=_as_handoff_agents(a, b), output_from=[a, "B"]).build()
result = await workflow.run("hello")
outputs = result.get_outputs()
assert len(outputs) == 3
@pytest.mark.asyncio
async def test_concurrent_intermediate_output_from_surface_as_intermediate() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=_as_handoff_agents(a, b), intermediate_output_from=["A", b]).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("hello", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert "aggregator" in output_executors
assert intermediate_executors == {"A", "B"}
# ---------------------------------------------------------------------------
# Sequential wrapped as_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_sequential_default_as_agent_forwards_original_content_types() -> None:
"""Default Sequential wrapped as_agent forwards original content types."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c)).build()
agent = workflow.as_agent("seq")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
reasoning_contents = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"]
assert any("C reply" in (c.text or "") for c in text_contents)
assert not reasoning_contents
@pytest.mark.asyncio
async def test_sequential_as_agent_output_from_all_text() -> None:
"""output_from makes designated participant replies normal response text content."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c), output_from=["A", "B", "C"]).build()
agent = workflow.as_agent("seq")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
text = " ".join(c.text or "" for c in text_contents)
assert "A reply" in text
assert "B reply" in text
assert "C reply" in text
@pytest.mark.asyncio
async def test_sequential_as_agent_intermediate_output_from_keeps_text_content() -> None:
"""intermediate_output_from keeps selected participant replies as their original content type."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c), intermediate_output_from=["A", "B"]).build()
agent = workflow.as_agent("seq")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
reasoning_contents = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"]
assert any("C reply" in (c.text or "") for c in text_contents)
assert any("A reply" in (c.text or "") for c in text_contents)
assert any("B reply" in (c.text or "") for c in text_contents)
assert not reasoning_contents
# ---------------------------------------------------------------------------
# Concurrent wrapped as_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_default_as_agent_participants_keep_text_content() -> None:
"""Default Concurrent wrapped as_agent keeps original participant content types."""
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = ConcurrentBuilder(participants=_as_handoff_agents(a, b)).build()
agent = workflow.as_agent("concurrent")
response = await agent.run("hi")
text_contents = [c for m in response.messages for c in m.contents if c.type == "text"]
reasoning_contents = [c for m in response.messages for c in m.contents if c.type == "text_reasoning"]
assert not any("A reply" in (c.text or "") for c in reasoning_contents)
assert not any("B reply" in (c.text or "") for c in reasoning_contents)
# The aggregator's default-yielded AgentResponse passes through as text content.
assert text_contents, "expected at least one terminal text content from the aggregator"
# ---------------------------------------------------------------------------
# GroupChat
# ---------------------------------------------------------------------------
def _two_step_selector() -> Callable[[GroupChatState], str]:
"""Selector that picks each participant once, then keeps the first to keep tests bounded."""
counter = {"n": 0}
def _select(state: GroupChatState) -> str:
participants = list(state.participants.keys())
step = counter["n"]
counter["n"] = step + 1
if step == 0:
return participants[0]
if step == 1 and len(participants) > 1:
return participants[1]
return participants[0]
return _select
@pytest.mark.asyncio
async def test_group_chat_default_only_orchestrator_is_output() -> None:
"""Default GroupChat designates only the orchestrator; participant replies are hidden."""
alpha = _EchoAgent(name="alpha")
beta = _EchoAgent(name="beta")
workflow = GroupChatBuilder(
participants=_as_handoff_agents(alpha, beta),
max_rounds=2,
selection_func=_two_step_selector(),
).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("kickoff", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert "group_chat_orchestrator" in output_executors
assert "alpha" not in intermediate_executors
assert "beta" not in intermediate_executors
# Participants must NOT appear among designated outputs in the default contract.
assert "alpha" not in output_executors
assert "beta" not in output_executors
@pytest.mark.asyncio
async def test_group_chat_output_from_designates_workflow_output_participants() -> None:
"""GroupChat output_from designates participants alongside the orchestrator."""
alpha = _EchoAgent(name="alpha")
beta = _EchoAgent(name="beta")
workflow = GroupChatBuilder(
participants=_as_handoff_agents(alpha, beta),
max_rounds=2,
selection_func=_two_step_selector(),
output_from=[alpha, "beta"],
).build()
output_executors: set[str] = set()
async for event in workflow.run("kickoff", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
assert {"group_chat_orchestrator", "alpha", "beta"}.issubset(output_executors)
@pytest.mark.asyncio
async def test_group_chat_intermediate_output_from_surface_as_intermediate() -> None:
alpha = _EchoAgent(name="alpha")
beta = _EchoAgent(name="beta")
workflow = GroupChatBuilder(
participants=_as_handoff_agents(alpha, beta),
max_rounds=2,
selection_func=_two_step_selector(),
intermediate_output_from=["alpha", beta],
).build()
output_executors: set[str] = set()
intermediate_executors: set[str] = set()
async for event in workflow.run("kickoff", stream=True):
if event.type == "output" and event.executor_id is not None:
output_executors.add(event.executor_id)
elif event.type == "intermediate" and event.executor_id is not None:
intermediate_executors.add(event.executor_id)
assert "group_chat_orchestrator" in output_executors
assert intermediate_executors == {"alpha", "beta"}
# ---------------------------------------------------------------------------
# Handoff
# ---------------------------------------------------------------------------
def test_handoff_builder_designates_every_participant_as_output() -> None:
"""Handoff has no intermediate channel — every participant's reply is a primary
output. The builder must designate all participants in the workflow's
output designation so each per-agent yield surfaces as type='output'.
Structural assertion (vs end-to-end) because Handoff agents require a full
chat-client/middleware stack that we don't want to reproduce in this contract test.
"""
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(
name="alpha",
id="alpha",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
beta = Agent(
name="beta",
id="beta",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
workflow = (
HandoffBuilder(participants=_as_handoff_agents(alpha, beta)).with_start_agent(_as_handoff_agent(alpha)).build()
)
designated = {ex.id for ex in workflow.get_output_executors()}
assert "alpha" in designated, f"alpha must be designated; got {designated}"
assert "beta" in designated, f"beta must be designated; got {designated}"
def test_handoff_builder_output_from_can_select_workflow_output_participants() -> None:
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(
name="alpha",
id="alpha",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
beta = Agent(
name="beta",
id="beta",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
workflow = (
HandoffBuilder(participants=_as_handoff_agents(alpha, beta), output_from=["alpha"])
.with_start_agent(_as_handoff_agent(alpha))
.build()
)
assert {ex.id for ex in workflow.get_output_executors()} == {"alpha"}
def test_handoff_builder_intermediate_output_from_demotes_from_default_output() -> None:
"""Regression: `intermediate_output_from` alone must not collide with the default output list.
Handoff defaults workflow output to every participant. Before the fix, supplying
`intermediate_output_from=["alpha"]` without restating `output_from` triggered
"Participants cannot be both output and intermediate designated: ['alpha']" because
alpha was simultaneously in the default output list and the explicit intermediate list.
The contract documented at `_handoff.py:619-622` promises `intermediate_output_from` is
usable on its own.
"""
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(name="alpha", id="alpha", client=_StubClient(), require_per_service_call_history_persistence=True)
beta = Agent(name="beta", id="beta", client=_StubClient(), require_per_service_call_history_persistence=True)
workflow = (
HandoffBuilder(participants=_as_handoff_agents(alpha, beta), intermediate_output_from=["alpha"])
.with_start_agent(_as_handoff_agent(alpha))
.build()
)
# alpha is implicitly removed from the default-final set; beta remains final.
assert {ex.id for ex in workflow.get_output_executors()} == {"beta"}
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"alpha"}
# ---------------------------------------------------------------------------
# Magentic
# ---------------------------------------------------------------------------
class _StubMagenticManager(MagenticManagerBase):
"""Deterministic manager that finishes after one round with a fixed final answer."""
FINAL_ANSWER: ClassVar[str] = "MAGENTIC_FINAL"
def __init__(self) -> None:
super().__init__(max_stall_count=3)
self.name = "magentic_manager"
self.next_speaker_name = "alpha"
async def plan(self, magentic_context: MagenticContext) -> Message:
return Message("assistant", ["Plan: do the thing."], author_name=self.name)
async def replan(self, magentic_context: MagenticContext) -> Message:
return Message("assistant", ["Replan."], author_name=self.name)
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
is_satisfied = len(magentic_context.chat_history) > 1
return MagenticProgressLedger(
is_request_satisfied=MagenticProgressLedgerItem(reason="t", answer=is_satisfied),
is_in_loop=MagenticProgressLedgerItem(reason="t", answer=False),
is_progress_being_made=MagenticProgressLedgerItem(reason="t", answer=True),
next_speaker=MagenticProgressLedgerItem(reason="t", answer=self.next_speaker_name),
instruction_or_question=MagenticProgressLedgerItem(reason="t", answer="Go."),
)
async def prepare_final_answer(self, magentic_context: MagenticContext) -> Message:
return Message("assistant", [self.FINAL_ANSWER], author_name=self.name)
def test_magentic_builder_default_only_manager_designated() -> None:
"""Default Magentic: only the orchestrator (manager) is designated for terminal output;
participant replies surface as type='intermediate'.
Structural assertion on the workflow's output designation because exercising a Magentic
plan/replan loop end-to-end is heavy and orthogonal to this contract.
"""
manager = _StubMagenticManager()
alpha = _EchoAgent(name="alpha")
workflow = MagenticBuilder(participants=_as_handoff_agents(alpha), manager=manager).build()
designated = {ex.id for ex in workflow.get_output_executors()}
assert "magentic_orchestrator" in designated, f"manager must be designated; got {designated}"
assert "alpha" not in designated, f"participant must not be designated by default; got {designated}"
def test_magentic_builder_output_from_designates_workflow_output_participants() -> None:
"""Magentic output_from designates workers alongside the orchestrator."""
manager = _StubMagenticManager()
alpha = _EchoAgent(name="alpha")
workflow = MagenticBuilder(participants=_as_handoff_agents(alpha), manager=manager, output_from=["alpha"]).build()
designated = {ex.id for ex in workflow.get_output_executors()}
assert {"magentic_orchestrator", "alpha"}.issubset(designated)
def test_magentic_builder_intermediate_output_from_designates_intermediate_workers() -> None:
manager = _StubMagenticManager()
alpha = _EchoAgent(name="alpha")
workflow = MagenticBuilder(
participants=_as_handoff_agents(alpha), manager=manager, intermediate_output_from=[alpha]
).build()
assert {ex.id for ex in workflow.get_output_executors()} == {"magentic_orchestrator"}
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"alpha"}
def test_sequential_output_from_all_selects_all_participants() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b, c), output_from="all").build()
assert {ex.id for ex in workflow.get_output_executors()} == {"A", "B", "C"}
def test_sequential_intermediate_output_from_all_other_selects_non_outputs() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
c = _EchoAgent(name="C")
workflow = SequentialBuilder(
participants=_as_handoff_agents(a, b, c), output_from=["C"], intermediate_output_from="all_other"
).build()
assert {ex.id for ex in workflow.get_output_executors()} == {"C"}
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"A", "B"}
def test_sequential_all_other_with_omitted_output_from_selects_all_intermediate() -> None:
a = _EchoAgent(name="A")
b = _EchoAgent(name="B")
workflow = SequentialBuilder(participants=_as_handoff_agents(a, b), intermediate_output_from="all_other").build()
assert {ex.id for ex in workflow.get_output_executors()} == set()
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"A", "B"}
# ---------------------------------------------------------------------------
# Participant designation validation
# ---------------------------------------------------------------------------
def _build_sequential_with_designation(**kwargs: Any) -> None:
SequentialBuilder(
participants=_as_handoff_agents(_EchoAgent(name="alpha"), _EchoAgent(name="beta")), **kwargs
).build()
def _build_concurrent_with_designation(**kwargs: Any) -> None:
ConcurrentBuilder(
participants=_as_handoff_agents(_EchoAgent(name="alpha"), _EchoAgent(name="beta")), **kwargs
).build()
def _build_group_chat_with_designation(**kwargs: Any) -> None:
GroupChatBuilder(
participants=_as_handoff_agents(_EchoAgent(name="alpha"), _EchoAgent(name="beta")),
max_rounds=1,
selection_func=_two_step_selector(),
**kwargs,
).build()
def _build_magentic_with_designation(**kwargs: Any) -> None:
MagenticBuilder(
participants=_as_handoff_agents(_EchoAgent(name="alpha")), manager=_StubMagenticManager(), **kwargs
).build()
def _build_handoff_with_designation(**kwargs: Any) -> None:
from agent_framework import Agent
from agent_framework._clients import BaseChatClient
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._tools import FunctionInvocationLayer
class _StubClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
def __init__(self) -> None:
ChatMiddlewareLayer.__init__(self)
FunctionInvocationLayer.__init__(self)
BaseChatClient.__init__(self)
def _inner_get_response(self, **kwargs: Any) -> Any: # pragma: no cover - never called
raise NotImplementedError
alpha = Agent(
name="alpha",
id="alpha",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
beta = Agent(
name="beta",
id="beta",
client=_StubClient(),
require_per_service_call_history_persistence=True,
)
HandoffBuilder(participants=_as_handoff_agents(alpha, beta), **kwargs).with_start_agent(
_as_handoff_agent(alpha)
).build()
@pytest.mark.parametrize(
"build",
[
_build_sequential_with_designation,
_build_concurrent_with_designation,
_build_group_chat_with_designation,
_build_magentic_with_designation,
_build_handoff_with_designation,
],
)
@pytest.mark.parametrize(
("kwargs", "match"),
[
({"output_from": [], "intermediate_output_from": []}, "cannot both be empty"),
({"output_from": ["alpha", "alpha"]}, "Duplicate output participant"),
({"output_from": ["alpha"], "intermediate_output_from": ["alpha"]}, "cannot be both output"),
({"output_from": ["missing"]}, "Unknown output participant"),
({"output_from": "all_other"}, "output_from='all_other'"),
],
)
def test_participant_output_config_validation(build: Callable[..., None], kwargs: dict[str, Any], match: str) -> None:
with pytest.raises(ValueError, match=match):
build(**kwargs)
@pytest.mark.parametrize(
"build",
[
_build_sequential_with_designation,
_build_concurrent_with_designation,
_build_group_chat_with_designation,
_build_magentic_with_designation,
_build_handoff_with_designation,
],
)
def test_participant_output_config_rejects_final_output_from_parameter(build: Callable[..., None]) -> None:
with pytest.raises(TypeError, match="final_output_from"):
build(final_output_from=["beta"])
@@ -0,0 +1,258 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for orchestration request info support."""
from collections.abc import AsyncIterable
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentSession,
Content,
Message,
SupportsAgentRun,
)
from agent_framework._workflows._agent_executor import AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._workflow_context import WorkflowContext
from agent_framework_orchestrations._orchestration_request_info import (
AgentApprovalExecutor,
AgentRequestInfoExecutor,
AgentRequestInfoResponse,
resolve_request_info_filter,
)
class TestResolveRequestInfoFilter:
"""Tests for resolve_request_info_filter function."""
def test_returns_empty_set_for_none_input(self):
"""Test that None input returns empty set (no filtering)."""
result = resolve_request_info_filter(None)
assert result == set()
def test_returns_empty_set_for_empty_list(self):
"""Test that empty list returns empty set."""
result = resolve_request_info_filter([])
assert result == set()
def test_resolves_string_names(self):
"""Test resolving string agent names."""
result = resolve_request_info_filter(["agent1", "agent2"])
assert result == {"agent1", "agent2"}
def test_resolves_agent_display_names(self):
"""Test resolving SupportsAgentRun instances by name attribute."""
agent1 = MagicMock(spec=SupportsAgentRun)
agent1.name = "writer"
agent2 = MagicMock(spec=SupportsAgentRun)
agent2.name = "reviewer"
result = resolve_request_info_filter([agent1, agent2])
assert result == {"writer", "reviewer"}
def test_mixed_types(self):
"""Test resolving a mix of strings and agents."""
agent = MagicMock(spec=SupportsAgentRun)
agent.name = "writer"
result = resolve_request_info_filter(["manual_name", agent])
assert result == {"manual_name", "writer"}
def test_raises_on_unsupported_type(self):
"""Test that unsupported types raise TypeError."""
with pytest.raises(TypeError, match="Unsupported type for request_info filter"):
resolve_request_info_filter([123]) # type: ignore
class TestAgentRequestInfoResponse:
"""Tests for AgentRequestInfoResponse dataclass."""
def test_create_response_with_messages(self):
"""Test creating an AgentRequestInfoResponse with messages."""
messages = [Message(role="user", contents=["Additional info"])]
response = AgentRequestInfoResponse(messages=messages)
assert response.messages == messages
def test_from_messages_factory(self):
"""Test creating response from Message list."""
messages = [
Message(role="user", contents=["Message 1"]),
Message(role="user", contents=["Message 2"]),
]
response = AgentRequestInfoResponse.from_messages(messages)
assert response.messages == messages
def test_from_strings_factory(self):
"""Test creating response from string list."""
texts = ["First message", "Second message"]
response = AgentRequestInfoResponse.from_strings(texts)
assert len(response.messages) == 2
assert response.messages[0].role == "user"
assert response.messages[0].text == "First message"
assert response.messages[1].role == "user"
assert response.messages[1].text == "Second message"
def test_approve_factory(self):
"""Test creating an approval response (empty messages)."""
response = AgentRequestInfoResponse.approve()
assert response.messages == []
class TestAgentRequestInfoExecutor:
"""Tests for AgentRequestInfoExecutor."""
@pytest.mark.asyncio
async def test_request_info_handler(self):
"""Test that request_info handler calls ctx.request_info."""
executor = AgentRequestInfoExecutor(id="test_executor")
agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Agent response"])])
executor_response = AgentExecutorResponse(
executor_id="test_agent",
agent_response=agent_response,
full_conversation=agent_response.messages,
)
ctx = MagicMock(spec=WorkflowContext)
ctx.request_info = AsyncMock()
await executor.request_info(executor_response, ctx)
ctx.request_info.assert_called_once_with(executor_response, AgentRequestInfoResponse)
@pytest.mark.asyncio
async def test_handle_request_info_response_with_messages(self):
"""Test response handler when user provides additional messages."""
executor = AgentRequestInfoExecutor(id="test_executor")
agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Original"])])
original_request = AgentExecutorResponse(
executor_id="test_agent",
agent_response=agent_response,
full_conversation=agent_response.messages,
)
response = AgentRequestInfoResponse.from_strings(["Additional input"])
ctx = MagicMock(spec=WorkflowContext)
ctx.send_message = AsyncMock()
await executor.handle_request_info_response(original_request, response, ctx)
# Should send new request with additional messages
ctx.send_message.assert_called_once()
call_args = ctx.send_message.call_args[0][0]
assert isinstance(call_args, AgentExecutorRequest)
assert call_args.should_respond is True
assert len(call_args.messages) == 1
assert call_args.messages[0].text == "Additional input"
@pytest.mark.asyncio
async def test_handle_request_info_response_approval(self):
"""Test response handler when user approves (no additional messages)."""
executor = AgentRequestInfoExecutor(id="test_executor")
agent_response = AgentResponse(messages=[Message(role="assistant", contents=["Original"])])
original_request = AgentExecutorResponse(
executor_id="test_agent",
agent_response=agent_response,
full_conversation=agent_response.messages,
)
response = AgentRequestInfoResponse.approve()
ctx = MagicMock(spec=WorkflowContext)
ctx.yield_output = AsyncMock()
await executor.handle_request_info_response(original_request, response, ctx)
# Should yield original response without modification
ctx.yield_output.assert_called_once_with(original_request)
class _TestAgent:
"""Simple test agent implementation."""
def __init__(self, id: str, name: str | None = None, description: str | None = None):
self._id = id
self._name = name
self._description = description
@property
def id(self) -> str:
return self._id
@property
def name(self) -> str | None:
return self._name
@property
def display_name(self) -> str:
return self._name or self._id
@property
def description(self) -> str | None:
return self._description
async def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Any:
"""Dummy run method."""
if stream:
return self._run_stream_impl()
return AgentResponse(messages=[Message(role="assistant", contents=["Test response"])])
async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="Test response stream")])
def create_session(self, **kwargs: Any) -> AgentSession:
"""Creates a new conversation session for the agent."""
return AgentSession(**kwargs)
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
"""Gets a conversation session for the agent."""
return AgentSession(service_session_id=service_session_id, session_id=session_id)
class TestAgentApprovalExecutor:
"""Tests for AgentApprovalExecutor."""
def test_initialization(self):
"""Test that AgentApprovalExecutor initializes correctly."""
agent = _TestAgent(id="test_id", name="test_agent", description="Test agent description")
executor = AgentApprovalExecutor(cast(SupportsAgentRun, agent))
assert executor.id == "test_agent"
assert executor.description == "Test agent description"
def test_builds_workflow_with_agent_and_request_info_executors(self):
"""Test that the internal workflow is created successfully."""
agent = _TestAgent(id="test_id", name="test_agent", description="Test description")
executor = AgentApprovalExecutor(cast(SupportsAgentRun, agent))
# Verify the executor has a workflow
assert executor.workflow is not None
assert executor.id == "test_agent"
def test_propagate_request_enabled(self):
"""Test that AgentApprovalExecutor has propagate_request enabled."""
agent = _TestAgent(id="test_id", name="test_agent", description="Test description")
executor = AgentApprovalExecutor(cast(SupportsAgentRun, agent))
assert executor._propagate_request is True # type: ignore
@@ -0,0 +1,477 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Awaitable, Sequence
from typing import Any, Literal, overload
import pytest
from agent_framework import (
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Executor,
Message,
ResponseStream,
TypeCompatibilityError,
WorkflowContext,
WorkflowRunState,
handler,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework.orchestrations import SequentialBuilder
class _EchoAgent(BaseAgent):
"""Simple agent that appends a single assistant message with its name."""
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")])
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])])
return _run()
class _SummarizerTerminator(Executor):
"""Custom-executor terminator that yields a synthesized summary as the workflow's final answer."""
@handler
async def summarize(
self,
agent_response: AgentExecutorResponse,
ctx: WorkflowContext[Any, AgentResponse],
) -> None:
conversation = agent_response.full_conversation or []
user_texts = [m.text for m in conversation if m.role == "user"]
agents = [m.author_name or m.role for m in conversation if m.role == "assistant"]
summary = Message("assistant", [f"Summary of users:{len(user_texts)} agents:{len(agents)}"])
await ctx.yield_output(AgentResponse(messages=[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[Message]]) -> None:
pass
def test_sequential_builder_rejects_empty_participants() -> None:
with pytest.raises(ValueError):
SequentialBuilder(participants=[])
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_streaming_yields_only_last_agent_updates() -> None:
"""Streaming mode surfaces only the last agent's AgentResponseUpdate chunks as outputs.
Intermediate agents do NOT emit `output` events; only the last agent (the workflow's
output_executor) emits chunks of the final answer.
"""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participants=[a1, a2]).build()
completed = False
update_events: list[AgentResponseUpdate] = []
async for ev in wf.run("hello sequential", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
completed = True
elif ev.type == "output":
update_events.append(ev.data) # type: ignore[arg-type]
if completed:
break
assert completed
# Only the last agent's streaming chunks surface as `output` events.
assert update_events, "Expected at least one streaming update from the last agent"
for upd in update_events:
assert isinstance(upd, AgentResponseUpdate)
combined_text = "".join(u.text for u in update_events if hasattr(u, "text"))
assert "A2 reply" in combined_text
assert "A1 reply" not in combined_text
async def test_sequential_non_streaming_yields_only_last_agent_response() -> None:
"""Non-streaming mode emits a single `output` event with the last agent's AgentResponse."""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participants=[a1, a2]).build()
output_events = [ev for ev in await wf.run("hello sequential") if ev.type == "output"]
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
assert all(m.role == "assistant" for m in response.messages)
combined = " ".join(m.text for m in response.messages)
assert "A2 reply" in combined
assert "A1 reply" not in combined
async def test_sequential_as_agent_returns_only_last_agent_response() -> None:
"""`workflow.as_agent().run(prompt)` returns ONLY the last agent's messages — not the user
input or earlier agents' replies. This is the core fix for the orchestration-as-agent
output contract."""
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
agent = SequentialBuilder(participants=[a1, a2]).build().as_agent()
response = await agent.run("hello as_agent")
assert isinstance(response, AgentResponse)
# Only the last agent's reply — no user prompt, no agent1 messages.
combined = " ".join(m.text for m in response.messages)
assert "A2 reply" in combined
assert "A1 reply" not in combined
assert "hello as_agent" not in combined
async def test_sequential_with_custom_executor_summary() -> None:
"""A custom-executor terminator yields its own AgentResponse — that becomes the workflow output.
Custom executors used as the terminator must call `ctx.yield_output(AgentResponse(...))`
directly (rather than `ctx.send_message(list[Message])` like an intermediate executor would),
because the terminator IS the workflow's output executor.
"""
a1 = _EchoAgent(id="agent1", name="A1")
summarizer = _SummarizerTerminator(id="summarizer")
wf = SequentialBuilder(participants=[a1, summarizer]).build()
output_events = [ev for ev in await wf.run("topic X") if ev.type == "output"]
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert response.messages[0].role == "assistant"
assert response.messages[0].text.startswith("Summary of users:")
async def test_sequential_checkpoint_resume_round_trip() -> None:
storage = InMemoryCheckpointStorage()
initial_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder(participants=list(initial_agents), checkpoint_storage=storage).build()
baseline_updates: list[AgentResponseUpdate] = []
async for ev in wf.run("checkpoint sequential", stream=True):
if ev.type == "output":
baseline_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_updates
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build()
resumed_updates: list[AgentResponseUpdate] = []
async for ev in wf_resume.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if ev.type == "output":
resumed_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_updates
baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text"))
resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text"))
assert baseline_text == resumed_text
async def test_sequential_checkpoint_runtime_only() -> None:
"""Test checkpointing configured ONLY at runtime, not at build time."""
storage = InMemoryCheckpointStorage()
agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder(participants=list(agents)).build()
baseline_updates: list[AgentResponseUpdate] = []
async for ev in wf.run("runtime checkpoint test", checkpoint_storage=storage, stream=True):
if ev.type == "output":
baseline_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_updates
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents)).build()
resumed_updates: list[AgentResponseUpdate] = []
async for ev in wf_resume.run(
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage, stream=True
):
if ev.type == "output":
resumed_updates.append(ev.data) # type: ignore[arg-type]
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
break
assert resumed_updates
baseline_text = "".join(u.text for u in baseline_updates if hasattr(u, "text"))
resumed_text = "".join(u.text for u in resumed_updates if hasattr(u, "text"))
assert baseline_text == resumed_text
async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
"""Test that runtime checkpoint storage overrides build-time configuration."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
from agent_framework._workflows._checkpoint import FileCheckpointStorage
buildtime_storage = FileCheckpointStorage(temp_dir1)
runtime_storage = FileCheckpointStorage(temp_dir2)
agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf = SequentialBuilder(participants=list(agents), checkpoint_storage=buildtime_storage).build()
baseline_output: list[Message] | None = None
async for ev in wf.run("override test", checkpoint_storage=runtime_storage, stream=True):
if ev.type == "output":
baseline_output = ev.data # type: ignore[assignment]
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
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_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
# ---------------------------------------------------------------------------
# chain_only_agent_responses tests
# ---------------------------------------------------------------------------
class _CapturingAgent(BaseAgent):
"""Agent that records the messages it received and returns a configurable reply."""
def __init__(self, *, reply_text: str = "reply", **kwargs: Any):
super().__init__(**kwargs)
self.reply_text = reply_text
self.last_messages: list[Message] = []
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...
@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...
def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
captured: list[Message] = []
if messages:
message_items = messages if isinstance(messages, Sequence) and not isinstance(messages, str) else [messages]
for m in message_items:
if isinstance(m, Message):
captured.append(m)
elif isinstance(m, str):
captured.append(Message("user", [m]))
self.last_messages = captured
if stream:
async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=self.reply_text)])
return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)
async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", [self.reply_text])])
return _run()
async def test_chain_only_agent_responses_false_passes_full_conversation() -> None:
"""Default (chain_only_agent_responses=False) passes full conversation to the second agent."""
a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply")
a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply")
wf = SequentialBuilder(participants=[a1, a2], chain_only_agent_responses=False).build()
async for ev in wf.run("hello", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# Second agent should see full conversation: [user("hello"), assistant("A1 reply")]
seen = a2.last_messages
assert len(seen) == 2
assert seen[0].role == "user" and "hello" in (seen[0].text or "")
assert seen[1].role == "assistant" and "A1 reply" in (seen[1].text or "")
async def test_chain_only_agent_responses_true_passes_only_agent_messages() -> None:
"""chain_only_agent_responses=True passes only the previous agent's response messages."""
a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply")
a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply")
wf = SequentialBuilder(participants=[a1, a2], chain_only_agent_responses=True).build()
async for ev in wf.run("hello", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# Second agent should see only the assistant message: [assistant("A1 reply")]
seen = a2.last_messages
assert len(seen) == 1
assert seen[0].role == "assistant" and "A1 reply" in (seen[0].text or "")
async def test_chain_only_agent_responses_three_agents() -> None:
"""chain_only_agent_responses=True with three agents: each sees only the prior agent's reply."""
a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply")
a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply")
a3 = _CapturingAgent(id="agent3", name="A3", reply_text="A3 reply")
wf = SequentialBuilder(participants=[a1, a2, a3], chain_only_agent_responses=True).build()
async for ev in wf.run("hello", stream=True):
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# a2 should see only A1's reply
assert len(a2.last_messages) == 1
assert a2.last_messages[0].role == "assistant" and "A1 reply" in (a2.last_messages[0].text or "")
# a3 should see only A2's reply
assert len(a3.last_messages) == 1
assert a3.last_messages[0].role == "assistant" and "A2 reply" in (a3.last_messages[0].text or "")
# ---------------------------------------------------------------------------
# with_request_info tests
# ---------------------------------------------------------------------------
async def test_sequential_request_info_last_participant_emits_output() -> None:
"""When the last participant is wrapped via with_request_info(), the workflow
still emits a terminal output event after approval.
This exercises the _EndWithConversation.end_with_agent_executor_response path
that converts the AgentApprovalExecutor's forwarded AgentExecutorResponse into
the workflow's final AgentResponse output.
"""
from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse
a1 = _EchoAgent(id="agent1", name="A1")
a2 = _EchoAgent(id="agent2", name="A2")
wf = SequentialBuilder(participants=[a1, a2]).with_request_info().build()
# First run: collect request_info events for both agents
request_events: list[Any] = []
async for ev in wf.run("hello with approval", stream=True):
if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse):
request_events.append(ev)
# Approve each agent in sequence until the workflow completes
output_events: list[Any] = []
while request_events:
responses = {req.request_id: AgentRequestInfoResponse.approve() for req in request_events}
request_events = []
output_events = []
async for ev in wf.run(stream=True, responses=responses):
if ev.type == "request_info" and isinstance(ev.data, AgentExecutorResponse):
request_events.append(ev)
elif ev.type == "output":
output_events.append(ev)
# The workflow must produce a terminal output with the last agent's response.
assert len(output_events) == 1
response = output_events[0].data
assert isinstance(response, AgentResponse)
assert any("A2 reply" in m.text for m in response.messages)