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:
Ben Thomas
2026-02-05 19:50:37 -08:00
committed by GitHub
Unverified
parent f96772f6e8
commit c609b14f63
20 changed files with 131 additions and 174 deletions
@@ -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