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
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:
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
+600
@@ -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
+240
@@ -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,
|
||||
)
|
||||
+78
@@ -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,
|
||||
)
|
||||
+167
@@ -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)
|
||||
Reference in New Issue
Block a user