mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: Refactor orchestrations (#3023)
* Group chat refactoring Part 1; Next: HIL and handoff * Add agent approval flow; next samples * WIP: samples * WIP: HIL samples * Group chat HIL working; next: handoff * Fix group chat tool approval sample * WIP: refactor handoff; next handoff handling * Handoff done; next handoff samples and concurrent and sequential * Handoff samples, concurrent, and sequential done; next Magentic * WIP: magentic; next test with samples + HIL * Magentic Working; next fix all samples and tests * Fix handoff samples; next tests * WIP: fixing tests; some orchestration as agent samples are failing * Group chat unit tests done * Handoff unit tests done * Remove old orchestration_request_info and fix related tests * Magentic unit tests done * Fix samples * Fix test * Fix test 2 * mypy * Address comments * Update readme * Address comments * Address comments 2 * Replace display name
This commit is contained in:
committed by
GitHub
Unverified
parent
3e97425245
commit
0b152418b6
@@ -6,6 +6,13 @@ from ._agent_executor import (
|
||||
AgentExecutorRequest,
|
||||
AgentExecutorResponse,
|
||||
)
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._base_group_chat_orchestrator import (
|
||||
BaseGroupChatOrchestrator,
|
||||
GroupChatRequestMessage,
|
||||
GroupChatRequestSentEvent,
|
||||
GroupChatResponseReceivedEvent,
|
||||
)
|
||||
from ._checkpoint import (
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
@@ -56,37 +63,30 @@ from ._executor import (
|
||||
)
|
||||
from ._function_executor import FunctionExecutor, executor
|
||||
from ._group_chat import (
|
||||
DEFAULT_MANAGER_INSTRUCTIONS,
|
||||
DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT,
|
||||
AgentBasedGroupChatOrchestrator,
|
||||
GroupChatBuilder,
|
||||
GroupChatDirective,
|
||||
GroupChatStateSnapshot,
|
||||
ManagerDirectiveModel,
|
||||
ManagerSelectionRequest,
|
||||
ManagerSelectionResponse,
|
||||
GroupChatState,
|
||||
)
|
||||
from ._handoff import HandoffBuilder, HandoffUserInputRequest
|
||||
from ._handoff import HandoffAgentUserRequest, HandoffBuilder, HandoffSentEvent
|
||||
from ._magentic import (
|
||||
MAGENTIC_EVENT_TYPE_AGENT_DELTA,
|
||||
MAGENTIC_EVENT_TYPE_ORCHESTRATOR,
|
||||
ORCH_MSG_KIND_INSTRUCTION,
|
||||
ORCH_MSG_KIND_NOTICE,
|
||||
ORCH_MSG_KIND_TASK_LEDGER,
|
||||
ORCH_MSG_KIND_USER_TASK,
|
||||
MagenticBuilder,
|
||||
MagenticContext,
|
||||
MagenticHumanInputRequest,
|
||||
MagenticHumanInterventionDecision,
|
||||
MagenticHumanInterventionKind,
|
||||
MagenticHumanInterventionReply,
|
||||
MagenticHumanInterventionRequest,
|
||||
MagenticManagerBase,
|
||||
MagenticStallInterventionDecision,
|
||||
MagenticStallInterventionReply,
|
||||
MagenticStallInterventionRequest,
|
||||
MagenticOrchestrator,
|
||||
MagenticOrchestratorEvent,
|
||||
MagenticOrchestratorEventType,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticPlanReviewResponse,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
MagenticResetSignal,
|
||||
StandardMagenticManager,
|
||||
)
|
||||
from ._orchestration_request_info import AgentInputRequest, AgentResponseReviewRequest, RequestInfoInterceptor
|
||||
from ._orchestration_request_info import AgentRequestInfoResponse
|
||||
from ._orchestration_state import OrchestrationState
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner import Runner
|
||||
@@ -112,22 +112,19 @@ from ._workflow_context import WorkflowContext
|
||||
from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MANAGER_INSTRUCTIONS",
|
||||
"DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"MAGENTIC_EVENT_TYPE_AGENT_DELTA",
|
||||
"MAGENTIC_EVENT_TYPE_ORCHESTRATOR",
|
||||
"ORCH_MSG_KIND_INSTRUCTION",
|
||||
"ORCH_MSG_KIND_NOTICE",
|
||||
"ORCH_MSG_KIND_TASK_LEDGER",
|
||||
"ORCH_MSG_KIND_USER_TASK",
|
||||
"AgentBasedGroupChatOrchestrator",
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentInputRequest",
|
||||
"AgentResponseReviewRequest",
|
||||
"AgentRequestInfoResponse",
|
||||
"AgentRunEvent",
|
||||
"AgentRunUpdateEvent",
|
||||
"BaseGroupChatOrchestrator",
|
||||
"Case",
|
||||
"CheckpointStorage",
|
||||
"ConcurrentBuilder",
|
||||
@@ -146,30 +143,29 @@ __all__ = [
|
||||
"FunctionExecutor",
|
||||
"GraphConnectivityError",
|
||||
"GroupChatBuilder",
|
||||
"GroupChatDirective",
|
||||
"GroupChatStateSnapshot",
|
||||
"GroupChatRequestMessage",
|
||||
"GroupChatRequestSentEvent",
|
||||
"GroupChatResponseReceivedEvent",
|
||||
"GroupChatState",
|
||||
"HandoffAgentUserRequest",
|
||||
"HandoffBuilder",
|
||||
"HandoffUserInputRequest",
|
||||
"HandoffSentEvent",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"MagenticBuilder",
|
||||
"MagenticContext",
|
||||
"MagenticHumanInputRequest",
|
||||
"MagenticHumanInterventionDecision",
|
||||
"MagenticHumanInterventionKind",
|
||||
"MagenticHumanInterventionReply",
|
||||
"MagenticHumanInterventionRequest",
|
||||
"MagenticManagerBase",
|
||||
"MagenticStallInterventionDecision",
|
||||
"MagenticStallInterventionReply",
|
||||
"MagenticStallInterventionRequest",
|
||||
"ManagerDirectiveModel",
|
||||
"ManagerSelectionRequest",
|
||||
"ManagerSelectionResponse",
|
||||
"MagenticOrchestrator",
|
||||
"MagenticOrchestratorEvent",
|
||||
"MagenticOrchestratorEventType",
|
||||
"MagenticPlanReviewRequest",
|
||||
"MagenticPlanReviewResponse",
|
||||
"MagenticProgressLedger",
|
||||
"MagenticProgressLedgerItem",
|
||||
"MagenticResetSignal",
|
||||
"Message",
|
||||
"OrchestrationState",
|
||||
"RequestInfoEvent",
|
||||
"RequestInfoInterceptor",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SequentialBuilder",
|
||||
@@ -208,6 +204,7 @@ __all__ = [
|
||||
"executor",
|
||||
"get_checkpoint_summary",
|
||||
"handler",
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
"validate_workflow_graph",
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ from agent_framework import FunctionApprovalRequestContent, FunctionApprovalResp
|
||||
from .._agents import AgentProtocol, ChatAgent
|
||||
from .._threads import AgentThread
|
||||
from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
|
||||
from ._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._conversation_state import encode_chat_messages
|
||||
@@ -88,16 +89,20 @@ class AgentExecutor(Executor):
|
||||
id: A unique identifier for the executor. If None, the agent's name will be used if available.
|
||||
"""
|
||||
# Prefer provided id; else use agent.name if present; else generate deterministic prefix
|
||||
exec_id = id or agent.name
|
||||
exec_id = id or resolve_agent_id(agent)
|
||||
if not exec_id:
|
||||
raise ValueError("Agent must have a name or an explicit id must be provided.")
|
||||
raise ValueError("Agent must have a non-empty name or id or an explicit id must be provided.")
|
||||
super().__init__(exec_id)
|
||||
self._agent = agent
|
||||
self._agent_thread = agent_thread or self._agent.get_new_thread()
|
||||
self._pending_agent_requests: dict[str, FunctionApprovalRequestContent] = {}
|
||||
self._pending_responses_to_agent: list[FunctionApprovalResponseContent] = []
|
||||
self._output_response = output_response
|
||||
|
||||
# AgentExecutor maintains an internal cache of messages in between runs
|
||||
self._cache: list[ChatMessage] = []
|
||||
# This tracks the full conversation after each run
|
||||
self._full_conversation: list[ChatMessage] = []
|
||||
|
||||
@property
|
||||
def output_response(self) -> bool:
|
||||
@@ -227,6 +232,7 @@ class AgentExecutor(Executor):
|
||||
|
||||
return {
|
||||
"cache": encode_chat_messages(self._cache),
|
||||
"full_conversation": encode_chat_messages(self._full_conversation),
|
||||
"agent_thread": serialized_thread,
|
||||
"pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests),
|
||||
"pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent),
|
||||
@@ -251,6 +257,16 @@ class AgentExecutor(Executor):
|
||||
else:
|
||||
self._cache = []
|
||||
|
||||
full_conversation_payload = state.get("full_conversation")
|
||||
if full_conversation_payload:
|
||||
try:
|
||||
self._full_conversation = decode_chat_messages(full_conversation_payload)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore full conversation: %s", exc)
|
||||
self._full_conversation = []
|
||||
else:
|
||||
self._full_conversation = []
|
||||
|
||||
thread_payload = state.get("agent_thread")
|
||||
if thread_payload:
|
||||
try:
|
||||
@@ -289,6 +305,12 @@ class AgentExecutor(Executor):
|
||||
# Non-streaming mode: use run() and emit single event
|
||||
response = await self._run_agent(cast(WorkflowContext, ctx))
|
||||
|
||||
# Always extend full conversation with cached messages plus agent outputs
|
||||
# (agent_run_response.messages) after each run. This is to avoid losing context
|
||||
# when agent did not complete and the cache is cleared when responses come back.
|
||||
# Do not mutate response.messages so AgentRunEvent remains faithful to the raw output.
|
||||
self._full_conversation.extend(list(self._cache) + (list(response.messages) if response else []))
|
||||
|
||||
if response is None:
|
||||
# Agent did not complete (e.g., waiting for user input); do not emit response
|
||||
logger.info("AgentExecutor %s: Agent did not complete, awaiting user input", self.id)
|
||||
@@ -297,12 +319,7 @@ class AgentExecutor(Executor):
|
||||
if self._output_response:
|
||||
await ctx.yield_output(response)
|
||||
|
||||
# Always construct a full conversation snapshot from inputs (cache)
|
||||
# plus agent outputs (agent_run_response.messages). Do not mutate
|
||||
# response.messages so AgentRunEvent remains faithful to the raw output.
|
||||
full_conversation: list[ChatMessage] = list(self._cache) + list(response.messages)
|
||||
|
||||
agent_response = AgentExecutorResponse(self.id, response, full_conversation=full_conversation)
|
||||
agent_response = AgentExecutorResponse(self.id, response, full_conversation=self._full_conversation)
|
||||
await ctx.send_message(agent_response)
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from .._agents import AgentProtocol
|
||||
|
||||
|
||||
def resolve_agent_id(agent: AgentProtocol) -> str:
|
||||
"""Resolve the unique identifier for an agent.
|
||||
|
||||
Prefers the `.name` attribute if set; otherwise falls back to `.id`.
|
||||
|
||||
Args:
|
||||
agent: The agent whose identifier is to be resolved.
|
||||
|
||||
Returns:
|
||||
The resolved unique identifier for the agent.
|
||||
"""
|
||||
return agent.name if agent.name else agent.id
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
"""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, abstractmethod
|
||||
from abc import ABC
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, TypeAlias
|
||||
|
||||
from .._types import ChatMessage
|
||||
from ._executor import Executor
|
||||
from ._orchestrator_helpers import ParticipantRegistry
|
||||
from typing_extensions import Never
|
||||
|
||||
from .._types import ChatMessage, Role
|
||||
from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
|
||||
from ._events import WorkflowEvent
|
||||
from ._executor import Executor, handler
|
||||
from ._orchestration_request_info import AgentApprovalExecutor
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
@@ -23,6 +30,129 @@ else:
|
||||
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[ChatMessage]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupChatResponseMessage:
|
||||
"""Response envelope emitted by participants back to the orchestrator."""
|
||||
|
||||
message: ChatMessage
|
||||
|
||||
|
||||
TerminationCondition: TypeAlias = Callable[[list[ChatMessage]], bool | Awaitable[bool]]
|
||||
GroupChatWorkflowContext_T_Out: TypeAlias = AgentExecutorRequest | GroupChatRequestMessage | GroupChatParticipantMessage
|
||||
|
||||
|
||||
# region Group chat events
|
||||
class GroupChatEvent(WorkflowEvent):
|
||||
"""Base class for group chat workflow events."""
|
||||
|
||||
def __init__(self, round_index: int, data: Any | None = None) -> None:
|
||||
"""Initialize group chat event.
|
||||
|
||||
Args:
|
||||
round_index: Current round index
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(data)
|
||||
self.round_index = round_index
|
||||
|
||||
|
||||
class GroupChatResponseReceivedEvent(GroupChatEvent):
|
||||
"""Event emitted when a participant response is received."""
|
||||
|
||||
def __init__(self, round_index: int, participant_name: str, data: Any | None = None) -> None:
|
||||
"""Initialize response received event.
|
||||
|
||||
Args:
|
||||
round_index: Current round index
|
||||
participant_name: Name of the participant who sent the response
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(round_index, data)
|
||||
self.participant_name = participant_name
|
||||
|
||||
|
||||
class GroupChatRequestSentEvent(GroupChatEvent):
|
||||
"""Event emitted when a request is sent to a participant."""
|
||||
|
||||
def __init__(self, round_index: int, participant_name: str, data: Any | None = None) -> None:
|
||||
"""Initialize request sent event.
|
||||
|
||||
Args:
|
||||
round_index: Current round index
|
||||
participant_name: Name of the participant to whom the request was sent
|
||||
data: Optional event-specific data
|
||||
"""
|
||||
super().__init__(round_index, data)
|
||||
self.participant_name = participant_name
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -33,36 +163,159 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
inheriting the common participant management infrastructure.
|
||||
"""
|
||||
|
||||
def __init__(self, executor_id: str) -> None:
|
||||
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:
|
||||
executor_id: Unique identifier for this orchestrator executor
|
||||
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__(executor_id)
|
||||
self._registry = ParticipantRegistry()
|
||||
# Shared conversation state management
|
||||
self._conversation: list[ChatMessage] = []
|
||||
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._max_rounds: int | None = None
|
||||
self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None
|
||||
self._participant_registry = participant_registry
|
||||
# Shared conversation state management
|
||||
self._full_conversation: list[ChatMessage] = []
|
||||
|
||||
def register_participant_entry(
|
||||
self, name: str, *, entry_id: str, is_agent: bool, exit_id: str | None = None
|
||||
# region Handlers
|
||||
|
||||
@handler
|
||||
async def handle_str(
|
||||
self,
|
||||
task: str,
|
||||
ctx: WorkflowContext[GroupChatWorkflowContext_T_Out, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Record routing details for a participant's entry executor.
|
||||
"""Handler for string input as workflow entry point.
|
||||
|
||||
This method provides a unified interface for registering participants
|
||||
across all orchestrator patterns, whether they are agents or custom executors.
|
||||
Wraps the string in a USER role ChatMessage and delegates to _handle_task_message.
|
||||
|
||||
Args:
|
||||
name: Participant name (used for selection and tracking)
|
||||
entry_id: Executor ID for this participant's entry point
|
||||
is_agent: Whether this is an AgentExecutor (True) or custom Executor (False)
|
||||
exit_id: Executor ID for this participant's exit point (where responses come from).
|
||||
If None, defaults to entry_id.
|
||||
task: Plain text task description from user
|
||||
ctx: Workflow context
|
||||
|
||||
Usage:
|
||||
workflow.run("Write a blog post about AI agents")
|
||||
"""
|
||||
self._registry.register(name, entry_id=entry_id, is_agent=is_agent, exit_id=exit_id)
|
||||
await self._handle_messages([ChatMessage(role=Role.USER, text=task)], ctx)
|
||||
|
||||
@handler
|
||||
async def handle_message(
|
||||
self,
|
||||
task: ChatMessage,
|
||||
ctx: WorkflowContext[GroupChatWorkflowContext_T_Out, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Handler for single ChatMessage input as workflow entry point.
|
||||
|
||||
Wraps the message in a list and delegates to _handle_task_message.
|
||||
|
||||
Args:
|
||||
task: ChatMessage from user
|
||||
ctx: Workflow context
|
||||
|
||||
Usage:
|
||||
workflow.run(ChatMessage(role=Role.USER, text="Write a blog post about AI agents"))
|
||||
"""
|
||||
await self._handle_messages([task], ctx)
|
||||
|
||||
@handler
|
||||
async def handle_messages(
|
||||
self,
|
||||
task: list[ChatMessage],
|
||||
ctx: WorkflowContext[GroupChatWorkflowContext_T_Out, list[ChatMessage]],
|
||||
) -> 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([
|
||||
ChatMessage(role=Role.USER, text="Write a blog post about AI agents"),
|
||||
ChatMessage(role=Role.USER, text="Make it engaging and informative.")
|
||||
])
|
||||
"""
|
||||
if not task:
|
||||
raise ValueError("At least one ChatMessage 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[GroupChatWorkflowContext_T_Out, list[ChatMessage]],
|
||||
) -> 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(
|
||||
GroupChatResponseReceivedEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=ctx.source_executor_ids[0] if ctx.source_executor_ids else "unknown",
|
||||
data=response,
|
||||
)
|
||||
)
|
||||
await self._handle_response(response, ctx)
|
||||
|
||||
# endregion
|
||||
|
||||
# region Handler methods subclasses must implement
|
||||
|
||||
async def _handle_messages(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
ctx: WorkflowContext[GroupChatWorkflowContext_T_Out, list[ChatMessage]],
|
||||
) -> 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[GroupChatWorkflowContext_T_Out, list[ChatMessage]],
|
||||
) -> 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)
|
||||
|
||||
@@ -72,7 +325,7 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
Args:
|
||||
messages: Messages to append
|
||||
"""
|
||||
self._conversation.extend(messages)
|
||||
self._full_conversation.extend(messages)
|
||||
|
||||
def _get_conversation(self) -> list[ChatMessage]:
|
||||
"""Get a copy of the current conversation.
|
||||
@@ -80,11 +333,27 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
Returns:
|
||||
Cloned conversation list
|
||||
"""
|
||||
return list(self._conversation)
|
||||
return list(self._full_conversation)
|
||||
|
||||
def _process_participant_response(
|
||||
self, response: AgentExecutorResponse | GroupChatResponseMessage
|
||||
) -> list[ChatMessage]:
|
||||
"""Extract ChatMessage from participant response.
|
||||
|
||||
Args:
|
||||
response: Response from participant
|
||||
Returns:
|
||||
List of ChatMessages extracted from the response
|
||||
"""
|
||||
if isinstance(response, AgentExecutorResponse):
|
||||
return response.agent_run_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._conversation.clear()
|
||||
self._full_conversation.clear()
|
||||
|
||||
def _increment_round(self) -> None:
|
||||
"""Increment the round counter."""
|
||||
@@ -102,97 +371,121 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
return False
|
||||
|
||||
result = self._termination_condition(self._get_conversation())
|
||||
if inspect.iscoroutine(result) or inspect.isawaitable(result):
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return bool(result)
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
def _get_author_name(self) -> str:
|
||||
"""Get the author name for orchestrator-generated messages.
|
||||
async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool:
|
||||
"""Check termination conditions and yield completion if met.
|
||||
|
||||
Subclasses must implement this to provide a stable author name
|
||||
for completion messages and other orchestrator-generated content.
|
||||
Args:
|
||||
ctx: Workflow context for yielding output
|
||||
|
||||
Returns:
|
||||
Author name to use for messages generated by this orchestrator
|
||||
True if termination condition met and output yielded, False otherwise
|
||||
"""
|
||||
...
|
||||
terminate = await self._check_termination()
|
||||
if terminate:
|
||||
self._append_messages([self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE)])
|
||||
await ctx.yield_output(self._full_conversation)
|
||||
return True
|
||||
|
||||
def _create_completion_message(
|
||||
self,
|
||||
text: str | None = None,
|
||||
reason: str = "completed",
|
||||
) -> ChatMessage:
|
||||
return False
|
||||
|
||||
def _create_completion_message(self, message: str) -> ChatMessage:
|
||||
"""Create a standardized completion message.
|
||||
|
||||
Args:
|
||||
text: Optional message text (auto-generated if None)
|
||||
reason: Completion reason for default text
|
||||
message: Completion text
|
||||
|
||||
Returns:
|
||||
ChatMessage with completion content
|
||||
"""
|
||||
from .._types import Role
|
||||
|
||||
message_text = text or f"Conversation {reason}."
|
||||
return ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
text=message_text,
|
||||
author_name=self._get_author_name(),
|
||||
)
|
||||
return ChatMessage(role=Role.ASSISTANT, text=message, author_name=self._name)
|
||||
|
||||
# Participant routing (shared across all patterns)
|
||||
|
||||
async def _route_to_participant(
|
||||
async def _broadcast_messages_to_participants(
|
||||
self,
|
||||
participant_name: str,
|
||||
conversation: list[ChatMessage],
|
||||
ctx: WorkflowContext[Any, Any],
|
||||
messages: list[ChatMessage],
|
||||
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],
|
||||
*,
|
||||
instruction: str | None = None,
|
||||
task: ChatMessage | None = None,
|
||||
additional_instruction: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Route a conversation to a participant.
|
||||
"""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:
|
||||
participant_name: Name of the participant to route to
|
||||
conversation: Conversation history to send
|
||||
target: Name of the participant to route to
|
||||
ctx: Workflow context for message routing
|
||||
instruction: Optional instruction from manager/orchestrator
|
||||
task: Optional task context
|
||||
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
|
||||
"""
|
||||
from ._agent_executor import AgentExecutorRequest
|
||||
from ._orchestrator_helpers import prepare_participant_request
|
||||
|
||||
entry_id = self._registry.get_entry_id(participant_name)
|
||||
if entry_id is None:
|
||||
raise ValueError(f"No registered entry executor for participant '{participant_name}'.")
|
||||
|
||||
if self._registry.is_agent(participant_name):
|
||||
if self._participant_registry.is_agent(target):
|
||||
# AgentExecutors receive simple message list
|
||||
await ctx.send_message(
|
||||
AgentExecutorRequest(messages=conversation, should_respond=True),
|
||||
target_id=entry_id,
|
||||
messages: list[ChatMessage] = []
|
||||
if additional_instruction:
|
||||
messages.append(ChatMessage(role=Role.USER, text=additional_instruction))
|
||||
request = AgentExecutorRequest(messages=messages, should_respond=True)
|
||||
await ctx.send_message(request, target_id=target)
|
||||
await ctx.add_event(
|
||||
GroupChatRequestSentEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=target,
|
||||
data=request,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Custom executors receive full context envelope
|
||||
request = prepare_participant_request(
|
||||
participant_name=participant_name,
|
||||
conversation=conversation,
|
||||
instruction=instruction or "",
|
||||
task=task,
|
||||
metadata=metadata,
|
||||
request = GroupChatRequestMessage(additional_instruction=additional_instruction, metadata=metadata) # type: ignore[assignment]
|
||||
await ctx.send_message(request, target_id=target)
|
||||
await ctx.add_event(
|
||||
GroupChatRequestSentEvent(
|
||||
round_index=self._round_index,
|
||||
participant_name=target,
|
||||
data=request,
|
||||
)
|
||||
)
|
||||
await ctx.send_message(request, target_id=entry_id)
|
||||
|
||||
# Round limit enforcement (shared across all patterns)
|
||||
|
||||
@@ -217,6 +510,23 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
|
||||
return False
|
||||
|
||||
async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool:
|
||||
"""Check round limit and yield completion 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:
|
||||
self._append_messages([self._create_completion_message(self.MAX_ROUNDS_MET_MESSAGE)])
|
||||
await ctx.yield_output(self._full_conversation)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# State persistence (shared across all patterns)
|
||||
|
||||
# State persistence (shared across all patterns)
|
||||
@@ -234,8 +544,9 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
from ._orchestration_state import OrchestrationState
|
||||
|
||||
state = OrchestrationState(
|
||||
conversation=list(self._conversation),
|
||||
conversation=list(self._full_conversation),
|
||||
round_index=self._round_index,
|
||||
orchestrator_name=self._name,
|
||||
metadata=self._snapshot_pattern_metadata(),
|
||||
)
|
||||
return state.to_dict()
|
||||
@@ -263,8 +574,9 @@ class BaseGroupChatOrchestrator(Executor, ABC):
|
||||
from ._orchestration_state import OrchestrationState
|
||||
|
||||
orch_state = OrchestrationState.from_dict(state)
|
||||
self._conversation = list(orch_state.conversation)
|
||||
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:
|
||||
|
||||
@@ -10,11 +10,12 @@ from typing_extensions import Never
|
||||
|
||||
from agent_framework import AgentProtocol, ChatMessage, Role
|
||||
|
||||
from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse
|
||||
from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._executor import Executor, handler
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._orchestration_request_info import RequestInfoInterceptor
|
||||
from ._orchestration_request_info import AgentApprovalExecutor
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -247,6 +248,7 @@ class ConcurrentBuilder:
|
||||
self._aggregator_factory: Callable[[], Executor] | None = None
|
||||
self._checkpoint_storage: CheckpointStorage | None = None
|
||||
self._request_info_enabled: bool = False
|
||||
self._request_info_filter: set[str] | None = None
|
||||
|
||||
def register_participants(
|
||||
self,
|
||||
@@ -461,25 +463,68 @@ class ConcurrentBuilder:
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
return self
|
||||
|
||||
def with_request_info(self) -> "ConcurrentBuilder":
|
||||
"""Enable request info before aggregation in the workflow.
|
||||
def with_request_info(
|
||||
self,
|
||||
*,
|
||||
agents: Sequence[str | AgentProtocol] | None = None,
|
||||
) -> "ConcurrentBuilder":
|
||||
"""Enable request info after agent participant responses.
|
||||
|
||||
When enabled, the workflow pauses after all parallel agents complete,
|
||||
emitting a RequestInfoEvent that allows the caller to review and optionally
|
||||
modify the combined results before aggregation. The caller provides feedback
|
||||
via the standard response_handler/request_info pattern.
|
||||
This enables human-in-the-loop (HIL) scenarios for the sequential orchestration.
|
||||
When enabled, the workflow pauses after each agent participant runs, emitting
|
||||
a RequestInfoEvent 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.
|
||||
|
||||
Note:
|
||||
Unlike SequentialBuilder and GroupChatBuilder, ConcurrentBuilder does not
|
||||
support per-agent filtering since all agents run in parallel and results
|
||||
are collected together. The pause occurs once with all agent outputs received.
|
||||
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: The builder instance for fluent chaining.
|
||||
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."""
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
# Resolve the participant factories now. This doesn't break the factory pattern
|
||||
# since the Sequential builder still creates new instances per workflow build.
|
||||
for factory in self._participant_factories:
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
participants = self._participants
|
||||
|
||||
executors: list[Executor] = []
|
||||
for p in participants:
|
||||
if isinstance(p, Executor):
|
||||
executors.append(p)
|
||||
elif isinstance(p, AgentProtocol):
|
||||
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 AgentProtocol or Executor instances. Got {type(p).__name__}.")
|
||||
|
||||
return executors
|
||||
|
||||
def build(self) -> Workflow:
|
||||
r"""Build and validate the concurrent workflow.
|
||||
|
||||
@@ -521,29 +566,15 @@ class ConcurrentBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
# Resolve the participant factories now. This doesn't break the factory pattern
|
||||
# since the Concurrent builder still creates new instances per workflow build.
|
||||
for factory in self._participant_factories:
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
participants = self._participants
|
||||
# Resolve participants and participant factories to executors
|
||||
participants: list[Executor] = self._resolve_participants()
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.set_start_executor(dispatcher)
|
||||
# Fan-out for parallel execution
|
||||
builder.add_fan_out_edges(dispatcher, participants)
|
||||
|
||||
if self._request_info_enabled:
|
||||
# Insert interceptor between fan-in and aggregator
|
||||
# participants -> fan-in -> interceptor -> aggregator
|
||||
request_info_interceptor = RequestInfoInterceptor(executor_id="request_info")
|
||||
builder.add_fan_in_edges(participants, request_info_interceptor)
|
||||
builder.add_edge(request_info_interceptor, aggregator)
|
||||
else:
|
||||
# Direct fan-in to aggregator
|
||||
builder.add_fan_in_edges(participants, aggregator)
|
||||
# Direct fan-in to aggregator
|
||||
builder.add_fan_in_edges(participants, aggregator)
|
||||
|
||||
if self._checkpoint_storage is not None:
|
||||
builder = builder.with_checkpointing(self._checkpoint_storage)
|
||||
|
||||
@@ -367,9 +367,9 @@ class ExecutorFailedEvent(ExecutorEvent):
|
||||
class AgentRunUpdateEvent(ExecutorEvent):
|
||||
"""Event triggered when an agent is streaming messages."""
|
||||
|
||||
data: AgentRunResponseUpdate | None
|
||||
data: AgentRunResponseUpdate
|
||||
|
||||
def __init__(self, executor_id: str, data: AgentRunResponseUpdate | None = None):
|
||||
def __init__(self, executor_id: str, data: AgentRunResponseUpdate):
|
||||
"""Initialize the agent streaming event."""
|
||||
super().__init__(executor_id, data)
|
||||
|
||||
@@ -381,9 +381,9 @@ class AgentRunUpdateEvent(ExecutorEvent):
|
||||
class AgentRunEvent(ExecutorEvent):
|
||||
"""Event triggered when an agent run is completed."""
|
||||
|
||||
data: AgentRunResponse | None
|
||||
data: AgentRunResponse
|
||||
|
||||
def __init__(self, executor_id: str, data: AgentRunResponse | None = None):
|
||||
def __init__(self, executor_id: str, data: AgentRunResponse):
|
||||
"""Initialize the agent run event."""
|
||||
super().__init__(executor_id, data)
|
||||
|
||||
|
||||
@@ -250,6 +250,8 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
):
|
||||
# Find the handler and handler spec that matches the message type.
|
||||
handler = self._find_handler(message)
|
||||
|
||||
original_message = message
|
||||
if isinstance(message, Message):
|
||||
# Unwrap raw data for handler call
|
||||
message = message.data
|
||||
@@ -261,6 +263,9 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
runner_context=runner_context,
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
request_id=original_message.original_request_info_event.request_id
|
||||
if isinstance(original_message, Message) and original_message.original_request_info_event
|
||||
else None,
|
||||
)
|
||||
|
||||
# Invoke the handler with the message and context
|
||||
@@ -291,6 +296,7 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> WorkflowContext[Any]:
|
||||
"""Create the appropriate WorkflowContext based on the handler's context annotation.
|
||||
|
||||
@@ -300,6 +306,7 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking.
|
||||
request_id: Optional request ID if this context is for a `handle_response` handler.
|
||||
|
||||
Returns:
|
||||
WorkflowContext[Any] based on the handler's context annotation.
|
||||
@@ -312,6 +319,7 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
runner_context=runner_context,
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def _discover_handlers(self) -> None:
|
||||
@@ -356,7 +364,17 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
True if the executor can handle the message type, False otherwise.
|
||||
"""
|
||||
if message.type == MessageType.RESPONSE:
|
||||
return any(is_instance_of(message.data, message_type) for message_type in self._response_handlers)
|
||||
if message.original_request_info_event is None:
|
||||
logger.warning(
|
||||
f"Executor {self.__class__.__name__} received a response message without an original request event."
|
||||
)
|
||||
return False
|
||||
|
||||
return any(
|
||||
is_instance_of(message.original_request_info_event.data, message_type[0])
|
||||
and is_instance_of(message.data, message_type[1])
|
||||
for message_type in self._response_handlers
|
||||
)
|
||||
|
||||
return any(is_instance_of(message.data, message_type) for message_type in self._handlers)
|
||||
|
||||
@@ -427,7 +445,7 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
output_types: set[type[Any]] = set()
|
||||
|
||||
# Collect workflow output types from all handlers
|
||||
for handler_spec in self._handler_specs:
|
||||
for handler_spec in self._handler_specs + self._response_handler_specs:
|
||||
handler_workflow_output_types = handler_spec.get("workflow_output_types", [])
|
||||
output_types.update(handler_workflow_output_types)
|
||||
|
||||
@@ -457,11 +475,15 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
f"Executor {self.__class__.__name__} cannot handle message of type {type(message.data)}."
|
||||
)
|
||||
# Response message case - find response handler based on original request and response types
|
||||
handler = self._find_response_handler(message.original_request, message.data)
|
||||
if message.original_request_info_event is None:
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} received a response message without an original request event."
|
||||
)
|
||||
handler = self._find_response_handler(message.original_request_info_event.data, message.data)
|
||||
if not handler:
|
||||
raise RuntimeError(
|
||||
f"Executor {self.__class__.__name__} cannot handle request of type "
|
||||
f"{type(message.original_request)} and response of type {type(message.data)}."
|
||||
f"{type(message.original_request_info_event.data)} and response of type {type(message.data)}."
|
||||
)
|
||||
return handler
|
||||
|
||||
|
||||
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
@@ -1,37 +1,20 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Request info support for high-level builder APIs.
|
||||
|
||||
This module provides a mechanism for pausing workflows to request external input
|
||||
before agent turns in `SequentialBuilder`, `ConcurrentBuilder`, `GroupChatBuilder`,
|
||||
and `HandoffBuilder`.
|
||||
|
||||
The design follows the standard `request_info` pattern used throughout the
|
||||
workflow system, keeping the API consistent and predictable.
|
||||
|
||||
Key components:
|
||||
- AgentInputRequest: Request type emitted via RequestInfoEvent for pre-agent steering
|
||||
- RequestInfoInterceptor: Internal executor that pauses workflow before agent runs
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._agents import AgentProtocol
|
||||
from .._types import ChatMessage, Role
|
||||
from ._agent_executor import AgentExecutorRequest
|
||||
from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._executor import Executor, handler
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
|
||||
|
||||
def resolve_request_info_filter(
|
||||
agents: list[str | AgentProtocol | Executor] | None,
|
||||
) -> set[str] | None:
|
||||
def resolve_request_info_filter(agents: list[str | AgentProtocol] | None) -> set[str]:
|
||||
"""Resolve a list of agent/executor references to a set of IDs for filtering.
|
||||
|
||||
Args:
|
||||
@@ -42,288 +25,122 @@ def resolve_request_info_filter(
|
||||
Set of executor/agent IDs to filter on, or None if no filtering.
|
||||
"""
|
||||
if agents is None:
|
||||
return None
|
||||
return set()
|
||||
|
||||
result: set[str] = set()
|
||||
for agent in agents:
|
||||
if isinstance(agent, str):
|
||||
result.add(agent)
|
||||
elif isinstance(agent, Executor):
|
||||
result.add(agent.id)
|
||||
elif isinstance(agent, AgentProtocol):
|
||||
name = getattr(agent, "name", None)
|
||||
if name:
|
||||
result.add(name)
|
||||
else:
|
||||
logger.warning("AgentProtocol without name cannot be used for request_info filtering")
|
||||
result.add(resolve_agent_id(agent))
|
||||
else:
|
||||
logger.warning(f"Unsupported type for request_info filter: {type(agent).__name__}")
|
||||
raise TypeError(f"Unsupported type for request_info filter: {type(agent).__name__}")
|
||||
|
||||
return result if result else None
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInputRequest:
|
||||
"""Request for human input before an agent runs in high-level builder workflows.
|
||||
|
||||
Emitted via RequestInfoEvent when a workflow pauses before an agent executes.
|
||||
The response is injected into the conversation as a user message to steer
|
||||
the agent's behavior.
|
||||
|
||||
This is the standard request type used by `.with_request_info()` on
|
||||
SequentialBuilder, ConcurrentBuilder, GroupChatBuilder, and HandoffBuilder.
|
||||
class AgentRequestInfoResponse:
|
||||
"""Response containing additional information requested from users for agents.
|
||||
|
||||
Attributes:
|
||||
target_agent_id: ID of the agent that is about to run
|
||||
conversation: Current conversation history the agent will receive
|
||||
instruction: Optional instruction from the orchestrator (e.g., manager in GroupChat)
|
||||
metadata: Builder-specific context (stores internal state for resume)
|
||||
messages: list[ChatMessage]: Additional messages provided by users. If empty,
|
||||
the agent response is approved as-is.
|
||||
"""
|
||||
|
||||
target_agent_id: str | None
|
||||
conversation: list[ChatMessage] = field(default_factory=lambda: [])
|
||||
instruction: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=lambda: {})
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
# Keep legacy name as alias for backward compatibility
|
||||
AgentResponseReviewRequest = AgentInputRequest
|
||||
|
||||
|
||||
DEFAULT_REQUEST_INFO_ID = "request_info_interceptor"
|
||||
|
||||
|
||||
class RequestInfoInterceptor(Executor):
|
||||
"""Internal executor that pauses workflow for human input before agent runs.
|
||||
|
||||
This executor is inserted into the workflow graph by builders when
|
||||
`.with_request_info()` is called. It intercepts AgentExecutorRequest messages
|
||||
BEFORE the agent runs and pauses the workflow via `ctx.request_info()` with
|
||||
an AgentInputRequest.
|
||||
|
||||
When a response is received, the response handler injects the input
|
||||
as a user message into the conversation and forwards the request to the agent.
|
||||
|
||||
The optional `agent_filter` parameter allows limiting which agents trigger the pause.
|
||||
If the target agent's ID is not in the filter set, the request is forwarded
|
||||
without pausing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executor_id: str | None = None,
|
||||
agent_filter: set[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the request info interceptor executor.
|
||||
@staticmethod
|
||||
def from_messages(messages: list[ChatMessage]) -> "AgentRequestInfoResponse":
|
||||
"""Create an AgentRequestInfoResponse from a list of ChatMessages.
|
||||
|
||||
Args:
|
||||
executor_id: ID for this executor. If None, generates a unique ID
|
||||
using the format "request_info_interceptor-<uuid4>".
|
||||
agent_filter: Optional set of agent/executor IDs to filter on.
|
||||
If provided, only requests to these agents trigger a pause.
|
||||
If None (default), all requests trigger a pause.
|
||||
messages: List of ChatMessage instances provided by users.
|
||||
|
||||
Returns:
|
||||
AgentRequestInfoResponse instance.
|
||||
"""
|
||||
if executor_id is None:
|
||||
executor_id = f"{DEFAULT_REQUEST_INFO_ID}-{uuid.uuid4().hex[:8]}"
|
||||
super().__init__(executor_id)
|
||||
self._agent_filter = agent_filter
|
||||
return AgentRequestInfoResponse(messages=messages)
|
||||
|
||||
def _should_pause_for_agent(self, agent_id: str | None) -> bool:
|
||||
"""Check if we should pause for the given agent ID."""
|
||||
if self._agent_filter is None:
|
||||
return True
|
||||
if agent_id is None:
|
||||
return False
|
||||
# Check both the full ID and any name portion after a prefix
|
||||
# e.g., "groupchat_agent:writer" should match filter "writer"
|
||||
if agent_id in self._agent_filter:
|
||||
return True
|
||||
# Extract name from prefixed IDs like "groupchat_agent:writer" or "request_info:writer"
|
||||
if ":" in agent_id:
|
||||
name_part = agent_id.split(":", 1)[1]
|
||||
if name_part in self._agent_filter:
|
||||
return True
|
||||
return False
|
||||
@staticmethod
|
||||
def from_strings(texts: list[str]) -> "AgentRequestInfoResponse":
|
||||
"""Create an AgentRequestInfoResponse from a list of string messages.
|
||||
|
||||
def _extract_agent_name_from_executor_id(self) -> str | None:
|
||||
"""Extract the agent name from this interceptor's executor ID.
|
||||
Args:
|
||||
texts: List of text messages provided by users.
|
||||
|
||||
The interceptor ID is typically "request_info:<agent_name>", so we
|
||||
extract the agent name to determine which agent we're intercepting for.
|
||||
Returns:
|
||||
AgentRequestInfoResponse instance.
|
||||
"""
|
||||
if ":" in self.id:
|
||||
return self.id.split(":", 1)[1]
|
||||
return None
|
||||
return AgentRequestInfoResponse(messages=[ChatMessage(role=Role.USER, text=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."""
|
||||
|
||||
@handler
|
||||
async def intercept_agent_request(
|
||||
self,
|
||||
request: AgentExecutorRequest,
|
||||
ctx: WorkflowContext[AgentExecutorRequest, Any],
|
||||
) -> None:
|
||||
"""Intercept request before agent runs and pause for human input.
|
||||
|
||||
Pauses the workflow and emits a RequestInfoEvent with the current
|
||||
conversation for steering. If an agent filter is configured and this
|
||||
agent is not in the filter, the request is forwarded without pausing.
|
||||
|
||||
Args:
|
||||
request: The request about to be sent to the agent
|
||||
ctx: Workflow context for requesting info
|
||||
"""
|
||||
# Determine the target agent from our executor ID
|
||||
target_agent = self._extract_agent_name_from_executor_id()
|
||||
|
||||
# Check if we should pause for this agent
|
||||
if not self._should_pause_for_agent(target_agent):
|
||||
logger.debug(f"Skipping request_info pause for agent {target_agent} (not in filter)")
|
||||
await ctx.send_message(request)
|
||||
return
|
||||
|
||||
conversation = list(request.messages or [])
|
||||
|
||||
input_request = AgentInputRequest(
|
||||
target_agent_id=target_agent,
|
||||
conversation=conversation,
|
||||
instruction=None, # Could be extended to include manager instruction
|
||||
metadata={"_original_request": request, "_input_type": "AgentExecutorRequest"},
|
||||
)
|
||||
await ctx.request_info(input_request, str)
|
||||
|
||||
@handler
|
||||
async def intercept_conversation(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
ctx: WorkflowContext[list[ChatMessage], Any],
|
||||
) -> None:
|
||||
"""Intercept conversation before agent runs (used by SequentialBuilder).
|
||||
|
||||
SequentialBuilder passes list[ChatMessage] directly to agents. This handler
|
||||
intercepts that flow and pauses for human input.
|
||||
|
||||
Args:
|
||||
messages: The conversation about to be sent to the agent
|
||||
ctx: Workflow context for requesting info
|
||||
"""
|
||||
# Determine the target agent from our executor ID
|
||||
target_agent = self._extract_agent_name_from_executor_id()
|
||||
|
||||
# Check if we should pause for this agent
|
||||
if not self._should_pause_for_agent(target_agent):
|
||||
logger.debug(f"Skipping request_info pause for agent {target_agent} (not in filter)")
|
||||
await ctx.send_message(messages)
|
||||
return
|
||||
|
||||
input_request = AgentInputRequest(
|
||||
target_agent_id=target_agent,
|
||||
conversation=list(messages),
|
||||
instruction=None,
|
||||
metadata={"_original_messages": messages, "_input_type": "list[ChatMessage]"},
|
||||
)
|
||||
await ctx.request_info(input_request, str)
|
||||
|
||||
@handler
|
||||
async def intercept_concurrent_requests(
|
||||
self,
|
||||
requests: list[AgentExecutorRequest],
|
||||
ctx: WorkflowContext[list[AgentExecutorRequest], Any],
|
||||
) -> None:
|
||||
"""Intercept requests before concurrent agents run.
|
||||
|
||||
This handler is used by ConcurrentBuilder to get human input before
|
||||
all parallel agents execute.
|
||||
|
||||
Args:
|
||||
requests: List of requests for all concurrent agents
|
||||
ctx: Workflow context for requesting info
|
||||
"""
|
||||
# Combine conversations for display
|
||||
combined_conversation: list[ChatMessage] = []
|
||||
if requests:
|
||||
combined_conversation = list(requests[0].messages or [])
|
||||
|
||||
input_request = AgentInputRequest(
|
||||
target_agent_id=None, # Multiple agents
|
||||
conversation=combined_conversation,
|
||||
instruction=None,
|
||||
metadata={"_original_requests": requests},
|
||||
)
|
||||
await ctx.request_info(input_request, str)
|
||||
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_input_response(
|
||||
async def handle_request_info_response(
|
||||
self,
|
||||
original_request: AgentInputRequest,
|
||||
# TODO(@moonbox3): Extend to support other content types
|
||||
response: str,
|
||||
ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], Any],
|
||||
original_request: AgentExecutorResponse,
|
||||
response: AgentRequestInfoResponse,
|
||||
ctx: WorkflowContext[AgentExecutorRequest, AgentExecutorResponse],
|
||||
) -> None:
|
||||
"""Handle the human input and forward the modified request to the agent.
|
||||
"""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)
|
||||
|
||||
Injects the response as a user message into the conversation
|
||||
and forwards the modified request to the agent.
|
||||
|
||||
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: AgentProtocol) -> None:
|
||||
"""Initialize the AgentApprovalExecutor.
|
||||
|
||||
Args:
|
||||
original_request: The AgentInputRequest that triggered the pause
|
||||
response: The human input text
|
||||
ctx: Workflow context for continuing the workflow
|
||||
|
||||
TODO: Consider having each orchestration implement its own response handler
|
||||
for more specialized behavior.
|
||||
agent: The agent protocol to use for generating responses.
|
||||
"""
|
||||
human_message = ChatMessage(role=Role.USER, text=response)
|
||||
super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True)
|
||||
self._description = agent.description
|
||||
|
||||
# Handle concurrent case (list of AgentExecutorRequest)
|
||||
original_requests: list[AgentExecutorRequest] | None = original_request.metadata.get("_original_requests")
|
||||
if original_requests is not None:
|
||||
updated_requests: list[AgentExecutorRequest] = []
|
||||
for orig_req in original_requests:
|
||||
messages = list(orig_req.messages or [])
|
||||
messages.append(human_message)
|
||||
updated_requests.append(
|
||||
AgentExecutorRequest(
|
||||
messages=messages,
|
||||
should_respond=orig_req.should_respond,
|
||||
)
|
||||
)
|
||||
def _build_workflow(self, agent: AgentProtocol) -> Workflow:
|
||||
"""Build the internal workflow for the AgentApprovalExecutor."""
|
||||
agent_executor = AgentExecutor(agent)
|
||||
request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor")
|
||||
|
||||
logger.debug(
|
||||
f"Human input received for concurrent workflow, "
|
||||
f"continuing with {len(updated_requests)} updated requests"
|
||||
)
|
||||
await ctx.send_message(updated_requests) # type: ignore[arg-type]
|
||||
return
|
||||
return (
|
||||
WorkflowBuilder()
|
||||
# Create a loop between agent executor and request info executor
|
||||
.add_edge(agent_executor, request_info_executor)
|
||||
.add_edge(request_info_executor, agent_executor)
|
||||
.set_start_executor(agent_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Handle list[ChatMessage] case (SequentialBuilder)
|
||||
original_messages: list[ChatMessage] | None = original_request.metadata.get("_original_messages")
|
||||
if original_messages is not None:
|
||||
messages = list(original_messages)
|
||||
messages.append(human_message)
|
||||
|
||||
logger.debug(
|
||||
f"Human input received for agent {original_request.target_agent_id}, "
|
||||
f"forwarding conversation with steering context"
|
||||
)
|
||||
await ctx.send_message(messages)
|
||||
return
|
||||
|
||||
# Handle AgentExecutorRequest case (GroupChatBuilder)
|
||||
orig_request: AgentExecutorRequest | None = original_request.metadata.get("_original_request")
|
||||
if orig_request is not None:
|
||||
messages = list(orig_request.messages or [])
|
||||
messages.append(human_message)
|
||||
|
||||
updated_request = AgentExecutorRequest(
|
||||
messages=messages,
|
||||
should_respond=orig_request.should_respond,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Human input received for agent {original_request.target_agent_id}, "
|
||||
f"forwarding request with steering context"
|
||||
)
|
||||
await ctx.send_message(updated_request)
|
||||
return
|
||||
|
||||
logger.error("Input response handler missing original request/messages in metadata")
|
||||
raise RuntimeError("Missing original request or messages in AgentInputRequest metadata")
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Get a description of the underlying agent."""
|
||||
return self._description
|
||||
|
||||
@@ -47,6 +47,7 @@ class OrchestrationState:
|
||||
|
||||
conversation: list[ChatMessage] = 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: ChatMessage | None = None
|
||||
|
||||
|
||||
@@ -7,13 +7,9 @@ No inheritance required - just import and call.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .._types import ChatMessage, Role
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._group_chat import _GroupChatRequestMessage # type: ignore[reportPrivateUsage]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -99,107 +95,3 @@ def create_completion_message(
|
||||
text=message_text,
|
||||
author_name=author_name,
|
||||
)
|
||||
|
||||
|
||||
def prepare_participant_request(
|
||||
*,
|
||||
participant_name: str,
|
||||
conversation: list[ChatMessage],
|
||||
instruction: str | None = None,
|
||||
task: ChatMessage | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> "_GroupChatRequestMessage":
|
||||
"""Create a standardized participant request message.
|
||||
|
||||
Simple helper to avoid duplicating request construction.
|
||||
|
||||
Args:
|
||||
participant_name: Name of the target participant
|
||||
conversation: Conversation history to send
|
||||
instruction: Optional instruction from manager/orchestrator
|
||||
task: Optional task context
|
||||
metadata: Optional metadata dict
|
||||
|
||||
Returns:
|
||||
GroupChatRequestMessage ready to send
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from ._group_chat import _GroupChatRequestMessage # type: ignore[reportPrivateUsage]
|
||||
|
||||
return _GroupChatRequestMessage(
|
||||
agent_name=participant_name,
|
||||
conversation=list(conversation),
|
||||
instruction=instruction or "",
|
||||
task=task,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
class ParticipantRegistry:
|
||||
"""Simple registry for tracking participant executor IDs and routing info.
|
||||
|
||||
Provides a clean interface for the common pattern of mapping participant names
|
||||
to executor IDs and tracking which are agents vs custom executors.
|
||||
|
||||
Tracks both entry IDs (where to send requests) and exit IDs (where responses
|
||||
come from) to support pipeline configurations where these differ.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._participant_entry_ids: dict[str, str] = {}
|
||||
self._agent_executor_ids: dict[str, str] = {}
|
||||
self._executor_id_to_participant: dict[str, str] = {}
|
||||
self._non_agent_participants: set[str] = set()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
entry_id: str,
|
||||
is_agent: bool,
|
||||
exit_id: str | None = None,
|
||||
) -> None:
|
||||
"""Register a participant's routing information.
|
||||
|
||||
Args:
|
||||
name: Participant name
|
||||
entry_id: Executor ID for this participant's entry point (where to send)
|
||||
is_agent: Whether this is an AgentExecutor (True) or custom Executor (False)
|
||||
exit_id: Executor ID for this participant's exit point (where responses come from).
|
||||
If None, defaults to entry_id (single-executor pipeline).
|
||||
"""
|
||||
self._participant_entry_ids[name] = entry_id
|
||||
actual_exit_id = exit_id if exit_id is not None else entry_id
|
||||
|
||||
if is_agent:
|
||||
self._agent_executor_ids[name] = entry_id
|
||||
# Map both entry and exit IDs to participant name for response routing
|
||||
self._executor_id_to_participant[entry_id] = name
|
||||
if actual_exit_id != entry_id:
|
||||
self._executor_id_to_participant[actual_exit_id] = name
|
||||
else:
|
||||
self._non_agent_participants.add(name)
|
||||
|
||||
def get_entry_id(self, name: str) -> str | None:
|
||||
"""Get the entry executor ID for a participant name."""
|
||||
return self._participant_entry_ids.get(name)
|
||||
|
||||
def get_participant_name(self, executor_id: str) -> str | None:
|
||||
"""Get the participant name for an executor ID (agents only)."""
|
||||
return self._executor_id_to_participant.get(executor_id)
|
||||
|
||||
def is_agent(self, name: str) -> bool:
|
||||
"""Check if a participant is an agent (vs custom executor)."""
|
||||
return name in self._agent_executor_ids
|
||||
|
||||
def is_registered(self, name: str) -> bool:
|
||||
"""Check if a participant is registered."""
|
||||
return name in self._participant_entry_ids
|
||||
|
||||
def is_participant_registered(self, name: str) -> bool:
|
||||
"""Check if a participant is registered (alias for is_registered for compatibility)."""
|
||||
return self.is_registered(name)
|
||||
|
||||
def all_participants(self) -> set[str]:
|
||||
"""Get all registered participant names."""
|
||||
return set(self._participant_entry_ids.keys())
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared participant helpers for orchestration builders."""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .._agents import AgentProtocol
|
||||
from ._agent_executor import AgentExecutor
|
||||
from ._executor import Executor
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupChatParticipantSpec:
|
||||
"""Metadata describing a single participant in group chat orchestrations.
|
||||
|
||||
Used by multiple orchestration patterns (GroupChat, Handoff, Magentic) to describe
|
||||
participants with consistent structure across different workflow types.
|
||||
|
||||
Attributes:
|
||||
name: Unique identifier for the participant used by managers for selection
|
||||
participant: AgentProtocol or Executor instance representing the participant
|
||||
description: Human-readable description provided to managers for selection context
|
||||
"""
|
||||
|
||||
name: str
|
||||
participant: AgentProtocol | Executor
|
||||
description: str
|
||||
|
||||
|
||||
_SANITIZE_PATTERN = re.compile(r"[^0-9a-zA-Z]+")
|
||||
|
||||
|
||||
def sanitize_identifier(value: str, *, default: str = "agent") -> str:
|
||||
"""Return a deterministic, lowercase identifier derived from `value`."""
|
||||
cleaned = _SANITIZE_PATTERN.sub("_", value).strip("_")
|
||||
if not cleaned:
|
||||
cleaned = default
|
||||
if cleaned[0].isdigit():
|
||||
cleaned = f"{default}_{cleaned}"
|
||||
return cleaned.lower()
|
||||
|
||||
|
||||
def wrap_participant(participant: AgentProtocol | Executor, *, executor_id: str | None = None) -> Executor:
|
||||
"""Represent `participant` as an `Executor`."""
|
||||
if isinstance(participant, Executor):
|
||||
return participant
|
||||
|
||||
if not isinstance(participant, AgentProtocol):
|
||||
raise TypeError(
|
||||
f"Participants must implement AgentProtocol or be Executor instances. Got {type(participant).__name__}."
|
||||
)
|
||||
|
||||
executor_id = executor_id or participant.name or participant.id
|
||||
return AgentExecutor(participant, id=executor_id)
|
||||
|
||||
|
||||
def participant_description(participant: AgentProtocol | Executor, fallback: str) -> str:
|
||||
"""Produce a human-readable description for manager context."""
|
||||
if isinstance(participant, Executor):
|
||||
description = getattr(participant, "description", None)
|
||||
if isinstance(description, str) and description.strip():
|
||||
return description.strip()
|
||||
return fallback
|
||||
description = getattr(participant, "description", None)
|
||||
if isinstance(description, str) and description.strip():
|
||||
return description.strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def build_alias_map(participant: AgentProtocol | Executor, executor: Executor) -> dict[str, str]:
|
||||
"""Collect canonical and sanitised aliases that should resolve to `executor`."""
|
||||
aliases: dict[str, str] = {}
|
||||
|
||||
def _register(values: Iterable[str | None]) -> None:
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
key = str(value)
|
||||
if key not in aliases:
|
||||
aliases[key] = executor.id
|
||||
sanitized = sanitize_identifier(key)
|
||||
if sanitized not in aliases:
|
||||
aliases[sanitized] = executor.id
|
||||
|
||||
_register([executor.id])
|
||||
|
||||
if isinstance(participant, AgentProtocol):
|
||||
name = getattr(participant, "name", None)
|
||||
agent_id = getattr(participant, "id", None)
|
||||
_register([name, agent_id])
|
||||
else:
|
||||
participant_id = getattr(participant, "id", None)
|
||||
_register([participant_id])
|
||||
|
||||
return aliases
|
||||
|
||||
|
||||
def merge_alias_maps(maps: Iterable[Mapping[str, str]]) -> dict[str, str]:
|
||||
"""Merge alias mappings, preserving the first occurrence of each alias."""
|
||||
merged: dict[str, str] = {}
|
||||
for mapping in maps:
|
||||
for key, value in mapping.items():
|
||||
merged.setdefault(key, value)
|
||||
return merged
|
||||
|
||||
|
||||
def prepare_participant_metadata(
|
||||
participants: Mapping[str, AgentProtocol | Executor],
|
||||
*,
|
||||
executor_id_factory: Callable[[str, AgentProtocol | Executor], str | None] | None = None,
|
||||
description_factory: Callable[[str, AgentProtocol | Executor], str] | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return metadata dicts for participants keyed by participant name."""
|
||||
executors: dict[str, Executor] = {}
|
||||
descriptions: dict[str, str] = {}
|
||||
alias_maps: list[Mapping[str, str]] = []
|
||||
|
||||
for name, participant in participants.items():
|
||||
desired_id = executor_id_factory(name, participant) if executor_id_factory else None
|
||||
executor = wrap_participant(participant, executor_id=desired_id)
|
||||
fallback_description = description_factory(name, participant) if description_factory else executor.id
|
||||
descriptions[name] = participant_description(participant, fallback_description)
|
||||
executors[name] = executor
|
||||
alias_maps.append(build_alias_map(participant, executor))
|
||||
|
||||
aliases = merge_alias_maps(alias_maps)
|
||||
return {
|
||||
"executors": executors,
|
||||
"descriptions": descriptions,
|
||||
"aliases": aliases,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_val
|
||||
from ._const import INTERNAL_SOURCE_ID
|
||||
from ._events import RequestInfoEvent, WorkflowEvent
|
||||
from ._shared_state import SharedState
|
||||
from ._typing_utils import is_instance_of
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,7 +45,7 @@ class Message:
|
||||
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
|
||||
|
||||
# For response messages, the original request data
|
||||
original_request: Any = None
|
||||
original_request_info_event: RequestInfoEvent | None = None
|
||||
|
||||
# Backward compatibility properties
|
||||
@property
|
||||
@@ -66,7 +67,7 @@ class Message:
|
||||
"type": self.type.value,
|
||||
"trace_contexts": self.trace_contexts,
|
||||
"source_span_ids": self.source_span_ids,
|
||||
"original_request": self.original_request,
|
||||
"original_request_info_event": encode_checkpoint_value(self.original_request_info_event),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -86,7 +87,7 @@ class Message:
|
||||
type=MessageType(data.get("type", "standard")),
|
||||
trace_contexts=data.get("trace_contexts"),
|
||||
source_span_ids=data.get("source_span_ids"),
|
||||
original_request=data.get("original_request"),
|
||||
original_request_info_event=decode_checkpoint_value(data.get("original_request_info_event")),
|
||||
)
|
||||
|
||||
|
||||
@@ -493,7 +494,7 @@ class InProcRunnerContext:
|
||||
raise ValueError(f"No pending request found for request_id: {request_id}")
|
||||
|
||||
# Validate response type if specified
|
||||
if event.response_type and not isinstance(response, event.response_type):
|
||||
if event.response_type and not is_instance_of(response, event.response_type):
|
||||
raise TypeError(
|
||||
f"Response type mismatch for request_id {request_id}: "
|
||||
f"expected {event.response_type.__name__}, got {type(response).__name__}"
|
||||
@@ -505,7 +506,7 @@ class InProcRunnerContext:
|
||||
source_id=INTERNAL_SOURCE_ID(event.source_executor_id),
|
||||
target_id=event.source_executor_id,
|
||||
type=MessageType.RESPONSE,
|
||||
original_request=event.data,
|
||||
original_request_info_event=event,
|
||||
)
|
||||
|
||||
await self.send_message(response_msg)
|
||||
|
||||
@@ -47,13 +47,14 @@ from ._agent_executor import (
|
||||
AgentExecutor,
|
||||
AgentExecutorResponse,
|
||||
)
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._executor import (
|
||||
Executor,
|
||||
handler,
|
||||
)
|
||||
from ._message_utils import normalize_messages_input
|
||||
from ._orchestration_request_info import RequestInfoInterceptor
|
||||
from ._orchestration_request_info import AgentApprovalExecutor
|
||||
from ._workflow import Workflow
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
@@ -77,24 +78,33 @@ class _InputToConversation(Executor):
|
||||
await ctx.send_message(normalize_messages_input(messages))
|
||||
|
||||
|
||||
class _ResponseToConversation(Executor):
|
||||
"""Converts AgentExecutorResponse to list[ChatMessage] conversation for chaining."""
|
||||
|
||||
@handler
|
||||
async def convert(self, response: AgentExecutorResponse, ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
# Always use full_conversation; AgentExecutor guarantees it is populated.
|
||||
if response.full_conversation is None: # Defensive: indicates a contract violation
|
||||
raise RuntimeError("AgentExecutorResponse.full_conversation missing. AgentExecutor must populate it.")
|
||||
await ctx.send_message(list(response.full_conversation))
|
||||
|
||||
|
||||
class _EndWithConversation(Executor):
|
||||
"""Terminates the workflow by emitting the final conversation context."""
|
||||
|
||||
@handler
|
||||
async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, list[ChatMessage]]) -> None:
|
||||
async def end_with_messages(
|
||||
self,
|
||||
conversation: list[ChatMessage],
|
||||
ctx: WorkflowContext[Any, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Handler for ending with a list of ChatMessage.
|
||||
|
||||
This is used when the last participant is a custom executor.
|
||||
"""
|
||||
await ctx.yield_output(list(conversation))
|
||||
|
||||
@handler
|
||||
async def end_with_agent_executor_response(
|
||||
self,
|
||||
response: AgentExecutorResponse,
|
||||
ctx: WorkflowContext[Any, list[ChatMessage] | None],
|
||||
) -> None:
|
||||
"""Handle case where last participant is an agent.
|
||||
|
||||
The agent is wrapped by AgentExecutor and emits AgentExecutorResponse.
|
||||
"""
|
||||
await ctx.yield_output(response.full_conversation)
|
||||
|
||||
|
||||
class SequentialBuilder:
|
||||
r"""High-level builder for sequential agent/executor workflows with shared context.
|
||||
@@ -206,44 +216,65 @@ class SequentialBuilder:
|
||||
def with_request_info(
|
||||
self,
|
||||
*,
|
||||
agents: Sequence[str | AgentProtocol | Executor] | None = None,
|
||||
agents: Sequence[str | AgentProtocol] | None = None,
|
||||
) -> "SequentialBuilder":
|
||||
"""Enable request info before agents run in the workflow.
|
||||
"""Enable request info after agent participant responses.
|
||||
|
||||
When enabled, the workflow pauses before each agent runs, emitting
|
||||
a RequestInfoEvent that allows the caller to review the conversation and
|
||||
optionally inject guidance before the agent responds. The caller provides
|
||||
input via the standard response_handler/request_info pattern.
|
||||
This enables human-in-the-loop (HIL) scenarios for the sequential orchestration.
|
||||
When enabled, the workflow pauses after each agent participant runs, emitting
|
||||
a RequestInfoEvent 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 filter - only pause before these specific agents/executors.
|
||||
Accepts agent names (str), agent instances, or executor instances.
|
||||
If None (default), pauses before every agent.
|
||||
agents: Optional list of agents names or agent factories to enable request info for.
|
||||
If None, enables HIL for all agent participants.
|
||||
|
||||
Returns:
|
||||
self: The builder instance for fluent chaining.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Pause before all agents
|
||||
workflow = SequentialBuilder().participants([a1, a2]).with_request_info().build()
|
||||
|
||||
# Pause only before specific agents
|
||||
workflow = (
|
||||
SequentialBuilder()
|
||||
.participants([drafter, reviewer, finalizer])
|
||||
.with_request_info(agents=[reviewer]) # Only pause before reviewer
|
||||
.build()
|
||||
)
|
||||
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."""
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
# Resolve the participant factories now. This doesn't break the factory pattern
|
||||
# since the Sequential builder still creates new instances per workflow build.
|
||||
for factory in self._participant_factories:
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
participants = self._participants
|
||||
|
||||
executors: list[Executor] = []
|
||||
for p in participants:
|
||||
if isinstance(p, Executor):
|
||||
executors.append(p)
|
||||
elif isinstance(p, AgentProtocol):
|
||||
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 AgentProtocol or Executor instances. Got {type(p).__name__}.")
|
||||
|
||||
return executors
|
||||
|
||||
def build(self) -> Workflow:
|
||||
"""Build and validate the sequential workflow.
|
||||
|
||||
@@ -272,48 +303,17 @@ class SequentialBuilder:
|
||||
input_conv = _InputToConversation(id="input-conversation")
|
||||
end = _EndWithConversation(id="end")
|
||||
|
||||
# Resolve participants and participant factories to executors
|
||||
participants: list[Executor] = self._resolve_participants()
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.set_start_executor(input_conv)
|
||||
|
||||
# Start of the chain is the input normalizer
|
||||
prior: Executor | AgentProtocol = input_conv
|
||||
|
||||
participants: list[Executor | AgentProtocol] = []
|
||||
if self._participant_factories:
|
||||
# Resolve the participant factories now. This doesn't break the factory pattern
|
||||
# since the Sequential builder still creates new instances per workflow build.
|
||||
for factory in self._participant_factories:
|
||||
p = factory()
|
||||
participants.append(p)
|
||||
else:
|
||||
participants = self._participants
|
||||
|
||||
for p in participants:
|
||||
if isinstance(p, (AgentProtocol, AgentExecutor)):
|
||||
label = p.id if isinstance(p, AgentExecutor) else p.name
|
||||
|
||||
if self._request_info_enabled:
|
||||
# Insert request info interceptor BEFORE the agent
|
||||
interceptor = RequestInfoInterceptor(
|
||||
executor_id=f"request_info:{label}",
|
||||
agent_filter=self._request_info_filter,
|
||||
)
|
||||
builder.add_edge(prior, interceptor)
|
||||
builder.add_edge(interceptor, p)
|
||||
else:
|
||||
builder.add_edge(prior, p)
|
||||
|
||||
resp_to_conv = _ResponseToConversation(id=f"to-conversation:{label}")
|
||||
builder.add_edge(p, resp_to_conv)
|
||||
prior = resp_to_conv
|
||||
elif isinstance(p, Executor):
|
||||
# Custom executor operates on list[ChatMessage]
|
||||
# If the executor doesn't handle list[ChatMessage] correctly, validation will fail
|
||||
builder.add_edge(prior, p)
|
||||
prior = p
|
||||
else:
|
||||
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
|
||||
|
||||
builder.add_edge(prior, p)
|
||||
prior = p
|
||||
# Terminate with the final conversation
|
||||
builder.add_edge(prior, end)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import functools
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from typing import Any
|
||||
@@ -34,12 +33,7 @@ from ._model_utils import DictConvertible
|
||||
from ._runner import Runner
|
||||
from ._runner_context import RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
pass # pragma: no cover
|
||||
else:
|
||||
pass # pragma: no cover
|
||||
|
||||
from ._typing_utils import is_instance_of
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -734,7 +728,7 @@ class Workflow(DictConvertible):
|
||||
if request_id not in pending_requests:
|
||||
raise ValueError(f"Response provided for unknown request ID: {request_id}")
|
||||
pending_request = pending_requests[request_id]
|
||||
if not isinstance(response, pending_request.response_type):
|
||||
if not is_instance_of(response, pending_request.response_type):
|
||||
raise ValueError(
|
||||
f"Response type mismatch for request ID {request_id}: "
|
||||
f"expected {pending_request.response_type}, got {type(response)}"
|
||||
|
||||
@@ -269,6 +269,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
request_id: str | None = None,
|
||||
):
|
||||
"""Initialize the executor context with the given workflow context.
|
||||
|
||||
@@ -281,6 +282,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
|
||||
request_id: Optional request ID if this context is for a `handle_response` handler.
|
||||
"""
|
||||
self._executor = executor
|
||||
self._executor_id = executor.id
|
||||
@@ -298,9 +300,21 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
self._trace_contexts = trace_contexts or []
|
||||
self._source_span_ids = source_span_ids or []
|
||||
|
||||
# request info related
|
||||
self._request_id: str | None = request_id
|
||||
|
||||
if not self._source_executor_ids:
|
||||
raise ValueError("source_executor_ids cannot be empty. At least one source executor ID is required.")
|
||||
|
||||
@property
|
||||
def request_id(self) -> str | None:
|
||||
"""Get the request ID if this context is for a `handle_response` handler.
|
||||
|
||||
Returns:
|
||||
The request ID string or None if not applicable.
|
||||
"""
|
||||
return self._request_id
|
||||
|
||||
async def send_message(self, message: T_Out, target_id: str | None = None) -> None:
|
||||
"""Send a message to the workflow context.
|
||||
|
||||
@@ -361,7 +375,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
return
|
||||
await self._runner_context.add_event(event)
|
||||
|
||||
async def request_info(self, request_data: object, response_type: type) -> None:
|
||||
async def request_info(self, request_data: object, response_type: type, *, request_id: str | None = None) -> None:
|
||||
"""Request information from outside of the workflow.
|
||||
|
||||
Calling this method will cause the workflow to emit a RequestInfoEvent, carrying the
|
||||
@@ -374,6 +388,8 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
Args:
|
||||
request_data: The data associated with the information request.
|
||||
response_type: The expected type of the response, used for validation.
|
||||
request_id: Optional unique identifier for the request. If not provided,
|
||||
a new UUID will be generated. This allows executors to track requests and responses.
|
||||
"""
|
||||
request_type: type = type(request_data)
|
||||
if not self._executor.is_request_supported(request_type, response_type):
|
||||
@@ -385,7 +401,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]):
|
||||
)
|
||||
|
||||
request_info_event = RequestInfoEvent(
|
||||
request_id=str(uuid.uuid4()),
|
||||
request_id=request_id or str(uuid.uuid4()),
|
||||
source_executor_id=self._executor_id,
|
||||
request_data=request_data,
|
||||
response_type=response_type,
|
||||
|
||||
@@ -18,10 +18,8 @@ from ._events import (
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
)
|
||||
from ._executor import (
|
||||
Executor,
|
||||
handler,
|
||||
)
|
||||
from ._executor import Executor, handler
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner_context import Message
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._workflow import WorkflowRunResult
|
||||
@@ -265,7 +263,14 @@ class WorkflowExecutor(Executor):
|
||||
- Concurrent executions are fully isolated and do not interfere with each other
|
||||
"""
|
||||
|
||||
def __init__(self, workflow: "Workflow", id: str, allow_direct_output: bool = False, **kwargs: Any):
|
||||
def __init__(
|
||||
self,
|
||||
workflow: "Workflow",
|
||||
id: str,
|
||||
allow_direct_output: bool = False,
|
||||
propagate_request: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
@@ -277,6 +282,11 @@ class WorkflowExecutor(Executor):
|
||||
When this is set to true, the outputs are yielded
|
||||
directly from the WorkflowExecutor to the parent
|
||||
workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the
|
||||
parent workflow. If set to true, requests from the sub-workflow
|
||||
will be propagated as the original RequestInfoEvent to the parent
|
||||
workflow. Otherwise, they will be wrapped in a SubWorkflowRequestMessage,
|
||||
which should be handled by an executor in the parent workflow.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
@@ -289,6 +299,7 @@ class WorkflowExecutor(Executor):
|
||||
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
|
||||
# Map request_id to execution_id for response routing
|
||||
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
|
||||
self._propagate_request = propagate_request
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any]]:
|
||||
@@ -336,8 +347,15 @@ class WorkflowExecutor(Executor):
|
||||
This prevents the WorkflowExecutor from accepting messages that should go to other
|
||||
executors because the handler `process_workflow` has no type restrictions.
|
||||
"""
|
||||
# Always handle SubWorkflowResponseMessage
|
||||
if isinstance(message.data, SubWorkflowResponseMessage):
|
||||
# Always handle SubWorkflowResponseMessage
|
||||
return True
|
||||
|
||||
if (
|
||||
message.original_request_info_event is not None
|
||||
and message.original_request_info_event.request_id in self._request_to_execution
|
||||
):
|
||||
# Handle propagated responses for known requests
|
||||
return True
|
||||
|
||||
# For other messages, only handle if the wrapped workflow can accept them as input
|
||||
@@ -388,7 +406,11 @@ class WorkflowExecutor(Executor):
|
||||
del self._execution_contexts[execution_id]
|
||||
|
||||
@handler
|
||||
async def handle_response(self, response: SubWorkflowResponseMessage, ctx: WorkflowContext[Any]) -> None:
|
||||
async def handle_message_wrapped_request_response(
|
||||
self,
|
||||
response: SubWorkflowResponseMessage,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Handle response from parent for a forwarded request.
|
||||
|
||||
This handler accumulates responses and only resumes the sub-workflow
|
||||
@@ -398,55 +420,34 @@ class WorkflowExecutor(Executor):
|
||||
response: The response to a previous request.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Find the execution context for this request
|
||||
original_request = response.source_event
|
||||
execution_id = self._request_to_execution.get(original_request.request_id)
|
||||
if not execution_id or execution_id not in self._execution_contexts:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {original_request.request_id}. "
|
||||
"This response will be ignored."
|
||||
)
|
||||
return
|
||||
await self._handle_response(
|
||||
request_id=response.source_event.request_id,
|
||||
response=response.data,
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
execution_context = self._execution_contexts[execution_id]
|
||||
@response_handler
|
||||
async def handle_propagated_request_response(
|
||||
self,
|
||||
original_request: Any,
|
||||
response: object,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Handle response for a request that was propagated to the parent workflow.
|
||||
|
||||
# Check if we have this pending request in the execution context
|
||||
if original_request.request_id not in execution_context.pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{original_request.request_id} in execution {execution_id}, ignoring"
|
||||
)
|
||||
return
|
||||
Args:
|
||||
original_request: The original RequestInfoEvent.
|
||||
response: The response data.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
if ctx.request_id is None:
|
||||
raise RuntimeError("WorkflowExecutor received a propagated response without a request ID in the context.")
|
||||
|
||||
# Remove the request from pending list and request mapping
|
||||
execution_context.pending_requests.pop(original_request.request_id, None)
|
||||
self._request_to_execution.pop(original_request.request_id, None)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[original_request.request_id] = response.data
|
||||
|
||||
# Check if we have all expected responses for this execution
|
||||
if len(execution_context.collected_responses) < execution_context.expected_response_count:
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
|
||||
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
|
||||
)
|
||||
return # Wait for more responses
|
||||
|
||||
# Send all collected responses to the sub-workflow
|
||||
responses_to_send = dict(execution_context.collected_responses)
|
||||
execution_context.collected_responses.clear() # Clear for next batch
|
||||
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.send_responses(responses_to_send)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if not execution_context.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
await self._handle_response(
|
||||
request_id=ctx.request_id,
|
||||
response=response,
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
@override
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
@@ -552,13 +553,15 @@ class WorkflowExecutor(Executor):
|
||||
execution_context.pending_requests[event.request_id] = event
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[event.request_id] = execution_context.execution_id
|
||||
# TODO(@taochen): There should be two ways a sub-workflow can make a request:
|
||||
# 1. In a workflow where the parent workflow has an executor that may intercept the
|
||||
# request and handle it directly, a message should be sent.
|
||||
# 2. In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
# a @response_handler would be required in the WorkflowExecutor to handle the response.
|
||||
await ctx.send_message(SubWorkflowRequestMessage(source_event=event, executor_id=self.id))
|
||||
if self._propagate_request:
|
||||
# In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
# a @response_handler would be required in the WorkflowExecutor to handle the response.
|
||||
await ctx.request_info(event.data, event.response_type, request_id=event.request_id)
|
||||
else:
|
||||
# In a workflow where the parent workflow has an executor that may intercept the
|
||||
# request and handle it directly, a message should be sent.
|
||||
await ctx.send_message(SubWorkflowRequestMessage(source_event=event, executor_id=self.id))
|
||||
|
||||
# Update expected response count for this execution
|
||||
execution_context.expected_response_count = len(request_info_events)
|
||||
@@ -602,3 +605,56 @@ class WorkflowExecutor(Executor):
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected workflow run state: {workflow_run_state}")
|
||||
|
||||
async def _handle_response(
|
||||
self,
|
||||
request_id: str,
|
||||
response: Any,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
execution_id = self._request_to_execution.get(request_id)
|
||||
if not execution_id or execution_id not in self._execution_contexts:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {request_id}. "
|
||||
"This response will be ignored."
|
||||
)
|
||||
return
|
||||
|
||||
execution_context = self._execution_contexts[execution_id]
|
||||
|
||||
# Check if we have this pending request in the execution context
|
||||
if request_id not in execution_context.pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{request_id} in execution {execution_id}, ignoring"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove the request from pending list and request mapping
|
||||
execution_context.pending_requests.pop(request_id, None)
|
||||
self._request_to_execution.pop(request_id, None)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[request_id] = response
|
||||
# Check if we have all expected responses for this execution
|
||||
if len(execution_context.collected_responses) < execution_context.expected_response_count:
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
|
||||
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
|
||||
)
|
||||
return # Wait for more responses
|
||||
|
||||
# Send all collected responses to the sub-workflow
|
||||
responses_to_send = dict(execution_context.collected_responses)
|
||||
execution_context.collected_responses.clear() # Clear for next batch
|
||||
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.send_responses(responses_to_send)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if not execution_context.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
|
||||
@@ -17,14 +17,6 @@ def test_agent_run_event_data_type() -> None:
|
||||
assert data.text == "Hello"
|
||||
|
||||
|
||||
def test_agent_run_event_data_none() -> None:
|
||||
"""Verify AgentRunEvent.data can be None."""
|
||||
event = AgentRunEvent(executor_id="test")
|
||||
|
||||
data: AgentRunResponse | None = event.data
|
||||
assert data is None
|
||||
|
||||
|
||||
def test_agent_run_update_event_data_type() -> None:
|
||||
"""Verify AgentRunUpdateEvent.data is typed as AgentRunResponseUpdate | None."""
|
||||
update = AgentRunResponseUpdate()
|
||||
@@ -33,11 +25,3 @@ def test_agent_run_update_event_data_type() -> None:
|
||||
# This assignment should pass type checking without a cast
|
||||
data: AgentRunResponseUpdate | None = event.data
|
||||
assert data is not None
|
||||
|
||||
|
||||
def test_agent_run_update_event_data_none() -> None:
|
||||
"""Verify AgentRunUpdateEvent.data can be None."""
|
||||
event = AgentRunUpdateEvent(executor_id="test")
|
||||
|
||||
data: AgentRunResponseUpdate | None = event.data
|
||||
assert data is None
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, AgentThread, ChatMessage
|
||||
from agent_framework._workflows._agent_utils import resolve_agent_id
|
||||
|
||||
|
||||
class MockAgent:
|
||||
"""Mock agent for testing agent utilities."""
|
||||
|
||||
def __init__(self, agent_id: str, name: str | None = None) -> None:
|
||||
self._id = agent_id
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Returns the display name of the agent."""
|
||||
...
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Returns the description of the agent."""
|
||||
...
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse: ...
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]: ...
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Creates a new conversation thread for the agent."""
|
||||
...
|
||||
|
||||
|
||||
def test_resolve_agent_id_with_name() -> None:
|
||||
"""Test that resolve_agent_id returns name when agent has a name."""
|
||||
agent = MockAgent(agent_id="agent-123", name="MyAgent")
|
||||
result = resolve_agent_id(agent)
|
||||
assert result == "MyAgent"
|
||||
|
||||
|
||||
def test_resolve_agent_id_without_name() -> None:
|
||||
"""Test that resolve_agent_id returns id when agent has no name."""
|
||||
agent = MockAgent(agent_id="agent-456", name=None)
|
||||
result = resolve_agent_id(agent)
|
||||
assert result == "agent-456"
|
||||
|
||||
|
||||
def test_resolve_agent_id_with_empty_name() -> None:
|
||||
"""Test that resolve_agent_id returns id when agent has empty string name."""
|
||||
agent = MockAgent(agent_id="agent-789", name="")
|
||||
result = resolve_agent_id(agent)
|
||||
assert result == "agent-789"
|
||||
|
||||
|
||||
def test_resolve_agent_id_prefers_name_over_id() -> None:
|
||||
"""Test that resolve_agent_id prefers name over id when both are set."""
|
||||
agent = MockAgent(agent_id="agent-abc", name="PreferredName")
|
||||
result = resolve_agent_id(agent)
|
||||
assert result == "PreferredName"
|
||||
assert result != "agent-abc"
|
||||
@@ -12,6 +12,7 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
executor,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,6 +267,247 @@ async def test_executor_events_with_complex_message_types():
|
||||
assert collector_invoked.data.results == ["HELLO", "HELLO", "HELLO"]
|
||||
|
||||
|
||||
def test_executor_output_types_property():
|
||||
"""Test that the output_types property correctly identifies message output types."""
|
||||
|
||||
# Test executor with no output types
|
||||
class NoOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
executor = NoOutputExecutor(id="no_output")
|
||||
assert executor.output_types == []
|
||||
|
||||
# Test executor with single output type
|
||||
class SingleOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
executor = SingleOutputExecutor(id="single_output")
|
||||
assert int in executor.output_types
|
||||
assert len(executor.output_types) == 1
|
||||
|
||||
# Test executor with union output types
|
||||
class UnionOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int | str]) -> None:
|
||||
pass
|
||||
|
||||
executor = UnionOutputExecutor(id="union_output")
|
||||
assert int in executor.output_types
|
||||
assert str in executor.output_types
|
||||
assert len(executor.output_types) == 2
|
||||
|
||||
# Test executor with multiple handlers having different output types
|
||||
class MultiHandlerExecutor(Executor):
|
||||
@handler
|
||||
async def handle_string(self, text: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
@handler
|
||||
async def handle_number(self, num: int, ctx: WorkflowContext[bool]) -> None:
|
||||
pass
|
||||
|
||||
executor = MultiHandlerExecutor(id="multi_handler")
|
||||
assert int in executor.output_types
|
||||
assert bool in executor.output_types
|
||||
assert len(executor.output_types) == 2
|
||||
|
||||
|
||||
def test_executor_workflow_output_types_property():
|
||||
"""Test that the workflow_output_types property correctly identifies workflow output types."""
|
||||
from typing_extensions import Never
|
||||
|
||||
# Test executor with no workflow output types
|
||||
class NoWorkflowOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
executor = NoWorkflowOutputExecutor(id="no_workflow_output")
|
||||
assert executor.workflow_output_types == []
|
||||
|
||||
# Test executor with workflow output type (second type parameter)
|
||||
class WorkflowOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
pass
|
||||
|
||||
executor = WorkflowOutputExecutor(id="workflow_output")
|
||||
assert str in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 1
|
||||
|
||||
# Test executor with union workflow output types
|
||||
class UnionWorkflowOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int, str | bool]) -> None:
|
||||
pass
|
||||
|
||||
executor = UnionWorkflowOutputExecutor(id="union_workflow_output")
|
||||
assert str in executor.workflow_output_types
|
||||
assert bool in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 2
|
||||
|
||||
# Test executor with multiple handlers having different workflow output types
|
||||
class MultiHandlerWorkflowExecutor(Executor):
|
||||
@handler
|
||||
async def handle_string(self, text: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
pass
|
||||
|
||||
@handler
|
||||
async def handle_number(self, num: int, ctx: WorkflowContext[bool, float]) -> None:
|
||||
pass
|
||||
|
||||
executor = MultiHandlerWorkflowExecutor(id="multi_workflow")
|
||||
assert str in executor.workflow_output_types
|
||||
assert float in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 2
|
||||
|
||||
# Test executor with Never for message output (only workflow output)
|
||||
class YieldOnlyExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
pass
|
||||
|
||||
executor = YieldOnlyExecutor(id="yield_only")
|
||||
assert str in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 1
|
||||
# Should have no message output types
|
||||
assert executor.output_types == []
|
||||
|
||||
|
||||
def test_executor_output_and_workflow_output_types_combined():
|
||||
"""Test executor with both message and workflow output types."""
|
||||
|
||||
class DualOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
pass
|
||||
|
||||
executor = DualOutputExecutor(id="dual")
|
||||
|
||||
# Should have int as message output type
|
||||
assert int in executor.output_types
|
||||
assert len(executor.output_types) == 1
|
||||
|
||||
# Should have str as workflow output type
|
||||
assert str in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 1
|
||||
|
||||
# They should be distinct
|
||||
assert int not in executor.workflow_output_types
|
||||
assert str not in executor.output_types
|
||||
|
||||
|
||||
def test_executor_output_types_includes_response_handlers():
|
||||
"""Test that output_types includes types from response handlers."""
|
||||
from agent_framework import response_handler
|
||||
|
||||
class RequestResponseExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_response(self, original_request: str, response: bool, ctx: WorkflowContext[float]) -> None:
|
||||
pass
|
||||
|
||||
executor = RequestResponseExecutor(id="request_response")
|
||||
|
||||
# Should include output types from both handler and response_handler
|
||||
assert int in executor.output_types
|
||||
assert float in executor.output_types
|
||||
assert len(executor.output_types) == 2
|
||||
|
||||
|
||||
def test_executor_workflow_output_types_includes_response_handlers():
|
||||
"""Test that workflow_output_types includes types from response handlers."""
|
||||
from agent_framework import response_handler
|
||||
|
||||
class RequestResponseWorkflowExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: str, response: bool, ctx: WorkflowContext[float, bool]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = RequestResponseWorkflowExecutor(id="request_response_workflow")
|
||||
|
||||
# Should include workflow output types from both handler and response_handler
|
||||
assert str in executor.workflow_output_types
|
||||
assert bool in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 2
|
||||
|
||||
# Verify message output types are separate
|
||||
assert int in executor.output_types
|
||||
assert float in executor.output_types
|
||||
assert len(executor.output_types) == 2
|
||||
|
||||
|
||||
def test_executor_multiple_response_handlers_output_types():
|
||||
"""Test that multiple response handlers contribute their output types."""
|
||||
|
||||
class MultiResponseHandlerExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_string_bool_response(
|
||||
self, original_request: str, response: bool, ctx: WorkflowContext[float]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_int_bool_response(
|
||||
self, original_request: int, response: bool, ctx: WorkflowContext[bool]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = MultiResponseHandlerExecutor(id="multi_response")
|
||||
|
||||
# Should include output types from all handlers and response handlers
|
||||
assert int in executor.output_types
|
||||
assert float in executor.output_types
|
||||
assert bool in executor.output_types
|
||||
assert len(executor.output_types) == 3
|
||||
|
||||
|
||||
def test_executor_response_handler_union_output_types():
|
||||
"""Test that response handlers with union output types contribute all types."""
|
||||
from agent_framework import response_handler
|
||||
|
||||
class UnionResponseHandlerExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self, original_request: str, response: bool, ctx: WorkflowContext[int | str | float, bool | int]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
executor = UnionResponseHandlerExecutor(id="union_response")
|
||||
|
||||
# Should include all output types from the union
|
||||
assert int in executor.output_types
|
||||
assert str in executor.output_types
|
||||
assert float in executor.output_types
|
||||
assert len(executor.output_types) == 3
|
||||
|
||||
# Should include all workflow output types from the union
|
||||
assert bool in executor.workflow_output_types
|
||||
assert int in executor.workflow_output_types
|
||||
assert len(executor.workflow_output_types) == 2
|
||||
|
||||
|
||||
async def test_executor_invoked_event_data_not_mutated_by_handler():
|
||||
"""Test that ExecutorInvokedEvent.data captures original input, not mutated input."""
|
||||
|
||||
|
||||
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
@@ -1,59 +1,51 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for request info support in high-level builders."""
|
||||
"""Unit tests for orchestration request info support."""
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentInputRequest,
|
||||
AgentProtocol,
|
||||
AgentResponseReviewRequest,
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
ChatMessage,
|
||||
RequestInfoInterceptor,
|
||||
Role,
|
||||
)
|
||||
from agent_framework._workflows._executor import Executor, handler
|
||||
from agent_framework._workflows._orchestration_request_info import resolve_request_info_filter
|
||||
from agent_framework._workflows._agent_executor import AgentExecutorRequest, AgentExecutorResponse
|
||||
from agent_framework._workflows._orchestration_request_info import (
|
||||
AgentApprovalExecutor,
|
||||
AgentRequestInfoExecutor,
|
||||
AgentRequestInfoResponse,
|
||||
resolve_request_info_filter,
|
||||
)
|
||||
from agent_framework._workflows._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
class DummyExecutor(Executor):
|
||||
"""Dummy executor with a handler for testing."""
|
||||
|
||||
@handler
|
||||
async def handle(self, data: str, ctx: WorkflowContext[Any, Any]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestResolveRequestInfoFilter:
|
||||
"""Tests for resolve_request_info_filter function."""
|
||||
|
||||
def test_returns_none_for_none_input(self):
|
||||
"""Test that None input returns None (no filtering)."""
|
||||
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 is None
|
||||
assert result == set()
|
||||
|
||||
def test_returns_none_for_empty_list(self):
|
||||
"""Test that empty list returns None."""
|
||||
def test_returns_empty_set_for_empty_list(self):
|
||||
"""Test that empty list returns empty set."""
|
||||
result = resolve_request_info_filter([])
|
||||
assert result is None
|
||||
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_executor_ids(self):
|
||||
"""Test resolving Executor instances by ID."""
|
||||
exec1 = DummyExecutor(id="executor1")
|
||||
exec2 = DummyExecutor(id="executor2")
|
||||
|
||||
result = resolve_request_info_filter([exec1, exec2])
|
||||
assert result == {"executor1", "executor2"}
|
||||
|
||||
def test_resolves_agent_names(self):
|
||||
"""Test resolving AgentProtocol-like objects by name attribute."""
|
||||
def test_resolves_agent_display_names(self):
|
||||
"""Test resolving AgentProtocol instances by name attribute."""
|
||||
agent1 = MagicMock(spec=AgentProtocol)
|
||||
agent1.name = "writer"
|
||||
agent2 = MagicMock(spec=AgentProtocol)
|
||||
@@ -63,106 +55,205 @@ class TestResolveRequestInfoFilter:
|
||||
assert result == {"writer", "reviewer"}
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""Test resolving a mix of strings, agents, and executors."""
|
||||
"""Test resolving a mix of strings and agents."""
|
||||
agent = MagicMock(spec=AgentProtocol)
|
||||
agent.name = "writer"
|
||||
executor = DummyExecutor(id="custom_exec")
|
||||
|
||||
result = resolve_request_info_filter(["manual_name", agent, executor])
|
||||
assert result == {"manual_name", "writer", "custom_exec"}
|
||||
result = resolve_request_info_filter(["manual_name", agent])
|
||||
assert result == {"manual_name", "writer"}
|
||||
|
||||
def test_skips_agent_without_name(self):
|
||||
"""Test that agents without names are skipped."""
|
||||
agent_with_name = MagicMock(spec=AgentProtocol)
|
||||
agent_with_name.name = "valid"
|
||||
agent_without_name = MagicMock(spec=AgentProtocol)
|
||||
agent_without_name.name = None
|
||||
|
||||
result = resolve_request_info_filter([agent_with_name, agent_without_name])
|
||||
assert result == {"valid"}
|
||||
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 TestAgentInputRequest:
|
||||
"""Tests for AgentInputRequest dataclass (formerly AgentResponseReviewRequest)."""
|
||||
class TestAgentRequestInfoResponse:
|
||||
"""Tests for AgentRequestInfoResponse dataclass."""
|
||||
|
||||
def test_create_request(self):
|
||||
"""Test creating an AgentInputRequest with all fields."""
|
||||
conversation = [ChatMessage(role=Role.USER, text="Hello")]
|
||||
request = AgentInputRequest(
|
||||
target_agent_id="test_agent",
|
||||
conversation=conversation,
|
||||
instruction="Review this",
|
||||
metadata={"key": "value"},
|
||||
def test_create_response_with_messages(self):
|
||||
"""Test creating an AgentRequestInfoResponse with messages."""
|
||||
messages = [ChatMessage(role=Role.USER, text="Additional info")]
|
||||
response = AgentRequestInfoResponse(messages=messages)
|
||||
|
||||
assert response.messages == messages
|
||||
|
||||
def test_from_messages_factory(self):
|
||||
"""Test creating response from ChatMessage list."""
|
||||
messages = [
|
||||
ChatMessage(role=Role.USER, text="Message 1"),
|
||||
ChatMessage(role=Role.USER, text="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 == Role.USER
|
||||
assert response.messages[0].text == "First message"
|
||||
assert response.messages[1].role == 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_run_response = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Agent response")])
|
||||
agent_response = AgentExecutorResponse(
|
||||
executor_id="test_agent",
|
||||
agent_run_response=agent_run_response,
|
||||
)
|
||||
|
||||
assert request.target_agent_id == "test_agent"
|
||||
assert request.conversation == conversation
|
||||
assert request.instruction == "Review this"
|
||||
assert request.metadata == {"key": "value"}
|
||||
ctx = MagicMock(spec=WorkflowContext)
|
||||
ctx.request_info = AsyncMock()
|
||||
|
||||
def test_create_request_defaults(self):
|
||||
"""Test creating an AgentInputRequest with default values."""
|
||||
request = AgentInputRequest(target_agent_id="test_agent")
|
||||
await executor.request_info(agent_response, ctx)
|
||||
|
||||
assert request.target_agent_id == "test_agent"
|
||||
assert request.conversation == []
|
||||
assert request.instruction is None
|
||||
assert request.metadata == {}
|
||||
ctx.request_info.assert_called_once_with(agent_response, AgentRequestInfoResponse)
|
||||
|
||||
def test_backward_compatibility_alias(self):
|
||||
"""Test that AgentResponseReviewRequest is an alias for AgentInputRequest."""
|
||||
assert AgentResponseReviewRequest is AgentInputRequest
|
||||
@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")
|
||||
|
||||
|
||||
class TestRequestInfoInterceptor:
|
||||
"""Tests for RequestInfoInterceptor executor."""
|
||||
|
||||
def test_interceptor_creation_generates_unique_id(self):
|
||||
"""Test creating a RequestInfoInterceptor generates unique IDs."""
|
||||
interceptor1 = RequestInfoInterceptor()
|
||||
interceptor2 = RequestInfoInterceptor()
|
||||
assert interceptor1.id.startswith("request_info_interceptor-")
|
||||
assert interceptor2.id.startswith("request_info_interceptor-")
|
||||
assert interceptor1.id != interceptor2.id
|
||||
|
||||
def test_interceptor_with_custom_id(self):
|
||||
"""Test creating a RequestInfoInterceptor with custom ID."""
|
||||
interceptor = RequestInfoInterceptor(executor_id="custom_review")
|
||||
assert interceptor.id == "custom_review"
|
||||
|
||||
def test_interceptor_with_agent_filter(self):
|
||||
"""Test creating a RequestInfoInterceptor with agent filter."""
|
||||
agent_filter = {"agent1", "agent2"}
|
||||
interceptor = RequestInfoInterceptor(
|
||||
executor_id="filtered_review",
|
||||
agent_filter=agent_filter,
|
||||
agent_run_response = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Original")])
|
||||
original_request = AgentExecutorResponse(
|
||||
executor_id="test_agent",
|
||||
agent_run_response=agent_run_response,
|
||||
)
|
||||
assert interceptor.id == "filtered_review"
|
||||
assert interceptor._agent_filter == agent_filter
|
||||
|
||||
def test_should_pause_for_agent_no_filter(self):
|
||||
"""Test that interceptor pauses for all agents when no filter is set."""
|
||||
interceptor = RequestInfoInterceptor()
|
||||
assert interceptor._should_pause_for_agent("any_agent") is True
|
||||
assert interceptor._should_pause_for_agent("another_agent") is True
|
||||
assert interceptor._should_pause_for_agent(None) is True
|
||||
response = AgentRequestInfoResponse.from_strings(["Additional input"])
|
||||
|
||||
def test_should_pause_for_agent_with_filter(self):
|
||||
"""Test that interceptor only pauses for agents in the filter."""
|
||||
agent_filter = {"writer", "reviewer"}
|
||||
interceptor = RequestInfoInterceptor(agent_filter=agent_filter)
|
||||
ctx = MagicMock(spec=WorkflowContext)
|
||||
ctx.send_message = AsyncMock()
|
||||
|
||||
assert interceptor._should_pause_for_agent("writer") is True
|
||||
assert interceptor._should_pause_for_agent("reviewer") is True
|
||||
assert interceptor._should_pause_for_agent("drafter") is False
|
||||
assert interceptor._should_pause_for_agent(None) is False
|
||||
await executor.handle_request_info_response(original_request, response, ctx)
|
||||
|
||||
def test_should_pause_for_agent_with_prefixed_id(self):
|
||||
"""Test that filter matches agent names in prefixed executor IDs."""
|
||||
agent_filter = {"writer"}
|
||||
interceptor = RequestInfoInterceptor(agent_filter=agent_filter)
|
||||
# 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"
|
||||
|
||||
# Should match the name portion after the colon
|
||||
assert interceptor._should_pause_for_agent("groupchat_agent:writer") is True
|
||||
assert interceptor._should_pause_for_agent("request_info:writer") is True
|
||||
assert interceptor._should_pause_for_agent("groupchat_agent:editor") is False
|
||||
@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_run_response = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Original")])
|
||||
original_request = AgentExecutorResponse(
|
||||
executor_id="test_agent",
|
||||
agent_run_response=agent_run_response,
|
||||
)
|
||||
|
||||
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 | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse:
|
||||
"""Dummy run method."""
|
||||
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Test response")])
|
||||
|
||||
def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
"""Dummy run_stream method."""
|
||||
|
||||
async def generator():
|
||||
yield AgentRunResponseUpdate(messages=[ChatMessage(role=Role.ASSISTANT, text="Test response stream")])
|
||||
|
||||
return generator()
|
||||
|
||||
def get_new_thread(self, **kwargs: Any) -> AgentThread:
|
||||
"""Creates a new conversation thread for the agent."""
|
||||
return AgentThread(**kwargs)
|
||||
|
||||
|
||||
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(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(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(agent)
|
||||
|
||||
assert executor._propagate_request is True # type: ignore
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentExecutorResponse,
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AgentThread,
|
||||
@@ -52,7 +53,8 @@ class _SummarizerExec(Executor):
|
||||
"""Custom executor that summarizes by appending a short assistant message."""
|
||||
|
||||
@handler
|
||||
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
async def summarize(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[list[ChatMessage]]) -> None:
|
||||
conversation = agent_response.full_conversation or []
|
||||
user_texts = [m.text for m in conversation if m.role == Role.USER]
|
||||
agents = [m.author_name or m.role for m in conversation if m.role == Role.ASSISTANT]
|
||||
summary = ChatMessage(role=Role.ASSISTANT, text=f"Summary of users:{len(user_texts)} agents:{len(agents)}")
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Annotated, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
@@ -11,7 +13,7 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
ConcurrentBuilder,
|
||||
GroupChatBuilder,
|
||||
GroupChatStateSnapshot,
|
||||
GroupChatState,
|
||||
HandoffBuilder,
|
||||
Role,
|
||||
SequentialBuilder,
|
||||
@@ -26,11 +28,6 @@ from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
|
||||
_received_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def _reset_received_kwargs() -> None:
|
||||
"""Reset the kwargs tracker before each test."""
|
||||
_received_kwargs.clear()
|
||||
|
||||
|
||||
@ai_function
|
||||
def tool_with_kwargs(
|
||||
action: Annotated[str, "The action to perform"],
|
||||
@@ -73,28 +70,6 @@ class _KwargsCapturingAgent(BaseAgent):
|
||||
yield AgentRunResponseUpdate(contents=[TextContent(text=f"{self.name} response")])
|
||||
|
||||
|
||||
class _EchoAgent(BaseAgent):
|
||||
"""Simple agent that echoes back for workflow completion."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentRunResponse:
|
||||
return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text=f"{self.name} reply")])
|
||||
|
||||
async def run_stream(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
*,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[AgentRunResponseUpdate]:
|
||||
yield AgentRunResponseUpdate(contents=[TextContent(text=f"{self.name} reply")])
|
||||
|
||||
|
||||
# region Sequential Builder Tests
|
||||
|
||||
|
||||
@@ -200,17 +175,21 @@ async def test_groupchat_kwargs_flow_to_agents() -> None:
|
||||
# Simple selector that takes GroupChatStateSnapshot
|
||||
turn_count = 0
|
||||
|
||||
def simple_selector(state: GroupChatStateSnapshot) -> str | None:
|
||||
def simple_selector(state: GroupChatState) -> str:
|
||||
nonlocal turn_count
|
||||
turn_count += 1
|
||||
if turn_count > 2: # Stop after 2 turns
|
||||
return None
|
||||
if turn_count > 2: # Loop after two turns for test
|
||||
turn_count = 0
|
||||
# state is a Mapping - access via dict syntax
|
||||
names = list(state["participants"].keys())
|
||||
names = list(state.participants.keys())
|
||||
return names[(turn_count - 1) % len(names)]
|
||||
|
||||
workflow = (
|
||||
GroupChatBuilder().participants(chat1=agent1, chat2=agent2).set_select_speakers_func(simple_selector).build()
|
||||
GroupChatBuilder()
|
||||
.participants([agent1, agent2])
|
||||
.with_select_speaker_func(simple_selector)
|
||||
.with_max_rounds(2) # Limit rounds to prevent infinite loop
|
||||
.build()
|
||||
)
|
||||
|
||||
custom_data = {"session_id": "group123"}
|
||||
@@ -359,6 +338,7 @@ async def test_kwargs_preserved_across_workflow_reruns() -> None:
|
||||
# region Handoff Builder Tests
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Handoff workflow does not yet propagate kwargs to agents")
|
||||
async def test_handoff_kwargs_flow_to_agents() -> None:
|
||||
"""Test that kwargs flow to agents in a handoff workflow."""
|
||||
agent1 = _KwargsCapturingAgent(name="coordinator")
|
||||
@@ -367,8 +347,9 @@ async def test_handoff_kwargs_flow_to_agents() -> None:
|
||||
workflow = (
|
||||
HandoffBuilder()
|
||||
.participants([agent1, agent2])
|
||||
.set_coordinator(agent1)
|
||||
.with_interaction_mode("autonomous")
|
||||
.with_start_agent(agent1)
|
||||
.with_autonomous_mode()
|
||||
.with_termination_condition(lambda conv: len(conv) >= 4)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -395,8 +376,8 @@ async def test_magentic_kwargs_flow_to_agents() -> None:
|
||||
from agent_framework._workflows._magentic import (
|
||||
MagenticContext,
|
||||
MagenticManagerBase,
|
||||
_MagenticProgressLedger,
|
||||
_MagenticProgressLedgerItem,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
)
|
||||
|
||||
# Create a mock manager that completes after one round
|
||||
@@ -405,29 +386,29 @@ async def test_magentic_kwargs_flow_to_agents() -> None:
|
||||
super().__init__(max_stall_count=3, max_reset_count=None, max_round_count=2)
|
||||
self.task_ledger = None
|
||||
|
||||
async def plan(self, context: MagenticContext) -> ChatMessage:
|
||||
async def plan(self, magentic_context: MagenticContext) -> ChatMessage:
|
||||
return ChatMessage(role=Role.ASSISTANT, text="Plan: Test task", author_name="manager")
|
||||
|
||||
async def replan(self, context: MagenticContext) -> ChatMessage:
|
||||
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
|
||||
return ChatMessage(role=Role.ASSISTANT, text="Replan: Test task", author_name="manager")
|
||||
|
||||
async def create_progress_ledger(self, context: MagenticContext) -> _MagenticProgressLedger:
|
||||
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
|
||||
# Return completed on first call
|
||||
return _MagenticProgressLedger(
|
||||
is_request_satisfied=_MagenticProgressLedgerItem(answer=True, reason="Done"),
|
||||
is_progress_being_made=_MagenticProgressLedgerItem(answer=True, reason="Progress"),
|
||||
is_in_loop=_MagenticProgressLedgerItem(answer=False, reason="Not looping"),
|
||||
instruction_or_question=_MagenticProgressLedgerItem(answer="Complete", reason="Done"),
|
||||
next_speaker=_MagenticProgressLedgerItem(answer="agent1", reason="First"),
|
||||
return MagenticProgressLedger(
|
||||
is_request_satisfied=MagenticProgressLedgerItem(answer=True, reason="Done"),
|
||||
is_progress_being_made=MagenticProgressLedgerItem(answer=True, reason="Progress"),
|
||||
is_in_loop=MagenticProgressLedgerItem(answer=False, reason="Not looping"),
|
||||
instruction_or_question=MagenticProgressLedgerItem(answer="Complete", reason="Done"),
|
||||
next_speaker=MagenticProgressLedgerItem(answer="agent1", reason="First"),
|
||||
)
|
||||
|
||||
async def prepare_final_answer(self, context: MagenticContext) -> ChatMessage:
|
||||
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
|
||||
return ChatMessage(role=Role.ASSISTANT, text="Final answer", author_name="manager")
|
||||
|
||||
agent = _KwargsCapturingAgent(name="agent1")
|
||||
manager = _MockManager()
|
||||
|
||||
workflow = MagenticBuilder().participants(agent1=agent).with_standard_manager(manager=manager).build()
|
||||
workflow = MagenticBuilder().participants([agent]).with_standard_manager(manager=manager).build()
|
||||
|
||||
custom_data = {"session_id": "magentic123"}
|
||||
|
||||
@@ -446,8 +427,8 @@ async def test_magentic_kwargs_stored_in_shared_state() -> None:
|
||||
from agent_framework._workflows._magentic import (
|
||||
MagenticContext,
|
||||
MagenticManagerBase,
|
||||
_MagenticProgressLedger,
|
||||
_MagenticProgressLedgerItem,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
)
|
||||
|
||||
class _MockManager(MagenticManagerBase):
|
||||
@@ -455,28 +436,28 @@ async def test_magentic_kwargs_stored_in_shared_state() -> None:
|
||||
super().__init__(max_stall_count=3, max_reset_count=None, max_round_count=1)
|
||||
self.task_ledger = None
|
||||
|
||||
async def plan(self, context: MagenticContext) -> ChatMessage:
|
||||
async def plan(self, magentic_context: MagenticContext) -> ChatMessage:
|
||||
return ChatMessage(role=Role.ASSISTANT, text="Plan", author_name="manager")
|
||||
|
||||
async def replan(self, context: MagenticContext) -> ChatMessage:
|
||||
async def replan(self, magentic_context: MagenticContext) -> ChatMessage:
|
||||
return ChatMessage(role=Role.ASSISTANT, text="Replan", author_name="manager")
|
||||
|
||||
async def create_progress_ledger(self, context: MagenticContext) -> _MagenticProgressLedger:
|
||||
return _MagenticProgressLedger(
|
||||
is_request_satisfied=_MagenticProgressLedgerItem(answer=True, reason="Done"),
|
||||
is_progress_being_made=_MagenticProgressLedgerItem(answer=True, reason="Progress"),
|
||||
is_in_loop=_MagenticProgressLedgerItem(answer=False, reason="Not looping"),
|
||||
instruction_or_question=_MagenticProgressLedgerItem(answer="Done", reason="Done"),
|
||||
next_speaker=_MagenticProgressLedgerItem(answer="agent1", reason="First"),
|
||||
async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger:
|
||||
return MagenticProgressLedger(
|
||||
is_request_satisfied=MagenticProgressLedgerItem(answer=True, reason="Done"),
|
||||
is_progress_being_made=MagenticProgressLedgerItem(answer=True, reason="Progress"),
|
||||
is_in_loop=MagenticProgressLedgerItem(answer=False, reason="Not looping"),
|
||||
instruction_or_question=MagenticProgressLedgerItem(answer="Done", reason="Done"),
|
||||
next_speaker=MagenticProgressLedgerItem(answer="agent1", reason="First"),
|
||||
)
|
||||
|
||||
async def prepare_final_answer(self, context: MagenticContext) -> ChatMessage:
|
||||
async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage:
|
||||
return ChatMessage(role=Role.ASSISTANT, text="Final", author_name="manager")
|
||||
|
||||
agent = _KwargsCapturingAgent(name="agent1")
|
||||
manager = _MockManager()
|
||||
|
||||
magentic_workflow = MagenticBuilder().participants(agent1=agent).with_standard_manager(manager=manager).build()
|
||||
magentic_workflow = MagenticBuilder().participants([agent]).with_standard_manager(manager=manager).build()
|
||||
|
||||
# Use MagenticWorkflow.run_stream() which goes through the kwargs attachment path
|
||||
custom_data = {"magentic_key": "magentic_value"}
|
||||
|
||||
Reference in New Issue
Block a user