mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix GroupChat orchestrator message cleanup issue (#3712)
* Fix GroupChat orchestrator message cleanup issue Apply clean_conversation_for_handoff to GroupChatOrchestrator and AgentBasedGroupChatOrchestrator _handle_response methods to remove tool-related content that causes API errors from empty messages. Fixes #3705 * Move orchestration related files to orchestrations package. * Fix imports --------- Co-authored-by: alliscode <bentho@microsoft.com> Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
f96772f6e8
commit
c609b14f63
@@ -7,12 +7,6 @@ from ._agent_executor import (
|
||||
AgentExecutorResponse,
|
||||
)
|
||||
from ._agent_utils import resolve_agent_id
|
||||
from ._base_group_chat_orchestrator import (
|
||||
BaseGroupChatOrchestrator,
|
||||
GroupChatRequestMessage,
|
||||
GroupChatRequestSentEvent,
|
||||
GroupChatResponseReceivedEvent,
|
||||
)
|
||||
from ._checkpoint import (
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
@@ -65,8 +59,6 @@ from ._executor import (
|
||||
handler,
|
||||
)
|
||||
from ._function_executor import FunctionExecutor, executor
|
||||
from ._orchestration_request_info import AgentRequestInfoResponse
|
||||
from ._orchestration_state import OrchestrationState
|
||||
from ._request_info_mixin import response_handler
|
||||
from ._runner import Runner
|
||||
from ._runner_context import (
|
||||
@@ -97,8 +89,6 @@ __all__ = [
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
"AgentRequestInfoResponse",
|
||||
"BaseGroupChatOrchestrator",
|
||||
"Case",
|
||||
"CheckpointStorage",
|
||||
"Default",
|
||||
@@ -115,13 +105,9 @@ __all__ = [
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
"GraphConnectivityError",
|
||||
"GroupChatRequestMessage",
|
||||
"GroupChatRequestSentEvent",
|
||||
"GroupChatResponseReceivedEvent",
|
||||
"InMemoryCheckpointStorage",
|
||||
"InProcRunnerContext",
|
||||
"Message",
|
||||
"OrchestrationState",
|
||||
"RequestInfoEvent",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Base class for group chat orchestrators that manages conversation flow and participant selection."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from abc import ABC
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, TypeAlias
|
||||
|
||||
from typing_extensions import Never
|
||||
|
||||
from .._types import ChatMessage
|
||||
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):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import override # type: ignore # pragma: no cover
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupChatRequestMessage:
|
||||
"""Request envelope sent from the orchestrator to a participant."""
|
||||
|
||||
additional_instruction: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupChatParticipantMessage:
|
||||
"""Message envelop containing messages generated by a participant.
|
||||
|
||||
This message envelope is used to broadcast messages from one participant
|
||||
to other participants in the group chat to keep them synchronized.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupChatResponseMessage:
|
||||
"""Response envelope emitted by participants back to the orchestrator."""
|
||||
|
||||
message: ChatMessage
|
||||
|
||||
|
||||
TerminationCondition: TypeAlias = Callable[[list[ChatMessage]], bool | Awaitable[bool]]
|
||||
GroupChatWorkflowContextOutT: 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.
|
||||
|
||||
Provides shared functionality for participant registration, routing,
|
||||
and round limit checking that is common across all group chat patterns.
|
||||
|
||||
Subclasses must implement pattern-specific orchestration logic while
|
||||
inheriting the common participant management infrastructure.
|
||||
"""
|
||||
|
||||
TERMINATION_CONDITION_MET_MESSAGE: ClassVar[str] = "The group chat has reached its termination condition."
|
||||
MAX_ROUNDS_MET_MESSAGE: ClassVar[str] = "The group chat has reached the maximum number of rounds."
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
participant_registry: ParticipantRegistry,
|
||||
*,
|
||||
name: str | None = None,
|
||||
max_rounds: int | None = None,
|
||||
termination_condition: TerminationCondition | None = None,
|
||||
) -> None:
|
||||
"""Initialize base orchestrator.
|
||||
|
||||
Args:
|
||||
id: Unique identifier for this orchestrator executor
|
||||
participant_registry: Registry of group chat participants that tracks their types (agents
|
||||
vs custom executors)
|
||||
name: Optional display name for orchestrator messages
|
||||
max_rounds: Optional maximum number of conversation rounds.
|
||||
Must be equal to or greater than 1 if set. Number smaller than 1 will be coerced to 1.
|
||||
termination_condition: Optional callable to determine conversation termination
|
||||
"""
|
||||
super().__init__(id)
|
||||
self._name = name or id
|
||||
self._max_rounds = max(1, max_rounds) if max_rounds is not None else None
|
||||
self._termination_condition = termination_condition
|
||||
self._round_index: int = 0
|
||||
self._participant_registry = participant_registry
|
||||
# Shared conversation state management
|
||||
self._full_conversation: list[ChatMessage] = []
|
||||
|
||||
# region Handlers
|
||||
|
||||
@handler
|
||||
async def handle_str(
|
||||
self,
|
||||
task: str,
|
||||
ctx: WorkflowContext[GroupChatWorkflowContextOutT, list[ChatMessage]],
|
||||
) -> None:
|
||||
"""Handler for string input as workflow entry point.
|
||||
|
||||
Wraps the string in a USER role ChatMessage and delegates to _handle_task_message.
|
||||
|
||||
Args:
|
||||
task: Plain text task description from user
|
||||
ctx: Workflow context
|
||||
|
||||
Usage:
|
||||
workflow.run("Write a blog post about AI agents")
|
||||
"""
|
||||
await self._handle_messages([ChatMessage(role="user", text=task)], ctx)
|
||||
|
||||
@handler
|
||||
async def handle_message(
|
||||
self,
|
||||
task: ChatMessage,
|
||||
ctx: WorkflowContext[GroupChatWorkflowContextOutT, 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="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[GroupChatWorkflowContextOutT, 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="user", text="Write a blog post about AI agents"),
|
||||
ChatMessage(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[GroupChatWorkflowContextOutT, 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[GroupChatWorkflowContextOutT, 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[GroupChatWorkflowContextOutT, 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)
|
||||
|
||||
def _append_messages(self, messages: Sequence[ChatMessage]) -> None:
|
||||
"""Append messages to the conversation history.
|
||||
|
||||
Args:
|
||||
messages: Messages to append
|
||||
"""
|
||||
self._full_conversation.extend(messages)
|
||||
|
||||
def _get_conversation(self) -> list[ChatMessage]:
|
||||
"""Get a copy of the current conversation.
|
||||
|
||||
Returns:
|
||||
Cloned conversation list
|
||||
"""
|
||||
return list(self._full_conversation)
|
||||
|
||||
def _process_participant_response(
|
||||
self, response: AgentExecutorResponse | GroupChatResponseMessage
|
||||
) -> list[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_response.messages
|
||||
if isinstance(response, GroupChatResponseMessage):
|
||||
return [response.message]
|
||||
raise TypeError(f"Unsupported response type: {type(response)}")
|
||||
|
||||
def _clear_conversation(self) -> None:
|
||||
"""Clear the conversation history."""
|
||||
self._full_conversation.clear()
|
||||
|
||||
def _increment_round(self) -> None:
|
||||
"""Increment the round counter."""
|
||||
self._round_index += 1
|
||||
|
||||
async def _check_termination(self) -> bool:
|
||||
"""Check if conversation should terminate based on termination condition.
|
||||
|
||||
Supports both synchronous and asynchronous termination conditions.
|
||||
|
||||
Returns:
|
||||
True if termination condition met, False otherwise
|
||||
"""
|
||||
if self._termination_condition is None:
|
||||
return False
|
||||
|
||||
result = self._termination_condition(self._get_conversation())
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return result
|
||||
|
||||
async def _check_terminate_and_yield(self, ctx: WorkflowContext[Never, list[ChatMessage]]) -> bool:
|
||||
"""Check termination conditions and yield completion if met.
|
||||
|
||||
Args:
|
||||
ctx: Workflow context for yielding output
|
||||
|
||||
Returns:
|
||||
True if termination condition met and output yielded, False otherwise
|
||||
"""
|
||||
terminate = await self._check_termination()
|
||||
if terminate:
|
||||
self._append_messages([self._create_completion_message(self.TERMINATION_CONDITION_MET_MESSAGE)])
|
||||
await ctx.yield_output(self._full_conversation)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _create_completion_message(self, message: str) -> ChatMessage:
|
||||
"""Create a standardized completion message.
|
||||
|
||||
Args:
|
||||
message: Completion text
|
||||
|
||||
Returns:
|
||||
ChatMessage with completion content
|
||||
"""
|
||||
return ChatMessage(role="assistant", text=message, author_name=self._name)
|
||||
|
||||
# Participant routing (shared across all patterns)
|
||||
|
||||
async def _broadcast_messages_to_participants(
|
||||
self,
|
||||
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],
|
||||
*,
|
||||
additional_instruction: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a request to a participant.
|
||||
|
||||
This method handles the dual envelope pattern:
|
||||
- AgentExecutors receive AgentExecutorRequest (messages only)
|
||||
- Custom executors receive GroupChatRequestMessage (full context)
|
||||
|
||||
Args:
|
||||
target: Name of the participant to route to
|
||||
ctx: Workflow context for message routing
|
||||
additional_instruction: Optional additional instruction for the participant.
|
||||
This can be used to provide guidance to steer the participant's response.
|
||||
metadata: Optional metadata dict
|
||||
|
||||
Raises:
|
||||
ValueError: If participant is not registered
|
||||
"""
|
||||
if self._participant_registry.is_agent(target):
|
||||
# AgentExecutors receive simple message list
|
||||
messages: list[ChatMessage] = []
|
||||
if additional_instruction:
|
||||
messages.append(ChatMessage(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 = 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,
|
||||
)
|
||||
)
|
||||
|
||||
# Round limit enforcement (shared across all patterns)
|
||||
|
||||
def _check_round_limit(self) -> bool:
|
||||
"""Check if round limit has been reached.
|
||||
|
||||
Uses instance variables _round_index and _max_rounds.
|
||||
|
||||
Returns:
|
||||
True if limit reached, False otherwise
|
||||
"""
|
||||
if self._max_rounds is None:
|
||||
return False
|
||||
|
||||
if self._round_index >= self._max_rounds:
|
||||
logger.warning(
|
||||
"%s reached max_rounds=%s; forcing completion.",
|
||||
self.__class__.__name__,
|
||||
self._max_rounds,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _check_round_limit_and_yield(self, ctx: WorkflowContext[Never, 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)
|
||||
|
||||
@override
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
"""Capture current orchestrator state for checkpointing.
|
||||
|
||||
Default implementation uses OrchestrationState to serialize common state.
|
||||
Subclasses can override this method or _snapshot_pattern_metadata() to add pattern-specific data.
|
||||
|
||||
Returns:
|
||||
Serialized state dict
|
||||
"""
|
||||
from ._orchestration_state import OrchestrationState
|
||||
|
||||
state = OrchestrationState(
|
||||
conversation=list(self._full_conversation),
|
||||
round_index=self._round_index,
|
||||
orchestrator_name=self._name,
|
||||
metadata=self._snapshot_pattern_metadata(),
|
||||
)
|
||||
return state.to_dict()
|
||||
|
||||
def _snapshot_pattern_metadata(self) -> dict[str, Any]:
|
||||
"""Serialize pattern-specific state.
|
||||
|
||||
Override this method to add pattern-specific checkpoint data.
|
||||
|
||||
Returns:
|
||||
Dict with pattern-specific state (empty by default)
|
||||
"""
|
||||
return {}
|
||||
|
||||
@override
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
"""Restore orchestrator state from checkpoint.
|
||||
|
||||
Default implementation uses OrchestrationState to deserialize common state.
|
||||
Subclasses can override this method or _restore_pattern_metadata() to restore pattern-specific data.
|
||||
|
||||
Args:
|
||||
state: Serialized state dict
|
||||
"""
|
||||
from ._orchestration_state import OrchestrationState
|
||||
|
||||
orch_state = OrchestrationState.from_dict(state)
|
||||
self._full_conversation = list(orch_state.conversation)
|
||||
self._round_index = orch_state.round_index
|
||||
self._name = orch_state.orchestrator_name
|
||||
self._restore_pattern_metadata(orch_state.metadata)
|
||||
|
||||
def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None:
|
||||
"""Restore pattern-specific state.
|
||||
|
||||
Override this method to restore pattern-specific checkpoint data.
|
||||
|
||||
Args:
|
||||
metadata: Pattern-specific state dict
|
||||
"""
|
||||
pass
|
||||
@@ -1,146 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._agents import AgentProtocol
|
||||
from .._types import ChatMessage
|
||||
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
|
||||
from ._workflow_executor import WorkflowExecutor
|
||||
|
||||
|
||||
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:
|
||||
agents: List of agent names (str), AgentProtocol instances, or Executor instances.
|
||||
If None, returns None (meaning no filtering - pause for all).
|
||||
|
||||
Returns:
|
||||
Set of executor/agent IDs to filter on, or None if no filtering.
|
||||
"""
|
||||
if agents is None:
|
||||
return set()
|
||||
|
||||
result: set[str] = set()
|
||||
for agent in agents:
|
||||
if isinstance(agent, str):
|
||||
result.add(agent)
|
||||
elif isinstance(agent, AgentProtocol):
|
||||
result.add(resolve_agent_id(agent))
|
||||
else:
|
||||
raise TypeError(f"Unsupported type for request_info filter: {type(agent).__name__}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRequestInfoResponse:
|
||||
"""Response containing additional information requested from users for agents.
|
||||
|
||||
Attributes:
|
||||
messages: list[ChatMessage]: Additional messages provided by users. If empty,
|
||||
the agent response is approved as-is.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
@staticmethod
|
||||
def from_messages(messages: list[ChatMessage]) -> "AgentRequestInfoResponse":
|
||||
"""Create an AgentRequestInfoResponse from a list of ChatMessages.
|
||||
|
||||
Args:
|
||||
messages: List of ChatMessage instances provided by users.
|
||||
|
||||
Returns:
|
||||
AgentRequestInfoResponse instance.
|
||||
"""
|
||||
return AgentRequestInfoResponse(messages=messages)
|
||||
|
||||
@staticmethod
|
||||
def from_strings(texts: list[str]) -> "AgentRequestInfoResponse":
|
||||
"""Create an AgentRequestInfoResponse from a list of string messages.
|
||||
|
||||
Args:
|
||||
texts: List of text messages provided by users.
|
||||
|
||||
Returns:
|
||||
AgentRequestInfoResponse instance.
|
||||
"""
|
||||
return AgentRequestInfoResponse(messages=[ChatMessage(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 request_info(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext) -> None:
|
||||
"""Handle the agent's response and gather additional info from users."""
|
||||
await ctx.request_info(agent_response, AgentRequestInfoResponse)
|
||||
|
||||
@response_handler
|
||||
async def handle_request_info_response(
|
||||
self,
|
||||
original_request: AgentExecutorResponse,
|
||||
response: AgentRequestInfoResponse,
|
||||
ctx: WorkflowContext[AgentExecutorRequest, AgentExecutorResponse],
|
||||
) -> None:
|
||||
"""Process the additional info provided by users."""
|
||||
if response.messages:
|
||||
# User provided additional messages, further iterate on agent response
|
||||
await ctx.send_message(AgentExecutorRequest(messages=response.messages, should_respond=True))
|
||||
else:
|
||||
# No additional info, approve original agent response
|
||||
await ctx.yield_output(original_request)
|
||||
|
||||
|
||||
class 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:
|
||||
agent: The agent protocol to use for generating responses.
|
||||
"""
|
||||
super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True)
|
||||
self._description = agent.description
|
||||
|
||||
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")
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
@property
|
||||
def description(self) -> str | None:
|
||||
"""Get a description of the underlying agent."""
|
||||
return self._description
|
||||
@@ -1,93 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unified state management for group chat orchestrators.
|
||||
|
||||
Provides OrchestrationState dataclass for standardized checkpoint serialization
|
||||
across GroupChat, Handoff, and Magentic patterns.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .._types import ChatMessage
|
||||
|
||||
|
||||
def _new_chat_message_list() -> list[ChatMessage]:
|
||||
"""Factory function for typed empty ChatMessage list.
|
||||
|
||||
Satisfies the type checker.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
def _new_metadata_dict() -> dict[str, Any]:
|
||||
"""Factory function for typed empty metadata dict.
|
||||
|
||||
Satisfies the type checker.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrchestrationState:
|
||||
"""Unified state container for orchestrator checkpointing.
|
||||
|
||||
This dataclass standardizes checkpoint serialization across all three
|
||||
group chat patterns while allowing pattern-specific extensions via metadata.
|
||||
|
||||
Common attributes cover shared orchestration concerns (task, conversation,
|
||||
round tracking). Pattern-specific state goes in the metadata dict.
|
||||
|
||||
Attributes:
|
||||
conversation: Full conversation history (all messages)
|
||||
round_index: Number of coordination rounds completed (0 if not tracked)
|
||||
metadata: Extensible dict for pattern-specific state
|
||||
task: Optional primary task/question being orchestrated
|
||||
"""
|
||||
|
||||
conversation: list[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
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize to dict for checkpointing.
|
||||
|
||||
Returns:
|
||||
Dict with encoded conversation and metadata for persistence
|
||||
"""
|
||||
from ._conversation_state import encode_chat_messages
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"conversation": encode_chat_messages(self.conversation),
|
||||
"round_index": self.round_index,
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
if self.task is not None:
|
||||
result["task"] = encode_chat_messages([self.task])[0]
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "OrchestrationState":
|
||||
"""Deserialize from checkpointed dict.
|
||||
|
||||
Args:
|
||||
data: Checkpoint data with encoded conversation
|
||||
|
||||
Returns:
|
||||
Restored OrchestrationState instance
|
||||
"""
|
||||
from ._conversation_state import decode_chat_messages
|
||||
|
||||
task = None
|
||||
if "task" in data:
|
||||
decoded_tasks = decode_chat_messages([data["task"]])
|
||||
task = decoded_tasks[0] if decoded_tasks else None
|
||||
|
||||
return cls(
|
||||
conversation=decode_chat_messages(data.get("conversation", [])),
|
||||
round_index=data.get("round_index", 0),
|
||||
metadata=dict(data.get("metadata", {})),
|
||||
task=task,
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared orchestrator utilities for group chat patterns.
|
||||
|
||||
This module provides simple, reusable functions for common orchestration tasks.
|
||||
No inheritance required - just import and call.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .._types import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Remove tool-related content from conversation for clean handoffs.
|
||||
|
||||
During handoffs, tool calls can cause API errors because:
|
||||
1. Assistant messages with tool_calls must be followed by tool responses
|
||||
2. Tool response messages must follow an assistant message with tool_calls
|
||||
|
||||
This creates a cleaned copy removing ALL tool-related content.
|
||||
|
||||
Removes:
|
||||
- function_approval_request and function_call from assistant messages
|
||||
- Tool response messages (role="tool")
|
||||
- Messages with only tool calls and no text
|
||||
|
||||
Preserves:
|
||||
- User messages
|
||||
- Assistant messages with text content
|
||||
|
||||
Args:
|
||||
conversation: Original conversation with potential tool content
|
||||
|
||||
Returns:
|
||||
Cleaned conversation safe for handoff routing
|
||||
"""
|
||||
cleaned: list[ChatMessage] = []
|
||||
for msg in conversation:
|
||||
# Skip tool response messages entirely
|
||||
if msg.role == "tool":
|
||||
continue
|
||||
|
||||
# Check for tool-related content
|
||||
has_tool_content = False
|
||||
if msg.contents:
|
||||
has_tool_content = any(
|
||||
content.type in ("function_approval_request", "function_call") for content in msg.contents
|
||||
)
|
||||
|
||||
# If no tool content, keep original
|
||||
if not has_tool_content:
|
||||
cleaned.append(msg)
|
||||
continue
|
||||
|
||||
# Has tool content - only keep if it also has text
|
||||
if msg.text and msg.text.strip():
|
||||
# Create fresh text-only message while preserving additional_properties
|
||||
msg_copy = ChatMessage(
|
||||
role=msg.role,
|
||||
text=msg.text,
|
||||
author_name=msg.author_name,
|
||||
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
|
||||
)
|
||||
cleaned.append(msg_copy)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def create_completion_message(
|
||||
*,
|
||||
text: str | None = None,
|
||||
author_name: str,
|
||||
reason: str = "completed",
|
||||
) -> ChatMessage:
|
||||
"""Create a standardized completion message.
|
||||
|
||||
Simple helper to avoid duplicating completion message creation.
|
||||
|
||||
Args:
|
||||
text: Message text, or None to generate default
|
||||
author_name: Author/orchestrator name
|
||||
reason: Reason for completion (for default text generation)
|
||||
|
||||
Returns:
|
||||
ChatMessage with assistant role
|
||||
"""
|
||||
message_text = text or f"Conversation {reason}."
|
||||
return ChatMessage(
|
||||
role="assistant",
|
||||
text=message_text,
|
||||
author_name=author_name,
|
||||
)
|
||||
@@ -17,6 +17,17 @@ _IMPORTS = [
|
||||
"HandoffBuilder",
|
||||
"HandoffConfiguration",
|
||||
"HandoffSentEvent",
|
||||
# Base orchestrator
|
||||
"BaseGroupChatOrchestrator",
|
||||
"GroupChatRequestMessage",
|
||||
"GroupChatRequestSentEvent",
|
||||
"GroupChatResponseReceivedEvent",
|
||||
"TerminationCondition",
|
||||
# Orchestration helpers
|
||||
"AgentRequestInfoResponse",
|
||||
"OrchestrationState",
|
||||
"clean_conversation_for_handoff",
|
||||
"create_completion_message",
|
||||
# Group Chat
|
||||
"AgentBasedGroupChatOrchestrator",
|
||||
"AgentOrchestrationOutput",
|
||||
|
||||
@@ -1,108 +1,39 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# Type stubs for lazy-loaded orchestrations module
|
||||
# These re-export types from agent_framework_orchestrations
|
||||
|
||||
from agent_framework_orchestrations import (
|
||||
# Magentic
|
||||
MAGENTIC_MANAGER_NAME as MAGENTIC_MANAGER_NAME,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
ORCH_MSG_KIND_INSTRUCTION as ORCH_MSG_KIND_INSTRUCTION,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
ORCH_MSG_KIND_NOTICE as ORCH_MSG_KIND_NOTICE,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
ORCH_MSG_KIND_TASK_LEDGER as ORCH_MSG_KIND_TASK_LEDGER,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
ORCH_MSG_KIND_USER_TASK as ORCH_MSG_KIND_USER_TASK,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
# Group Chat
|
||||
AgentBasedGroupChatOrchestrator as AgentBasedGroupChatOrchestrator,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
AgentOrchestrationOutput as AgentOrchestrationOutput,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
# Concurrent
|
||||
ConcurrentBuilder as ConcurrentBuilder,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
GroupChatBuilder as GroupChatBuilder,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
GroupChatOrchestrator as GroupChatOrchestrator,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
GroupChatSelectionFunction as GroupChatSelectionFunction,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
GroupChatState as GroupChatState,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
# Handoff
|
||||
HandoffAgentExecutor as HandoffAgentExecutor,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
HandoffAgentUserRequest as HandoffAgentUserRequest,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
HandoffBuilder as HandoffBuilder,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
HandoffConfiguration as HandoffConfiguration,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
HandoffSentEvent as HandoffSentEvent,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticAgentExecutor as MagenticAgentExecutor,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticBuilder as MagenticBuilder,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticContext as MagenticContext,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticManagerBase as MagenticManagerBase,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticOrchestrator as MagenticOrchestrator,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticOrchestratorEvent as MagenticOrchestratorEvent,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticOrchestratorEventType as MagenticOrchestratorEventType,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticPlanReviewRequest as MagenticPlanReviewRequest,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticPlanReviewResponse as MagenticPlanReviewResponse,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticProgressLedger as MagenticProgressLedger,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticProgressLedgerItem as MagenticProgressLedgerItem,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
MagenticResetSignal as MagenticResetSignal,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
# Sequential
|
||||
SequentialBuilder as SequentialBuilder,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
StandardMagenticManager as StandardMagenticManager,
|
||||
)
|
||||
from agent_framework_orchestrations import (
|
||||
__version__ as __version__,
|
||||
MAGENTIC_MANAGER_NAME,
|
||||
ORCH_MSG_KIND_INSTRUCTION,
|
||||
ORCH_MSG_KIND_NOTICE,
|
||||
ORCH_MSG_KIND_TASK_LEDGER,
|
||||
ORCH_MSG_KIND_USER_TASK,
|
||||
AgentBasedGroupChatOrchestrator,
|
||||
AgentOrchestrationOutput,
|
||||
AgentRequestInfoResponse,
|
||||
ConcurrentBuilder,
|
||||
GroupChatBuilder,
|
||||
GroupChatOrchestrator,
|
||||
GroupChatSelectionFunction,
|
||||
GroupChatState,
|
||||
HandoffAgentExecutor,
|
||||
HandoffAgentUserRequest,
|
||||
HandoffBuilder,
|
||||
HandoffConfiguration,
|
||||
HandoffSentEvent,
|
||||
MagenticAgentExecutor,
|
||||
MagenticBuilder,
|
||||
MagenticContext,
|
||||
MagenticManagerBase,
|
||||
MagenticOrchestrator,
|
||||
MagenticOrchestratorEvent,
|
||||
MagenticOrchestratorEventType,
|
||||
MagenticPlanReviewRequest,
|
||||
MagenticPlanReviewResponse,
|
||||
MagenticProgressLedger,
|
||||
MagenticProgressLedgerItem,
|
||||
MagenticResetSignal,
|
||||
SequentialBuilder,
|
||||
StandardMagenticManager,
|
||||
__version__,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -113,6 +44,7 @@ __all__ = [
|
||||
"ORCH_MSG_KIND_USER_TASK",
|
||||
"AgentBasedGroupChatOrchestrator",
|
||||
"AgentOrchestrationOutput",
|
||||
"AgentRequestInfoResponse",
|
||||
"ConcurrentBuilder",
|
||||
"GroupChatBuilder",
|
||||
"GroupChatOrchestrator",
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Unit tests for orchestration request info support."""
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentThread,
|
||||
ChatMessage,
|
||||
)
|
||||
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 TestResolveRequestInfoFilter:
|
||||
"""Tests for resolve_request_info_filter function."""
|
||||
|
||||
def test_returns_empty_set_for_none_input(self):
|
||||
"""Test that None input returns empty set (no filtering)."""
|
||||
result = resolve_request_info_filter(None)
|
||||
assert result == set()
|
||||
|
||||
def test_returns_empty_set_for_empty_list(self):
|
||||
"""Test that empty list returns empty set."""
|
||||
result = resolve_request_info_filter([])
|
||||
assert result == set()
|
||||
|
||||
def test_resolves_string_names(self):
|
||||
"""Test resolving string agent names."""
|
||||
result = resolve_request_info_filter(["agent1", "agent2"])
|
||||
assert result == {"agent1", "agent2"}
|
||||
|
||||
def test_resolves_agent_display_names(self):
|
||||
"""Test resolving AgentProtocol instances by name attribute."""
|
||||
agent1 = MagicMock(spec=AgentProtocol)
|
||||
agent1.name = "writer"
|
||||
agent2 = MagicMock(spec=AgentProtocol)
|
||||
agent2.name = "reviewer"
|
||||
|
||||
result = resolve_request_info_filter([agent1, agent2])
|
||||
assert result == {"writer", "reviewer"}
|
||||
|
||||
def test_mixed_types(self):
|
||||
"""Test resolving a mix of strings and agents."""
|
||||
agent = MagicMock(spec=AgentProtocol)
|
||||
agent.name = "writer"
|
||||
|
||||
result = resolve_request_info_filter(["manual_name", agent])
|
||||
assert result == {"manual_name", "writer"}
|
||||
|
||||
def test_raises_on_unsupported_type(self):
|
||||
"""Test that unsupported types raise TypeError."""
|
||||
with pytest.raises(TypeError, match="Unsupported type for request_info filter"):
|
||||
resolve_request_info_filter([123]) # type: ignore
|
||||
|
||||
|
||||
class TestAgentRequestInfoResponse:
|
||||
"""Tests for AgentRequestInfoResponse dataclass."""
|
||||
|
||||
def test_create_response_with_messages(self):
|
||||
"""Test creating an AgentRequestInfoResponse with messages."""
|
||||
messages = [ChatMessage(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="user", text="Message 1"),
|
||||
ChatMessage(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 == "user"
|
||||
assert response.messages[0].text == "First message"
|
||||
assert response.messages[1].role == "user"
|
||||
assert response.messages[1].text == "Second message"
|
||||
|
||||
def test_approve_factory(self):
|
||||
"""Test creating an approval response (empty messages)."""
|
||||
response = AgentRequestInfoResponse.approve()
|
||||
|
||||
assert response.messages == []
|
||||
|
||||
|
||||
class TestAgentRequestInfoExecutor:
|
||||
"""Tests for AgentRequestInfoExecutor."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_info_handler(self):
|
||||
"""Test that request_info handler calls ctx.request_info."""
|
||||
executor = AgentRequestInfoExecutor(id="test_executor")
|
||||
|
||||
agent_response = AgentResponse(messages=[ChatMessage(role="assistant", text="Agent response")])
|
||||
agent_response = AgentExecutorResponse(
|
||||
executor_id="test_agent",
|
||||
agent_response=agent_response,
|
||||
)
|
||||
|
||||
ctx = MagicMock(spec=WorkflowContext)
|
||||
ctx.request_info = AsyncMock()
|
||||
|
||||
await executor.request_info(agent_response, ctx)
|
||||
|
||||
ctx.request_info.assert_called_once_with(agent_response, AgentRequestInfoResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_request_info_response_with_messages(self):
|
||||
"""Test response handler when user provides additional messages."""
|
||||
executor = AgentRequestInfoExecutor(id="test_executor")
|
||||
|
||||
agent_response = AgentResponse(messages=[ChatMessage(role="assistant", text="Original")])
|
||||
original_request = AgentExecutorResponse(
|
||||
executor_id="test_agent",
|
||||
agent_response=agent_response,
|
||||
)
|
||||
|
||||
response = AgentRequestInfoResponse.from_strings(["Additional input"])
|
||||
|
||||
ctx = MagicMock(spec=WorkflowContext)
|
||||
ctx.send_message = AsyncMock()
|
||||
|
||||
await executor.handle_request_info_response(original_request, response, ctx)
|
||||
|
||||
# Should send new request with additional messages
|
||||
ctx.send_message.assert_called_once()
|
||||
call_args = ctx.send_message.call_args[0][0]
|
||||
assert isinstance(call_args, AgentExecutorRequest)
|
||||
assert call_args.should_respond is True
|
||||
assert len(call_args.messages) == 1
|
||||
assert call_args.messages[0].text == "Additional input"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_request_info_response_approval(self):
|
||||
"""Test response handler when user approves (no additional messages)."""
|
||||
executor = AgentRequestInfoExecutor(id="test_executor")
|
||||
|
||||
agent_response = AgentResponse(messages=[ChatMessage(role="assistant", text="Original")])
|
||||
original_request = AgentExecutorResponse(
|
||||
executor_id="test_agent",
|
||||
agent_response=agent_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,
|
||||
*,
|
||||
stream: bool = False,
|
||||
thread: AgentThread | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResponse | AsyncIterable[AgentResponseUpdate]:
|
||||
"""Dummy run method."""
|
||||
if stream:
|
||||
return self._run_stream_impl()
|
||||
return AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")])
|
||||
|
||||
async def _run_stream_impl(self) -> AsyncIterable[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(messages=[ChatMessage(role="assistant", text="Test response stream")])
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user