From 24bdd2936c58c17c6aa4cabb449131efad6d081d Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 17 Oct 2025 16:26:25 +0900 Subject: [PATCH 01/15] Intro group chat and refactor magentic. Fix as_agent() --- .../agent_framework/_workflows/__init__.py | 32 +- .../agent_framework/_workflows/__init__.pyi | 22 +- .../agent_framework/_workflows/_executor.py | 8 +- .../agent_framework/_workflows/_group_chat.py | 1340 +++++++++++++++++ .../agent_framework/_workflows/_magentic.py | 771 +++++++--- .../workflow/test_group_chat_builder_spec.py | 201 +++ .../core/tests/workflow/test_magentic.py | 20 +- python/samples/README.md | 2 +- .../getting_started/workflows/README.md | 4 + .../agents/concurrent_workflow_as_agent.py | 126 ++ .../agents/group_chat_workflow_as_agent.py | 76 + .../agents/magentic_workflow_as_agent.py | 139 ++ .../agents/sequential_workflow_as_agent.py | 87 ++ .../workflows/orchestration/group_chat.py | 70 + .../workflows/orchestration/magentic.py | 73 +- .../orchestration/magentic_checkpoint.py | 6 +- .../magentic_human_plan_update.py | 74 +- 17 files changed, 2719 insertions(+), 332 deletions(-) create mode 100644 python/packages/core/agent_framework/_workflows/_group_chat.py create mode 100644 python/packages/core/tests/workflow/test_group_chat_builder_spec.py create mode 100644 python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py create mode 100644 python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py create mode 100644 python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py create mode 100644 python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py create mode 100644 python/samples/getting_started/workflows/orchestration/group_chat.py diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 94950e19487..17de3bd2067 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -52,13 +52,26 @@ handler, ) from ._function_executor import FunctionExecutor, executor +from ._group_chat import ( + DEFAULT_MANAGER_INSTRUCTIONS, + GroupChatBuilder, + GroupChatDirective, + GroupChatManagerProtocol, + GroupChatOrchestratorExecutor, + GroupChatParticipantNodes, + GroupChatParticipantSpec, + GroupChatRequestMessage, + GroupChatResponseMessage, + GroupChatState, + GroupChatTurn, + GroupChatWiring, + StandardGroupChatManager, +) from ._magentic import ( MagenticAgentDeltaEvent, MagenticAgentExecutor, MagenticAgentMessageEvent, MagenticBuilder, - MagenticCallbackEvent, - MagenticCallbackMode, MagenticContext, MagenticFinalResultEvent, MagenticManagerBase, @@ -103,6 +116,7 @@ from ._workflow_executor import WorkflowExecutor __all__ = [ + "DEFAULT_MANAGER_INSTRUCTIONS", "DEFAULT_MAX_ITERATIONS", "AgentExecutor", "AgentExecutorRequest", @@ -126,14 +140,23 @@ "FileCheckpointStorage", "FunctionExecutor", "GraphConnectivityError", + "GroupChatBuilder", + "GroupChatDirective", + "GroupChatManagerProtocol", + "GroupChatOrchestratorExecutor", + "GroupChatParticipantNodes", + "GroupChatParticipantSpec", + "GroupChatRequestMessage", + "GroupChatResponseMessage", + "GroupChatState", + "GroupChatTurn", + "GroupChatWiring", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", "MagenticAgentExecutor", "MagenticAgentMessageEvent", "MagenticBuilder", - "MagenticCallbackEvent", - "MagenticCallbackMode", "MagenticContext", "MagenticFinalResultEvent", "MagenticManagerBase", @@ -158,6 +181,7 @@ "SequentialBuilder", "SharedState", "SingleEdgeGroup", + "StandardGroupChatManager", "StandardMagenticManager", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index d98829c56da..241c2c6c90f 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -50,13 +50,21 @@ from ._executor import ( handler, ) from ._function_executor import FunctionExecutor, executor +from ._group_chat import ( + GroupChatBuilder, + GroupChatDirective, + GroupChatManagerProtocol, + GroupChatOrchestratorExecutor, + GroupChatRequestMessage, + GroupChatResponseMessage, + GroupChatState, + StandardGroupChatManager, +) from ._magentic import ( MagenticAgentDeltaEvent, MagenticAgentExecutor, MagenticAgentMessageEvent, MagenticBuilder, - MagenticCallbackEvent, - MagenticCallbackMode, MagenticContext, MagenticFinalResultEvent, MagenticManagerBase, @@ -124,14 +132,19 @@ __all__ = [ "FileCheckpointStorage", "FunctionExecutor", "GraphConnectivityError", + "GroupChatBuilder", + "GroupChatDirective", + "GroupChatManagerProtocol", + "GroupChatOrchestratorExecutor", + "GroupChatRequestMessage", + "GroupChatResponseMessage", + "GroupChatState", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", "MagenticAgentExecutor", "MagenticAgentMessageEvent", "MagenticBuilder", - "MagenticCallbackEvent", - "MagenticCallbackMode", "MagenticContext", "MagenticFinalResultEvent", "MagenticManagerBase", @@ -156,6 +169,7 @@ __all__ = [ "SequentialBuilder", "SharedState", "SingleEdgeGroup", + "StandardGroupChatManager", "StandardMagenticManager", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 1f822e870a0..852a6eef8ed 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -450,13 +450,7 @@ def to_dict(self) -> dict[str, Any]: def handler( func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]], -) -> ( - Callable[[ExecutorT, Any, ContextT], Awaitable[Any]] - | Callable[ - [Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]], - Callable[[ExecutorT, Any, ContextT], Awaitable[Any]], - ] -): +) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]: """Decorator to register a handler for an executor. Args: diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py new file mode 100644 index 00000000000..febc6ae90f1 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -0,0 +1,1340 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Group chat orchestration primitives. + +This module introduces a reusable orchestration surface for manager-directed +multi-agent conversations. The key components are: + +- GroupChatRequestMessage / GroupChatResponseMessage: canonical envelopes used + between the orchestrator and participants. +- GroupChatManagerProtocol: minimal contract for pluggable coordination logic. +- GroupChatOrchestratorExecutor: runtime state machine that delegates to a + manager to select the next participant or complete the task. +- GroupChatBuilder: high-level builder that wires managers and participants + into a workflow graph. It mirrors the ergonomics of SequentialBuilder and + ConcurrentBuilder while allowing Magentic to reuse the same infrastructure. + +The default wiring uses AgentExecutor under the hood for agent participants so +existing observability and streaming semantics continue to apply. +""" + +import itertools +import json +import logging +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable +from uuid import uuid4 + +from pydantic import BaseModel, ValidationError + +from .._agents import AgentProtocol +from .._clients import ChatClientProtocol +from .._types import ChatMessage, Role +from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse +from ._checkpoint import CheckpointStorage +from ._executor import Executor, handler +from ._workflow import Workflow, WorkflowBuilder +from ._workflow_context import WorkflowContext + +logger = logging.getLogger(__name__) + + +# region Message primitives + + +@dataclass +class GroupChatRequestMessage: + """Request envelope sent from the orchestrator to a participant.""" + + agent_name: str + conversation: list[ChatMessage] = field(default_factory=list) # type: ignore + instruction: str = "" + task: ChatMessage | None = None + metadata: dict[str, Any] | None = None + + +@dataclass +class GroupChatResponseMessage: + """Response envelope emitted by participants back to the orchestrator.""" + + agent_name: str + message: ChatMessage + target_agent: str | None = None + broadcast: bool = False + metadata: dict[str, Any] | None = None + + +@dataclass +class GroupChatTurn: + """Represents a single turn in the manager-participant conversation.""" + + speaker: str + role: str + message: ChatMessage + + +@dataclass +class GroupChatState: + """Snapshot of the current orchestration state provided to managers.""" + + task: ChatMessage + participants: Mapping[str, str] + conversation: Sequence[ChatMessage] + history: Sequence[GroupChatTurn] + pending_agent: str | None + round_index: int + + +@dataclass +class GroupChatDirective: + """Instruction emitted by a GroupChatManagerProtocol implementation.""" + + agent_name: str | None = None + instruction: str | None = None + metadata: dict[str, Any] | None = None + finish: bool = False + final_message: ChatMessage | None = None + + +# endregion + + +# region Manager protocol + + +@runtime_checkable +class GroupChatManagerProtocol(Protocol): + """Interface for orchestration managers that drive group chat workflows.""" + + @property + def name(self) -> str: ... + + async def next_action(self, state: GroupChatState) -> GroupChatDirective: + """Return the next directive based on current conversation state.""" + ... + + +@dataclass +class GroupChatParticipantSpec: + """Metadata describing a single participant in the orchestration. + + Attributes: + name: Unique identifier for the participant used by the manager for selection + participant: AgentProtocol or Executor instance representing the participant + description: Human-readable description provided to the manager for selection context + """ + + name: str + participant: AgentProtocol | Executor + description: str + + +@dataclass +class GroupChatParticipantNodes: + """Nodes that implement a participant pipeline in the workflow graph. + + Attributes: + entry: First executor in the participant pipeline that receives orchestrator requests + exit: Final executor in the participant pipeline that sends responses back + intermediates: Optional sequence of executors between entry and exit (e.g., AgentExecutor) + """ + + entry: Executor + exit: Executor + intermediates: Sequence[Executor] = field(default_factory=tuple) + + +@dataclass +class GroupChatWiring: + """Configuration passed to factories during workflow assembly. + + Attributes: + manager: Manager instance responsible for orchestration decisions + manager_name: Display name for the manager in conversation history + participants: Mapping of participant names to their specifications + max_rounds: Optional limit on manager selection rounds to prevent infinite loops + orchestrator: Orchestrator executor instance (populated during build) + """ + + manager: GroupChatManagerProtocol + manager_name: str + participants: Mapping[str, GroupChatParticipantSpec] + max_rounds: int | None = None + orchestrator: Executor | None = None + + +# endregion + + +# region Default participant adapters + + +class _GroupChatAgentIngress(Executor): + """Adapter that converts orchestrator requests into agent-compatible execution requests. + + This internal executor sits at the entry point of each agent participant's pipeline, + translating GroupChatRequestMessage envelopes from the orchestrator into + AgentExecutorRequest format that AgentExecutor understands. + + Responsibilities: + - Filter messages by participant name (ignores requests for other participants) + - Extract conversation history from the request envelope + - Append manager instructions as a user message when present + - Forward the formatted request to AgentExecutor + + Pipeline position: orchestrator -> ingress -> AgentExecutor -> egress -> orchestrator + + Why this adapter exists: + The orchestrator operates on a broadcast model where all participants receive + GroupChatRequestMessage envelopes, but each ingress filters for its specific + agent_name. This keeps routing logic simple and makes the graph structure explicit. + + Args: + agent_name: Unique name of the participant this ingress serves + """ + + def __init__(self, agent_name: str) -> None: + super().__init__(f"groupchat_ingress:{agent_name}") + self._agent_name = agent_name + + @handler + async def handle_request( + self, + message: GroupChatRequestMessage, + ctx: WorkflowContext[AgentExecutorRequest], + ) -> None: + """Process GroupChatRequestMessage and forward to AgentExecutor if targeted. + + Args: + message: Request envelope from the orchestrator + ctx: Workflow context for sending the transformed request + + Behavior: + - Silently ignores messages not addressed to this participant + - Clones conversation to avoid shared state mutation + - Appends manager instruction as USER message if provided + - Always sets should_respond=True to ensure agent produces output + """ + if message.agent_name != self._agent_name: + return + conversation = list(message.conversation) + if message.instruction: + conversation.append(ChatMessage(role=Role.USER, text=message.instruction)) + await ctx.send_message(AgentExecutorRequest(messages=conversation, should_respond=True)) + + +class _GroupChatAgentEgress(Executor): + """Adapter that converts agent responses into orchestrator-compatible response envelopes. + + This internal executor sits at the exit point of each agent participant's pipeline, + translating AgentExecutorResponse into GroupChatResponseMessage format that the + orchestrator expects. + + Responsibilities: + - Extract the final assistant message from the agent's response + - Ensure author_name is populated for conversation tracking + - Wrap the message in a GroupChatResponseMessage envelope + - Send the envelope back to the orchestrator + + Pipeline position: orchestrator -> ingress -> AgentExecutor -> egress -> orchestrator + + Why this adapter exists: + AgentExecutorResponse contains rich metadata (full_conversation, streaming events) + but the orchestrator only needs the final assistant message. The egress adapter + normalizes this and ensures consistent author attribution for multi-agent tracking. + + Args: + agent_name: Unique name of the participant this egress serves + """ + + def __init__(self, agent_name: str) -> None: + super().__init__(f"groupchat_egress:{agent_name}") + self._agent_name = agent_name + + @handler + async def handle_response( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[GroupChatResponseMessage], + ) -> None: + """Extract final assistant message and send to orchestrator as response envelope. + + Args: + response: Response from AgentExecutor containing agent output + ctx: Workflow context for sending the response envelope + + Behavior: + - Searches agent_run_response.messages first, then full_conversation + - Scans backwards to find the most recent ASSISTANT role message + - Creates empty assistant message if no output found (defensive) + - Populates author_name if missing to preserve conversation attribution + - Wraps message in GroupChatResponseMessage for orchestrator routing + """ + # Prefer the final assistant message from the agent run. + final_message: ChatMessage | None = None + candidate_sequences: tuple[Sequence[ChatMessage] | None, ...] = ( + response.agent_run_response.messages, + response.full_conversation, + ) + for sequence in candidate_sequences: + if not sequence: + continue + for candidate in reversed(sequence): + if getattr(candidate, "role", None) == Role.ASSISTANT: + final_message = candidate + break + if final_message is not None: + break + + if final_message is None: + final_message = ChatMessage(role=Role.ASSISTANT, text="", author_name=self._agent_name) + elif not final_message.author_name: + message_dict = final_message.to_dict() + message_dict["author_name"] = self._agent_name + final_message = ChatMessage.from_dict(message_dict) + + await ctx.send_message( + GroupChatResponseMessage( + agent_name=self._agent_name, + message=final_message, + ) + ) + + +def _default_participant_factory( + spec: GroupChatParticipantSpec, + _: GroupChatWiring, +) -> GroupChatParticipantNodes: + """Default factory for constructing participant pipeline nodes in the workflow graph. + + Creates a three-node pipeline for AgentProtocol participants (ingress -> executor -> egress) + or a single-node passthrough for Executor participants. + + This is the internal implementation used by GroupChatBuilder when no custom factory + is provided. It wires agents with the standard adapters that handle protocol translation + between the orchestrator's envelope format and AgentExecutor's request/response format. + + Args: + spec: Participant specification containing name, instance, and description + _: GroupChatWiring configuration (unused by default implementation) + + Returns: + GroupChatParticipantNodes with entry/exit executors and optional intermediates + + Behavior for AgentProtocol participants: + - Creates _GroupChatAgentIngress to translate orchestrator requests + - Wraps agent in AgentExecutor for streaming and observability + - Creates _GroupChatAgentEgress to translate agent responses + - Returns three-node pipeline: ingress -> executor -> egress + + Behavior for Executor participants: + - Assumes executor handles GroupChatRequestMessage directly + - Returns executor as both entry and exit (single node, no adapters) + - Expects executor to emit GroupChatResponseMessage + + Pipeline topology (agent case): + orchestrator --GroupChatRequestMessage--> ingress + ingress --AgentExecutorRequest--> agent_executor + agent_executor --AgentExecutorResponse--> egress + egress --GroupChatResponseMessage--> orchestrator + """ + participant = spec.participant + if isinstance(participant, Executor): + return GroupChatParticipantNodes(entry=participant, exit=participant) + + agent = participant + ingress = _GroupChatAgentIngress(spec.name) + agent_executor = AgentExecutor(agent, id=f"groupchat_agent:{spec.name}") + egress = _GroupChatAgentEgress(spec.name) + return GroupChatParticipantNodes(entry=ingress, exit=egress, intermediates=[agent_executor]) + + +# endregion + + +# region Default orchestrator + + +class GroupChatOrchestratorExecutor(Executor): + """Default orchestrator executor that implements manager-directed group chat coordination. + + This is the central runtime state machine that drives multi-agent conversations. It + maintains conversation state, delegates speaker selection to a manager, routes messages + to participants, and collects responses in a loop until the manager signals completion. + + Core responsibilities: + - Accept initial input as str, ChatMessage, or list[ChatMessage] + - Maintain conversation history and turn tracking + - Query manager for next action (select participant or finish) + - Route requests to selected participants via GroupChatRequestMessage + - Collect participant responses and append to conversation + - Enforce optional round limits to prevent infinite loops + - Yield final completion message and transition to idle state + + State management: + - _conversation: Growing list of all messages (user, manager, agents) + - _history: Turn-by-turn record with speaker attribution and roles + - _task_message: Original user task extracted from input + - _pending_agent: Name of agent currently processing a request + - _round_index: Count of manager selection rounds for limit enforcement + + Manager interaction: + The orchestrator builds GroupChatState snapshots and passes them to the manager's + next_action() method. The manager returns a GroupChatDirective indicating either: + - Next participant to speak (with optional instruction) + - Finish signal (with optional final message) + + Message flow topology: + User input -> orchestrator -> manager -> orchestrator -> participant -> orchestrator + (loops until manager returns finish directive) + + Why this design: + - Separates orchestration logic (this class) from selection logic (manager) + - Manager is stateless and testable in isolation + - Orchestrator handles all state mutations and message routing + - Broadcast routing to participants keeps graph structure simple + + Args: + manager: Manager instance implementing next_action() for speaker selection + participants: Mapping of participant names to descriptions (for manager context) + manager_name: Display name for manager in conversation history + max_rounds: Optional limit on manager selection rounds (None = unlimited) + executor_id: Optional custom ID for observability (auto-generated if not provided) + """ + + def __init__( + self, + manager: GroupChatManagerProtocol, + *, + participants: Mapping[str, str], + manager_name: str, + max_rounds: int | None = None, + executor_id: str | None = None, + ) -> None: + super().__init__(executor_id or f"groupchat_orchestrator_{uuid4().hex[:8]}") + self._manager = manager + self._participants = dict(participants) + self._manager_name = manager_name + self._conversation: list[ChatMessage] = [] + self._history: list[GroupChatTurn] = [] + self._task_message: ChatMessage | None = None + self._pending_agent: str | None = None + self._round_index = 0 + self._max_rounds = max_rounds + self._pending_initial_conversation: list[ChatMessage] | None = None + + @staticmethod + def _select_task_message(conversation: Sequence[ChatMessage]) -> ChatMessage: + """Extract the primary user task message from a conversation history. + + Scans backwards through the conversation to find the most recent USER role message, + which is treated as the main task description. Falls back to the last message if + no user message is found. + + Args: + conversation: Sequence of chat messages (may include system, user, assistant) + + Returns: + The task message to provide to the manager for context + + Usage: + Called when workflow receives a list[ChatMessage] as initial input to identify + which message represents the user's task request. + """ + for msg in reversed(conversation): + role_value = getattr(msg.role, "value", None) or str(msg.role) + if str(role_value).lower() == Role.USER.value: + return msg + return conversation[-1] + + @staticmethod + def _role_value(message: ChatMessage) -> str: + """Extract string role value from a ChatMessage, handling enum and string cases. + + Args: + message: Chat message with role attribute (may be enum or string) + + Returns: + String representation of the role (e.g., "user", "assistant", "system") + + Why this exists: + Different ChatMessage implementations may use Role enum or plain strings. + This normalizes access for consistent turn tracking. + """ + role = getattr(message.role, "value", None) or str(message.role) + return str(role) + + def _build_state(self) -> GroupChatState: + """Build a snapshot of current orchestration state for the manager. + + Packages conversation history, participant metadata, and round tracking into + a GroupChatState that the manager uses to make speaker selection decisions. + + Returns: + GroupChatState containing all context needed for manager decision-making + + Raises: + RuntimeError: If called before task message initialization (defensive check) + + When this is called: + - After initial input is processed (first manager query) + - After each participant response (subsequent manager queries) + """ + if self._task_message is None: + raise RuntimeError("GroupChatOrchestratorExecutor state not initialized with task message.") + return GroupChatState( + task=self._task_message, + participants=self._participants, + conversation=tuple(self._conversation), + history=tuple(self._history), + pending_agent=self._pending_agent, + round_index=self._round_index, + ) + + async def _apply_directive( + self, + directive: GroupChatDirective, + ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ) -> None: + """Execute a manager directive by either finishing the workflow or routing to a participant. + + This is the core routing logic that interprets manager decisions. It handles two cases: + 1. Finish directive: append final message, update state, yield output, become idle + 2. Agent selection: build request envelope, route to participant, increment round counter + + Args: + directive: Manager's decision (finish or select next participant) + ctx: Workflow context for sending messages and yielding output + + Behavior for finish directive: + - Uses provided final_message or creates default completion message + - Ensures author_name is set to manager for attribution + - Appends to conversation and history for complete record + - Yields message as workflow output + - Orchestrator becomes idle (no further processing) + + Behavior for agent selection: + - Validates agent_name exists in participants + - Optionally appends manager instruction as USER message + - Builds GroupChatRequestMessage with full conversation context + - Sends request to workflow (participant ingress filters for agent_name) + - Increments round counter and enforces max_rounds if configured + + Round limit enforcement: + If max_rounds is reached, recursively calls _apply_directive with a finish + directive to gracefully terminate the conversation. + + Raises: + ValueError: If directive lacks agent_name when finish=False, or if + agent_name doesn't match any participant + """ + if directive.finish: + final_message = directive.final_message + if final_message is None: + final_message = ChatMessage( + role=Role.ASSISTANT, + text="Completed without final summary.", + author_name=self._manager_name, + ) + elif not final_message.author_name: + message_dict = final_message.to_dict() + message_dict["author_name"] = self._manager_name + final_message = ChatMessage.from_dict(message_dict) + + self._conversation.append(final_message) + self._history.append(GroupChatTurn(self._manager_name, "manager", final_message)) + self._pending_agent = None + await ctx.yield_output(final_message) + return + + agent_name = directive.agent_name + if not agent_name: + raise ValueError("Directive must include agent_name when finish is False.") + if agent_name not in self._participants: + raise ValueError(f"Manager selected unknown participant '{agent_name}'.") + + instruction = directive.instruction or "" + conversation = list(self._conversation) + if instruction: + manager_message = ChatMessage( + role=Role.USER, + text=instruction, + author_name=self._manager_name, + ) + conversation.append(manager_message) + self._conversation.append(manager_message) + self._history.append(GroupChatTurn(self._manager_name, "manager", manager_message)) + + request = GroupChatRequestMessage( + agent_name=agent_name, + conversation=conversation, + task=self._task_message, + metadata=dict(directive.metadata or {}), + ) + self._pending_agent = agent_name + self._round_index += 1 + await ctx.send_message(request) + + if self._max_rounds is not None and self._round_index >= self._max_rounds: + logger.warning( + "GroupChatOrchestratorExecutor reached max_rounds=%s; forcing completion.", + self._max_rounds, + ) + await self._apply_directive( + GroupChatDirective( + finish=True, + final_message=ChatMessage( + role=Role.ASSISTANT, + text="Conversation halted after reaching manager round limit.", + author_name=self._manager_name, + ), + ), + ctx, + ) + + async def _handle_task_message( + self, + task_message: ChatMessage, + ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ) -> None: + """Initialize orchestrator state and start the manager-directed conversation loop. + + This internal method is called by all public handlers (str, ChatMessage, list[ChatMessage]) + after normalizing their input. It initializes conversation state, queries the manager + for the first action, and applies the resulting directive. + + Args: + task_message: The primary user task message (extracted or provided directly) + ctx: Workflow context for sending messages and yielding output + + Behavior: + - Sets task_message for manager context + - Initializes conversation from pending_initial_conversation if present + - Otherwise starts fresh with just the task message + - Builds turn history with speaker attribution + - Resets pending_agent and round_index + - Queries manager for first action + - Applies directive to start the conversation loop + + State initialization: + - _conversation: Full message list for context + - _history: Turn-by-turn record with speaker names and roles + - _pending_agent: None (no active request) + - _round_index: 0 (first manager query) + + Why pending_initial_conversation exists: + The handle_conversation handler receives a list[ChatMessage] and needs to + extract the task message before calling this method. The full list is stashed + in _pending_initial_conversation to preserve all context when initializing state. + """ + self._task_message = task_message + if self._pending_initial_conversation: + initial_conversation = list(self._pending_initial_conversation) + self._pending_initial_conversation = None + self._conversation = initial_conversation + self._history = [ + GroupChatTurn( + msg.author_name or self._role_value(msg), + self._role_value(msg), + msg, + ) + for msg in initial_conversation + ] + else: + self._conversation = [task_message] + self._history = [GroupChatTurn("user", "user", task_message)] + self._pending_agent = None + self._round_index = 0 + directive = await self._manager.next_action(self._build_state()) + await self._apply_directive(directive, ctx) + + @handler + async def handle_str( + self, + task: str, + ctx: WorkflowContext[GroupChatRequestMessage, 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_task_message(ChatMessage(role=Role.USER, text=task), ctx) + + @handler + async def handle_chat_message( + self, + task_message: ChatMessage, + ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ) -> None: + """Handler for ChatMessage input as workflow entry point. + + Directly delegates to _handle_task_message for state initialization. + + Args: + task_message: Structured chat message from user (may include metadata, role, etc.) + ctx: Workflow context + + Usage: + workflow.run(ChatMessage(role=Role.USER, text="Analyze this data")) + """ + await self._handle_task_message(task_message, ctx) + + @handler + async def handle_conversation( + self, + conversation: list[ChatMessage], + ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ) -> None: + """Handler for conversation history as workflow entry point. + + Accepts a pre-existing conversation and extracts the primary task message. + Preserves the full conversation for state initialization. + + Args: + conversation: List of chat messages (system, user, assistant) + ctx: Workflow context + + Raises: + ValueError: If conversation list is empty + + Behavior: + - Validates conversation is non-empty + - Clones conversation to avoid mutation + - Extracts task message (most recent USER message) + - Stashes full conversation in _pending_initial_conversation + - Delegates to _handle_task_message for initialization + + Usage: + existing_messages = [ + ChatMessage(role=Role.SYSTEM, text="You are an expert"), + ChatMessage(role=Role.USER, text="Help me with this task") + ] + workflow.run(existing_messages) + """ + if not conversation: + raise ValueError("GroupChat workflow requires at least one chat message.") + self._pending_initial_conversation = list(conversation) + task_message = self._select_task_message(conversation) + await self._handle_task_message(task_message, ctx) + + @handler + async def handle_agent_response( + self, + response: GroupChatResponseMessage, + ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ) -> None: + """Handler for participant responses returning to the orchestrator. + + This is the completion point of the participant->orchestrator loop. After a + participant processes a request and returns a response, this handler updates + orchestrator state and queries the manager for the next action. + + Args: + response: Response envelope from participant egress + ctx: Workflow context + + Behavior: + - Validates agent_name matches a known participant (defensive) + - Ensures message has author_name for conversation attribution + - Appends message to conversation history + - Records turn in history with agent name and role + - Clears pending_agent (request fulfilled) + - Checks if max_rounds reached (yields completion if so) + - Queries manager for next action + - Applies directive to continue or finish + + Round limit handling: + If max_rounds is reached after receiving a response, yields a default + completion message instead of querying the manager. This prevents the + manager from selecting another participant when the limit is exhausted. + + Defensive behavior: + Silently ignores responses from unknown participants (shouldn't happen + in normal operation, but protects against graph misconfiguration). + """ + agent_name = response.agent_name + if agent_name not in self._participants: + logger.debug("Ignoring response from unknown participant '%s'.", agent_name) + return + + message = response.message + if not message.author_name: + message_dict = message.to_dict() + message_dict["author_name"] = agent_name + message = ChatMessage.from_dict(message_dict) + + self._conversation.append(message) + self._history.append(GroupChatTurn(agent_name, "agent", message)) + self._pending_agent = None + + if self._max_rounds is not None and self._round_index >= self._max_rounds: + logger.warning( + "GroupChatOrchestratorExecutor reached max_rounds=%s after receiving agent response.", + self._max_rounds, + ) + await ctx.yield_output( + ChatMessage( + role=Role.ASSISTANT, + text="Conversation halted after reaching manager round limit.", + author_name=self._manager_name, + ) + ) + return + + directive = await self._manager.next_action(self._build_state()) + await self._apply_directive(directive, ctx) + + +def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: + """Default factory for creating the GroupChatOrchestratorExecutor instance. + + This is the internal implementation used by GroupChatBuilder to instantiate the + orchestrator. It extracts participant descriptions from the wiring configuration + and passes them to the orchestrator for manager context. + + Args: + wiring: Complete workflow configuration assembled by the builder + + Returns: + Initialized GroupChatOrchestratorExecutor ready to coordinate the conversation + + Behavior: + - Extracts participant names and descriptions for manager context + - Forwards manager instance, manager name, and max_rounds settings + - Allows orchestrator to auto-generate its executor ID + + Why descriptions are extracted: + The manager needs participant descriptions (not full specs) to make informed + selection decisions. The orchestrator doesn't need participant instances directly + since routing is handled by the workflow graph. + """ + return GroupChatOrchestratorExecutor( + manager=wiring.manager, + participants={name: spec.description for name, spec in wiring.participants.items()}, + manager_name=wiring.manager_name, + max_rounds=wiring.max_rounds, + ) + + +# endregion + + +# region Builder + + +class GroupChatBuilder: + r"""High-level builder for manager-directed group chat workflows with dynamic orchestration. + + - `set_manager(...)` configures the orchestration manager (required) + - `participants({...})` accepts a mapping of named AgentProtocol or Executor instances + - The workflow wires an orchestrator that delegates speaker selection to the manager + - Agents are automatically wrapped as AgentExecutor for consistent observability + - The manager receives conversation state and returns directives (next speaker or finish) + - The final output is the manager's completion message when the task is finished + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder, StandardGroupChatManager + + manager = StandardGroupChatManager(chat_client) + workflow = ( + GroupChatBuilder().set_manager(manager).participants(writer=writer_agent, reviewer=reviewer_agent).build() + ) + + # Enable checkpoint persistence + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants({"analyst": analyst_agent, "coder": coder_agent}) + .with_checkpointing(storage) + .build() + ) + + # Limit conversation rounds + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants(agent1=agent1, agent2=agent2) + .with_max_rounds(10) + .build() + ) + """ + + def __init__( + self, + *, + _orchestrator_factory: Callable[[GroupChatWiring], Executor] | None = None, + _participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantNodes] + | None = None, + ) -> None: + """Initialize the GroupChatBuilder. + + Args: + _orchestrator_factory: Internal extension point for custom orchestrator implementations. + Used by Magentic. Not part of public API - subject to change. + _participant_factory: Internal extension point for custom participant pipelines. + Used by Magentic. Not part of public API - subject to change. + """ + self._participants: dict[str, AgentProtocol | Executor] = {} + self._participant_descriptions: dict[str, str] = {} + self._manager: GroupChatManagerProtocol | None = None + self._manager_name: str = "manager" + self._checkpoint_storage: CheckpointStorage | None = None + self._max_rounds: int | None = None + self._request_handler: tuple[Executor, Callable[[Any], bool]] | None = None + self._orchestrator_factory = _orchestrator_factory or _default_orchestrator_factory + self._participant_factory = _participant_factory or _default_participant_factory + + def set_manager(self, manager: GroupChatManagerProtocol, *, display_name: str | None = None) -> "GroupChatBuilder": + """Configure the orchestration manager that selects participants and completes tasks. + + The manager receives conversation state and returns directives indicating which + participant should speak next or whether the task is complete. + + Args: + manager: Implementation of GroupChatManagerProtocol for orchestration logic + display_name: Optional custom name for the manager in conversation history + + Returns: + Self for fluent chaining + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder, StandardGroupChatManager + + manager = StandardGroupChatManager(chat_client, instructions="Custom instructions") + workflow = GroupChatBuilder().set_manager(manager, display_name="coordinator").build() + """ + self._manager = manager + resolved_name = display_name or getattr(manager, "name", None) or "manager" + self._manager_name = resolved_name + return self + + def participants( + self, + participants: Mapping[str, AgentProtocol | Executor] | None = None, + /, + **named_participants: AgentProtocol | Executor, + ) -> "GroupChatBuilder": + """Define the named participants for this group chat workflow. + + Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances. + Participant names must be unique and non-empty. The manager uses these names when + selecting the next speaker. + + Args: + participants: Optional mapping of participant names to agent/executor instances + **named_participants: Keyword arguments mapping names to agent/executor instances + + Returns: + Self for fluent chaining + + Raises: + ValueError: If participants are empty, names are duplicated, or names are empty strings + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder + + # Using keyword arguments + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants(writer=writer_agent, editor=editor_agent, reviewer=reviewer_agent) + .build() + ) + + # Using dictionary + participants_dict = {"analyst": analyst_agent, "coder": coder_agent} + workflow = GroupChatBuilder().set_manager(manager).participants(participants_dict).build() + + # Combining both approaches + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants({"agent1": agent1}, agent2=agent2, agent3=agent3) + .build() + ) + """ + combined: dict[str, AgentProtocol | Executor] = {} + if participants: + combined.update(participants) + combined.update(named_participants) + + if not combined: + raise ValueError("participants cannot be empty") + + for name, participant in combined.items(): + if not name: + raise ValueError("participant names must be non-empty strings") + if name in self._participants: + raise ValueError(f"Duplicate participant name '{name}' supplied.") + self._participants[name] = participant + description = "" + if isinstance(participant, Executor): + description = participant.id + else: + description = getattr(participant, "description", None) or participant.__class__.__name__ + self._participant_descriptions[name] = description + return self + + def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupChatBuilder": + """Enable checkpointing for the built workflow using the provided storage. + + Checkpointing allows the workflow to persist state and resume from interruption + points, enabling long-running conversations and failure recovery. + + Args: + checkpoint_storage: Storage implementation for persisting workflow state + + Returns: + Self for fluent chaining + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder, MemoryCheckpointStorage + + storage = MemoryCheckpointStorage() + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants(agent1=agent1, agent2=agent2) + .with_checkpointing(storage) + .build() + ) + """ + self._checkpoint_storage = checkpoint_storage + return self + + def with_request_handler( + self, + executor: Executor, + *, + condition: Callable[[Any], bool], + ) -> "GroupChatBuilder": + """Register an executor that intercepts and handles special orchestrator requests. + + This advanced feature allows custom executors to process specific messages + emitted by the orchestrator before they reach participants. Useful for + implementing plan review, validation gates, or custom routing logic. + + Args: + executor: Executor instance that handles intercepted requests + condition: Callable that returns True for messages this executor should handle + + Returns: + Self for fluent chaining + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder, Executor + + + def is_plan_review(msg: Any) -> bool: + return isinstance(msg, dict) and msg.get("type") == "plan_review" + + + review_executor = PlanReviewExecutor() + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants(agent1=agent1) + .with_request_handler(review_executor, condition=is_plan_review) + .build() + ) + """ + self._request_handler = (executor, condition) + return self + + def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": + """Set a maximum number of manager rounds to prevent infinite conversations. + + When the round limit is reached, the workflow automatically completes with + a default completion message. Setting to None allows unlimited rounds. + + Args: + max_rounds: Maximum number of manager selection rounds, or None for unlimited + + Returns: + Self for fluent chaining + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder + + # Limit to 15 rounds + workflow = ( + GroupChatBuilder() + .set_manager(manager) + .participants(agent1=agent1, agent2=agent2) + .with_max_rounds(15) + .build() + ) + + # Unlimited rounds + workflow = GroupChatBuilder().set_manager(manager).participants(agent1=agent1).with_max_rounds(None).build() + """ + self._max_rounds = max_rounds + return self + + def _build_participant_specs(self) -> dict[str, GroupChatParticipantSpec]: + specs: dict[str, GroupChatParticipantSpec] = {} + for name, participant in self._participants.items(): + specs[name] = GroupChatParticipantSpec( + name=name, + participant=participant, + description=self._participant_descriptions[name], + ) + return specs + + def build(self) -> Workflow: + """Build and validate the group chat workflow. + + Assembles the orchestrator, participants, and their interconnections into + a complete workflow graph. The orchestrator delegates speaker selection to + the manager, routes requests to the appropriate participants, and collects + their responses to continue or complete the conversation. + + Returns: + Validated Workflow instance ready for execution + + Raises: + ValueError: If manager or participants are not configured + + Wiring pattern: + - Orchestrator receives initial input (str, ChatMessage, or list[ChatMessage]) + - Orchestrator queries manager for next action (participant selection or finish) + - If participant selected: request routed to participant entry node + - Participant pipeline: ingress -> (agent executor) -> egress + - Egress sends response back to orchestrator + - Orchestrator updates state and queries manager again + - When manager returns finish directive: orchestrator yields final message and becomes idle + + Usage: + + .. code-block:: python + + from agent_framework import GroupChatBuilder, StandardGroupChatManager + + manager = StandardGroupChatManager(chat_client) + workflow = GroupChatBuilder().set_manager(manager).participants(agent1=agent1, agent2=agent2).build() + + # Execute the workflow + async for message in workflow.run("Solve this problem collaboratively"): + print(message.text) + """ + if self._manager is None: + raise ValueError("manager must be configured before build()") + if not self._participants: + raise ValueError("participants must be configured before build()") + + participant_specs = self._build_participant_specs() + wiring = GroupChatWiring( + manager=self._manager, + manager_name=self._manager_name, + participants=participant_specs, + max_rounds=self._max_rounds, + ) + + orchestrator = self._orchestrator_factory(wiring) + wiring.orchestrator = orchestrator + + workflow_builder = WorkflowBuilder().set_start_executor(orchestrator) + + for name, spec in participant_specs.items(): + nodes = self._participant_factory(spec, wiring) + chain: list[Executor] = [nodes.entry, *nodes.intermediates, nodes.exit] + target_name = name + + def _route(msg: Any, expected: str = target_name) -> bool: + return isinstance(msg, GroupChatRequestMessage) and msg.agent_name == expected + + workflow_builder = workflow_builder.add_edge(orchestrator, nodes.entry, condition=_route) + for upstream, downstream in itertools.pairwise(chain): + workflow_builder = workflow_builder.add_edge(upstream, downstream) + if nodes.exit is not orchestrator: + workflow_builder = workflow_builder.add_edge(nodes.exit, orchestrator) + + if self._request_handler is not None: + handler_executor, condition = self._request_handler + workflow_builder = workflow_builder.add_edge(orchestrator, handler_executor, condition=condition) + workflow_builder = workflow_builder.add_edge(handler_executor, orchestrator) + + if self._checkpoint_storage is not None: + workflow_builder = workflow_builder.with_checkpointing(self._checkpoint_storage) + + return workflow_builder.build() + + +# endregion + + +# region Default manager implementation + + +class _ManagerDirectiveModel(BaseModel): + """Pydantic model for structured output from LLM manager decisions. + + Defines the JSON schema that StandardGroupChatManager expects from the LLM's + response_format output. This ensures type-safe parsing and validation of manager + directives. + + Attributes: + next_agent: Name of participant to speak next (null when finishing) + message: Optional instruction for the selected participant + finish: Boolean indicating if the task is complete + final_response: Final answer to the user (only when finish=True) + + Usage: + The LLM is prompted to return this exact structure via structured output, + which is then parsed and converted to GroupChatDirective for orchestrator routing. + """ + + next_agent: str | None = None + message: str | None = None + finish: bool = False + final_response: str | None = None + + +DEFAULT_MANAGER_INSTRUCTIONS = """You are coordinating a team conversation to solve the user's task. +Select the next participant to respond or finish the task. When selecting an agent you MUST return +the JSON fields: +- next_agent: name of the participant who should act next (use null when finish is true) +- message: instruction for that participant (empty string if not needed) +- finish: boolean indicating if the task is complete +- final_response: when finish is true, provide the final answer to the user +""" + + +class StandardGroupChatManager(GroupChatManagerProtocol): + """LLM-backed manager that produces directives via structured output. + + This is the default manager implementation for group chat workflows. It uses an LLM + to make speaker selection decisions based on conversation state, participant + descriptions, and custom instructions. + + Coordination strategy: + - Receives GroupChatState snapshot with full conversation history + - Formats system prompt with instructions, task, and participant descriptions + - Appends conversation context and structured output prompt + - Calls LLM with response_format=_ManagerDirectiveModel for type safety + - Parses LLM response and converts to GroupChatDirective + + Flexibility: + - Custom instructions allow domain-specific coordination strategies + - Participant descriptions guide the LLM's selection logic + - Structured output ensures reliable parsing (no regex or brittle prompts) + + Example coordination patterns: + - Round-robin: "Rotate between participants in order" + - Task-based: "Select the participant best suited for the current sub-task" + - Dependency-aware: "Only call analyst after researcher provides data" + + Args: + chat_client: ChatClientProtocol implementation for LLM inference + instructions: Custom system instructions (defaults to DEFAULT_MANAGER_INSTRUCTIONS) + name: Display name for the manager in conversation history + + Raises: + RuntimeError: If LLM response cannot be parsed into _ManagerDirectiveModel + If directive is missing next_agent when finish=False + If selected agent is not in participants + """ + + def __init__( + self, + chat_client: ChatClientProtocol, + *, + instructions: str | None = None, + name: str | None = None, + ) -> None: + self._chat_client = chat_client + self._instructions = instructions or DEFAULT_MANAGER_INSTRUCTIONS + self._name = name or "GroupChatManager" + + @property + def name(self) -> str: + return self._name + + async def next_action(self, state: GroupChatState) -> GroupChatDirective: + participants_section = "\n".join( + f"- {agent}: {description}" for agent, description in state.participants.items() + ) + + system_message = ChatMessage( + role=Role.SYSTEM, + text=(f"{self._instructions}\n\nTask:\n{state.task.text}\n\nParticipants:\n{participants_section}"), + ) + + messages: list[ChatMessage] = [system_message, *state.conversation] + messages.append( + ChatMessage( + role=Role.USER, + text=( + "Return a JSON object with keys (next_agent, message, finish, final_response). " + "If you decide to finish, next_agent must be null." + ), + ) + ) + + try: + response = await self._chat_client.get_response( + messages, + response_format=_ManagerDirectiveModel, + ) + directive_obj: _ManagerDirectiveModel + if response.value is not None: + directive_obj = _ManagerDirectiveModel.model_validate(response.value) + elif response.messages: + payload = response.messages[-1].text or "{}" + directive_obj = _ManagerDirectiveModel.model_validate_json(payload) + else: + raise RuntimeError("LLM response did not contain structured output.") + except (ValidationError, json.JSONDecodeError) as exc: + logger.error("Failed to parse manager directive: %s", exc) + raise RuntimeError("Unable to parse manager directive from chat client response.") from exc + + if directive_obj.finish: + final_text = directive_obj.final_response or "" + return GroupChatDirective( + finish=True, + final_message=ChatMessage( + role=Role.ASSISTANT, + text=final_text, + author_name=self._name, + ), + ) + + next_agent = directive_obj.next_agent + if not next_agent: + raise RuntimeError("Manager directive missing next_agent while finish is False.") + if next_agent not in state.participants: + raise RuntimeError(f"Manager selected unknown participant '{next_agent}'.") + + return GroupChatDirective( + agent_name=next_agent, + instruction=directive_obj.message or "", + ) + + +# endregion diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 3ee1c10690f..e12027dd680 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -7,7 +7,7 @@ import re import sys from abc import ABC, abstractmethod -from collections.abc import AsyncIterable, Awaitable, Callable +from collections.abc import AsyncIterable, Awaitable, Callable, Sequence from dataclasses import dataclass, field from enum import Enum from typing import Any, Literal, Protocol, TypeVar, Union, cast @@ -28,9 +28,17 @@ from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._events import WorkflowEvent from ._executor import Executor, handler +from ._group_chat import ( + GroupChatBuilder, + GroupChatParticipantNodes, + GroupChatParticipantSpec, + GroupChatRequestMessage, + GroupChatResponseMessage, + GroupChatWiring, +) from ._model_utils import DictConvertible, encode_value -from ._request_info_executor import RequestInfoMessage, RequestResponse -from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult +from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse +from ._workflow import Workflow, WorkflowRunResult from ._workflow_context import WorkflowContext if sys.version_info >= (3, 11): @@ -90,51 +98,50 @@ def _message_from_payload(payload: Any) -> ChatMessage: # region Unified callback API (developer-facing) -class MagenticCallbackMode(str, Enum): - """Controls whether agent deltas are surfaced via on_event. - - STREAMING: emit AgentDeltaEvent chunks and a final AgentMessageEvent. - NON_STREAMING: suppress deltas and only emit AgentMessageEvent. - """ - - STREAMING = "streaming" - NON_STREAMING = "non_streaming" - - @dataclass -class MagenticOrchestratorMessageEvent: - source: Literal["orchestrator"] = "orchestrator" +class MagenticOrchestratorMessageEvent(WorkflowEvent): orchestrator_id: str = "" message: ChatMessage | None = None - # Kind values include: user_task, task_ledger, instruction, notice kind: str = "" + source: Literal["orchestrator"] = field(init=False, default="orchestrator") + + def __post_init__(self) -> None: + super().__init__(data=self.message) @dataclass -class MagenticAgentDeltaEvent: - source: Literal["agent"] = "agent" +class MagenticAgentDeltaEvent(WorkflowEvent): agent_id: str | None = None text: str | None = None - # Optional: function/tool streaming payloads function_call_id: str | None = None function_call_name: str | None = None function_call_arguments: Any | None = None function_result_id: str | None = None function_result: Any | None = None role: Role | None = None + source: Literal["agent"] = field(init=False, default="agent") + + def __post_init__(self) -> None: + super().__init__(data=self.text) @dataclass -class MagenticAgentMessageEvent: - source: Literal["agent"] = "agent" +class MagenticAgentMessageEvent(WorkflowEvent): agent_id: str = "" message: ChatMessage | None = None + source: Literal["agent"] = field(init=False, default="agent") + + def __post_init__(self) -> None: + super().__init__(data=self.message) @dataclass -class MagenticFinalResultEvent: - source: Literal["workflow"] = "workflow" +class MagenticFinalResultEvent(WorkflowEvent): message: ChatMessage | None = None + source: Literal["workflow"] = field(init=False, default="workflow") + + def __post_init__(self) -> None: + super().__init__(data=self.message) MagenticCallbackEvent = Union[ @@ -347,16 +354,34 @@ def from_dict(cls, value: dict[str, Any]) -> "MagenticStartMessage": return cls(task=task) -@dataclass -class MagenticRequestMessage: +@dataclass(slots=True, init=False) +class MagenticRequestMessage(GroupChatRequestMessage): """A request message type for agents in a magentic workflow.""" - agent_name: str - instruction: str = "" task_context: str = "" + def __init__( + self, + *, + agent_name: str, + instruction: str = "", + task_context: str = "", + conversation: Sequence[ChatMessage] | None = None, + task: ChatMessage | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + GroupChatRequestMessage.__init__( + self, + agent_name=agent_name, + conversation=list(conversation or []), + instruction=instruction, + task=task, + metadata=metadata, + ) + self.task_context = task_context + -class MagenticResponseMessage: +class MagenticResponseMessage(GroupChatResponseMessage): """A response message type. When emitted by the orchestrator you can mark it as a broadcast to all agents, @@ -369,9 +394,14 @@ def __init__( target_agent: str | None = None, # deliver only to this agent if set broadcast: bool = False, # deliver to all agents if True ) -> None: + agent_name = body.author_name or "" + super().__init__( + agent_name=agent_name, + message=body, + target_agent=target_agent, + broadcast=broadcast, + ) self.body = body - self.target_agent = target_agent - self.broadcast = broadcast def to_dict(self) -> dict[str, Any]: """Create a dict representation of the message.""" @@ -978,11 +1008,36 @@ def __init__( self._terminated = False # Tracks whether checkpoint state has been applied for this run self._state_restored = False + self._initial_history: list[ChatMessage] | None = None + + @staticmethod + def _select_task_message(conversation: Sequence[ChatMessage]) -> ChatMessage: + for msg in reversed(conversation): + role_value = getattr(msg.role, "value", None) or str(msg.role) + if str(role_value).lower() == Role.USER.value: + return msg + return conversation[-1] def register_agent_executor(self, name: str, executor: "MagenticAgentExecutor") -> None: """Register an agent executor for internal control (no messages).""" self._agent_executors[name] = executor + async def _emit_orchestrator_message( + self, + ctx: WorkflowContext[Any, ChatMessage], + message: ChatMessage, + kind: str, + ) -> None: + event = MagenticOrchestratorMessageEvent( + orchestrator_id=self.id, + message=message, + kind=kind, + ) + await ctx.add_event(event) + if self._message_callback: + with contextlib.suppress(Exception): + await self._message_callback(self.id, message, kind) + def snapshot_state(self) -> dict[str, Any]: state: dict[str, Any] = { "plan_review_round": self._plan_review_round, @@ -1103,11 +1158,13 @@ async def handle_start_message( task=message.task, participant_descriptions=self._participants, ) + initial_history = self._initial_history + self._initial_history = None + if initial_history: + self._context.chat_history.extend(list(initial_history)) self._state_restored = True # Non-streaming callback for the orchestrator receipt of the task - if self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, message.task, ORCH_MSG_KIND_USER_TASK) + await self._emit_orchestrator_message(context, message.task, ORCH_MSG_KIND_USER_TASK) # Initial planning using the manager with real model calls self._task_ledger = await self._manager.plan(self._context.clone(deep=True)) @@ -1122,9 +1179,7 @@ async def handle_start_message( logger.debug("Task ledger created.") - if self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) + await self._emit_orchestrator_message(context, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) # Start the inner loop ctx2 = cast( @@ -1133,6 +1188,77 @@ async def handle_start_message( ) await self._run_inner_loop(ctx2) + @handler + async def handle_task_text( + self, + task_text: str, + context: WorkflowContext[ + MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + ], + ) -> None: + message = MagenticStartMessage.from_string(task_text) + if getattr(self, "_terminated", False): + return + logger.info("Magentic Orchestrator: Received start message") + + self._context = MagenticContext( + task=message.task, + participant_descriptions=self._participants, + ) + initial_history = self._initial_history + self._initial_history = None + if initial_history: + self._context.chat_history.extend(list(initial_history)) + self._state_restored = True + # Non-streaming callback for the orchestrator receipt of the task + await self._emit_orchestrator_message(context, message.task, ORCH_MSG_KIND_USER_TASK) + + # Initial planning using the manager with real model calls + self._task_ledger = await self._manager.plan(self._context.clone(deep=True)) + self._context.chat_history.append(self._task_ledger) + await self._emit_orchestrator_message(context, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) + + # If plan review is required, send plan review request + if self._require_plan_signoff: + plan_text = getattr(self._task_ledger, "text", "") + request = MagenticPlanReviewRequest( + task_text=message.task.text, + plan_text=plan_text, + round_index=self._plan_review_round, + ) + await context.send_message(request) + return + + # Otherwise start inner loop immediately + ctx2: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage] = cast( + WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], context + ) + await self._run_inner_loop(ctx2) + + @handler + async def handle_task_message( + self, + task_message: ChatMessage, + context: WorkflowContext[ + MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + ], + ) -> None: + await self.handle_start_message(MagenticStartMessage(task=task_message), context) + + @handler + async def handle_task_messages( + self, + conversation: list[ChatMessage], + context: WorkflowContext[ + MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + ], + ) -> None: + if not conversation: + raise ValueError("Magentic workflow requires at least one chat message.") + self._initial_history = list(conversation) + task_message = self._select_task_message(conversation) + await self.handle_task_message(task_message, context) + @handler async def handle_response_message( self, @@ -1215,9 +1341,7 @@ async def handle_plan_review_response( # Record the signed-off plan (no broadcast) if self._task_ledger: self._context.chat_history.append(self._task_ledger) - if self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) + await self._emit_orchestrator_message(context, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) # Enter the normal coordination loop ctx2 = cast( @@ -1243,9 +1367,7 @@ async def handle_plan_review_response( author_name=MAGENTIC_MANAGER_NAME, ) self._context.chat_history.append(notice) - if self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, notice, ORCH_MSG_KIND_NOTICE) + await self._emit_orchestrator_message(context, notice, ORCH_MSG_KIND_NOTICE) if self._task_ledger: self._context.chat_history.append(self._task_ledger) # No further review requests; proceed directly into coordination @@ -1300,9 +1422,8 @@ async def _run_outer_loop( self._context.chat_history.append(self._task_ledger) # Optionally surface the updated task ledger via message callback (no broadcast) - if self._task_ledger and self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) + if self._task_ledger is not None: + await self._emit_orchestrator_message(context, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) # Start inner loop await self._run_inner_loop(context) @@ -1386,10 +1507,7 @@ async def _run_inner_loop_helper( author_name=MAGENTIC_MANAGER_NAME, ) ctx.chat_history.append(instruction_msg) - # Surface instruction message to observers - if self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, instruction_msg, ORCH_MSG_KIND_INSTRUCTION) + await self._emit_orchestrator_message(context, instruction_msg, ORCH_MSG_KIND_INSTRUCTION) # Determine the selected agent's executor id target_executor_id = f"agent_{next_speaker_value}" @@ -1420,6 +1538,8 @@ async def _reset_and_replan( # Replan self._task_ledger = await self._manager.replan(self._context.clone(deep=True)) + self._context.chat_history.append(self._task_ledger) + await self._emit_orchestrator_message(context, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) # Internally reset all registered agent executors (no handler/messages involved) for agent in self._agent_executors.values(): @@ -1442,6 +1562,7 @@ async def _prepare_final_answer( # Emit a completed event for the workflow await context.yield_output(final_answer) + await context.add_event(MagenticFinalResultEvent(message=final_answer)) if self._result_callback: await self._result_callback(final_answer) @@ -1476,6 +1597,7 @@ async def _check_within_limits_or_complete( # Yield the partial result and signal completion await context.yield_output(partial_result) + await context.add_event(MagenticFinalResultEvent(message=partial_result)) if self._result_callback: await self._result_callback(partial_result) @@ -1608,7 +1730,7 @@ def _get_persona_adoption_role(self) -> Role: @handler async def handle_request_message( - self, message: MagenticRequestMessage, context: WorkflowContext[MagenticResponseMessage] + self, message: MagenticRequestMessage, context: WorkflowContext[MagenticResponseMessage, AgentRunResponse] ) -> None: """Handle request to respond.""" if message.agent_name != self._agent_id: @@ -1639,10 +1761,12 @@ async def handle_request_message( text=f"{self._agent_id} is a workflow executor and cannot be invoked directly.", author_name=self._agent_id, ) + self._chat_history.append(response) + await self._emit_agent_message_event(context, response) else: # Invoke the agent - response = await self._invoke_agent() - self._chat_history.append(response) + response = await self._invoke_agent(context) + self._chat_history.append(response) # Send response back to orchestrator await context.send_message(MagenticResponseMessage(body=response)) @@ -1655,6 +1779,7 @@ async def handle_request_message( text=f"Agent {self._agent_id}: Error processing request - {str(e)[:100]}", ) self._chat_history.append(response) + await self._emit_agent_message_event(context, response) await context.send_message(MagenticResponseMessage(body=response)) def reset(self) -> None: @@ -1663,7 +1788,55 @@ def reset(self) -> None: self._chat_history.clear() self._state_restored = True - async def _invoke_agent(self) -> ChatMessage: + async def _emit_agent_delta_event( + self, + ctx: WorkflowContext[Any, Any], + update: AgentRunResponseUpdate, + ) -> None: + contents = list(getattr(update, "contents", []) or []) + chunk = getattr(update, "text", None) + if not chunk: + chunk = "".join(getattr(item, "text", "") for item in contents if hasattr(item, "text")) + if chunk: + await ctx.add_event( + MagenticAgentDeltaEvent( + agent_id=self._agent_id, + text=chunk or None, + role=getattr(update, "role", None), + ) + ) + for item in contents: + if isinstance(item, FunctionCallContent): + await ctx.add_event( + MagenticAgentDeltaEvent( + agent_id=self._agent_id, + function_call_id=getattr(item, "call_id", None), + function_call_name=getattr(item, "name", None), + function_call_arguments=getattr(item, "arguments", None), + role=getattr(update, "role", None), + ) + ) + elif isinstance(item, FunctionResultContent): + await ctx.add_event( + MagenticAgentDeltaEvent( + agent_id=self._agent_id, + function_result_id=getattr(item, "call_id", None), + function_result=getattr(item, "result", None), + role=getattr(update, "role", None), + ) + ) + + async def _emit_agent_message_event( + self, + ctx: WorkflowContext[Any, Any], + message: ChatMessage, + ) -> None: + await ctx.add_event(MagenticAgentMessageEvent(agent_id=self._agent_id, message=message)) + + async def _invoke_agent( + self, + ctx: WorkflowContext[MagenticResponseMessage, AgentRunResponse], + ) -> ChatMessage: """Invoke the wrapped agent and return a response.""" logger.debug(f"Agent {self._agent_id}: Running with {len(self._chat_history)} messages") @@ -1672,6 +1845,7 @@ async def _invoke_agent(self) -> ChatMessage: agent = cast("AgentProtocol", self._agent) async for update in agent.run_stream(messages=self._chat_history): # type: ignore[attr-defined] updates.append(update) + await self._emit_agent_delta_event(ctx, update) if self._streaming_agent_response_callback is not None: with contextlib.suppress(Exception): await self._streaming_agent_response_callback( @@ -1695,6 +1869,7 @@ async def _invoke_agent(self) -> ChatMessage: role: Role = last.role if last.role else Role.ASSISTANT text = last.text or str(last) msg = ChatMessage(role=role, text=text, author_name=author) + await self._emit_agent_message_event(ctx, msg) if self._agent_response_callback is not None: with contextlib.suppress(Exception): await self._agent_response_callback(self._agent_id, msg) @@ -1705,6 +1880,7 @@ async def _invoke_agent(self) -> ChatMessage: text=f"Agent {self._agent_id}: No output produced", author_name=self._agent_id, ) + await self._emit_agent_message_event(ctx, msg) if self._agent_response_callback is not None: with contextlib.suppress(Exception): await self._agent_response_callback(self._agent_id, msg) @@ -1717,7 +1893,60 @@ async def _invoke_agent(self) -> ChatMessage: class MagenticBuilder: - """High-level builder for creating Magentic One workflows.""" + """Fluent builder for creating Magentic One multi-agent orchestration workflows. + + Magentic One workflows use an LLM-powered manager to coordinate multiple agents through + dynamic task planning, progress tracking, and adaptive replanning. The manager creates + plans, selects agents, monitors progress, and determines when to replan or complete. + + The builder provides a fluent API for configuring participants, the manager, optional + plan review, checkpointing, and event callbacks. + + Usage: + + .. code-block:: python + + from agent_framework import MagenticBuilder, StandardMagenticManager + from azure.ai.projects.aio import AIProjectClient + + # Create manager with LLM client + project_client = AIProjectClient.from_connection_string(...) + chat_client = project_client.inference.get_chat_completions_client() + + # Build Magentic workflow with agents + workflow = ( + MagenticBuilder() + .participants(researcher=research_agent, writer=writing_agent, coder=coding_agent) + .with_standard_manager(chat_client=chat_client, max_round_count=20, max_stall_count=3) + .with_plan_review(enable=True) + .with_checkpointing(checkpoint_storage) + .build() + ) + + # Execute workflow + async for message in workflow.run("Research and write article about AI agents"): + print(message.text) + + With custom manager: + + .. code-block:: python + + # Create custom manager subclass + class MyCustomManager(MagenticManagerBase): + async def plan(self, context: MagenticContext) -> ChatMessage: + # Custom planning logic + ... + + + manager = MyCustomManager() + workflow = MagenticBuilder().participants(agent1=agent1, agent2=agent2).with_standard_manager(manager).build() + + See Also: + - :class:`MagenticManagerBase`: Base class for custom managers + - :class:`StandardMagenticManager`: Default LLM-powered manager + - :class:`MagenticContext`: Context object passed to manager methods + - :class:`MagenticEvent`: Base class for workflow events + """ def __init__(self) -> None: self._participants: dict[str, AgentProtocol | Executor] = {} @@ -1729,23 +1958,135 @@ def __init__(self) -> None: self._agent_response_callback: Callable[[str, ChatMessage], Awaitable[None]] | None = None self._agent_streaming_callback: Callable[[str, AgentRunResponseUpdate, bool], Awaitable[None]] | None = None self._enable_plan_review: bool = False - # Unified callback wiring - self._unified_callback: CallbackSink | None = None - self._callback_mode: MagenticCallbackMode | None = None self._checkpoint_storage: CheckpointStorage | None = None def participants(self, **participants: AgentProtocol | Executor) -> Self: - """Add participants (agents) to the workflow.""" + """Add participant agents or executors to the Magentic workflow. + + Participants are the agents that will execute tasks under the manager's direction. + Each participant should have distinct capabilities that complement the team. The + manager will select which participant to invoke based on the current plan and + progress state. + + Args: + **participants: Named agents or executors to add to the workflow. Names should + be descriptive of the agent's role (e.g., researcher=research_agent). + Accepts BaseAgent instances or custom Executor implementations. + + Returns: + Self for method chaining + + Usage: + + .. code-block:: python + + workflow = ( + MagenticBuilder() + .participants( + researcher=research_agent, writer=writing_agent, coder=coding_agent, reviewer=review_agent + ) + .with_standard_manager(chat_client=client) + .build() + ) + + Notes: + - Participant names become part of the manager's context for selection + - Agent descriptions (if available) are extracted and provided to the manager + - Can be called multiple times to add participants incrementally + """ self._participants.update(participants) return self def with_plan_review(self, enable: bool = True) -> "MagenticBuilder": - """Require human sign-off on the plan before coordination begins.""" + """Enable or disable human-in-the-loop plan review before task execution. + + When enabled, the workflow will pause after the manager generates the initial + plan and emit a MagenticPlanReviewRequest event. A human reviewer can then + approve, request revisions, or reject the plan. The workflow continues only + after approval. + + This is useful for: + - High-stakes tasks requiring human oversight + - Validating the manager's understanding of requirements + - Catching hallucinations or unrealistic plans early + - Educational scenarios where learners review AI planning + + Args: + enable: Whether to require plan review (default True) + + Returns: + Self for method chaining + + Usage: + + .. code-block:: python + + workflow = ( + MagenticBuilder() + .participants(agent1=agent1) + .with_standard_manager(chat_client=client) + .with_plan_review(enable=True) + .build() + ) + + # During execution, handle plan review + async for event in workflow.run_stream("task"): + if isinstance(event, MagenticPlanReviewRequest): + # Review plan and respond + reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) + await workflow.send(reply) + + See Also: + - :class:`MagenticPlanReviewRequest`: Event emitted for review + - :class:`MagenticPlanReviewReply`: Response to send back + - :class:`MagenticPlanReviewDecision`: Approve/Revise/Reject options + """ self._enable_plan_review = enable return self def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "MagenticBuilder": - """Persist workflow state using the provided checkpoint storage.""" + """Enable workflow state persistence using the provided checkpoint storage. + + Checkpointing allows workflows to be paused, resumed across process restarts, + or recovered after failures. The entire workflow state including conversation + history, task ledgers, and progress is persisted at key points. + + Args: + checkpoint_storage: Storage backend for checkpoints (e.g., InMemoryCheckpointStorage, + FileCheckpointStorage, or custom implementations) + + Returns: + Self for method chaining + + Usage: + + .. code-block:: python + + from agent_framework import InMemoryCheckpointStorage + + storage = InMemoryCheckpointStorage() + workflow = ( + MagenticBuilder() + .participants(agent1=agent1) + .with_standard_manager(chat_client=client) + .with_checkpointing(storage) + .build() + ) + + # First run + thread_id = "task-123" + async for msg in workflow.run("task", thread_id=thread_id): + print(msg.text) + + # Resume from checkpoint + async for msg in workflow.run("continue", thread_id=thread_id): + print(msg.text) + + Notes: + - Checkpoints are created after each significant state transition + - Thread ID must be consistent across runs to resume properly + - Storage implementations may have different persistence guarantees + """ self._checkpoint_storage = checkpoint_storage return self @@ -1770,18 +2111,106 @@ def with_standard_manager( max_reset_count: int | None = None, max_round_count: int | None = None, ) -> Self: - """Configure the Magentic manager. + """Configure the workflow manager for task planning and agent coordination. + + The manager is responsible for creating plans, selecting agents, tracking progress, + and deciding when to replan or complete. This method supports two usage patterns: + + 1. **Provide existing manager**: Pass a pre-configured manager instance (custom + or standard) for full control over behavior + 2. **Auto-create standard manager**: Pass chat_client and options to automatically + create a StandardMagenticManager with specified configuration + + Args: + manager: Pre-configured manager instance (StandardMagenticManager or custom + MagenticManagerBase subclass). If provided, all other arguments are ignored. + chat_client: LLM chat client for generating plans and decisions. Required if + manager is not provided. + task_ledger: Optional custom task ledger implementation for specialized + prompting or structured output requirements + instructions: System instructions prepended to all manager prompts to guide + behavior and set expectations + task_ledger_facts_prompt: Custom prompt template for extracting facts from + task description + task_ledger_plan_prompt: Custom prompt template for generating initial plan + task_ledger_full_prompt: Custom prompt template for complete task ledger + (facts + plan combined) + task_ledger_facts_update_prompt: Custom prompt template for updating facts + based on agent progress + task_ledger_plan_update_prompt: Custom prompt template for replanning when + needed + progress_ledger_prompt: Custom prompt template for assessing progress and + determining next actions + final_answer_prompt: Custom prompt template for synthesizing final response + when task is complete + max_stall_count: Maximum consecutive rounds without progress before triggering + replan (default 3). Set to 0 to disable stall detection. + max_reset_count: Maximum number of complete resets allowed before failing. + None means unlimited resets. + max_round_count: Maximum total coordination rounds before stopping with + partial result. None means unlimited rounds. + + Returns: + Self for method chaining + + Raises: + ValueError: If manager is None and chat_client is also None + + Usage with auto-created manager: + + .. code-block:: python + + from azure.ai.projects.aio import AIProjectClient + + project_client = AIProjectClient.from_connection_string(...) + chat_client = project_client.inference.get_chat_completions_client() + + workflow = ( + MagenticBuilder() + .participants(agent1=agent1, agent2=agent2) + .with_standard_manager( + chat_client=chat_client, + max_round_count=20, + max_stall_count=3, + instructions="Be concise and focus on accuracy", + ) + .build() + ) + + Usage with custom manager: + + .. code-block:: python + + class MyManager(MagenticManagerBase): + async def plan(self, context: MagenticContext) -> ChatMessage: + # Custom planning logic + return ChatMessage(role=Role.ASSISTANT, text="...") + - Usage patterns: - - Provide an existing manager instance (recommended for custom or preconfigured managers): - builder.with_standard_manager(my_manager) - - Or pass explicit kwargs to construct a StandardMagenticManager for you: - builder.with_standard_manager(chat_client=my_client, max_round_count=10, max_stall_count=3) + manager = MyManager() + workflow = MagenticBuilder().participants(agent1=agent1).with_standard_manager(manager).build() + + Usage with prompt customization: + + .. code-block:: python + + workflow = ( + MagenticBuilder() + .participants(coder=coder_agent, reviewer=reviewer_agent) + .with_standard_manager( + chat_client=chat_client, + task_ledger_plan_prompt="Create a detailed step-by-step plan...", + progress_ledger_prompt="Assess progress and decide next action...", + max_stall_count=2, + ) + .build() + ) Notes: - - If ``manager`` is provided, it is used as-is (can be a StandardMagenticManager or any MagenticManagerBase). - - If not provided, ``chat_client`` is required and a new StandardMagenticManager will be created - with the provided options. + - StandardMagenticManager uses structured LLM calls for all decisions + - Custom managers can implement alternative selection strategies + - Prompt templates support Jinja2-style variable substitution + - Stall detection helps prevent infinite loops in stuck scenarios """ if manager is not None: self._manager = manager @@ -1809,29 +2238,7 @@ def with_standard_manager( ) return self - def on_exception(self, callback: Callable[[Exception], None]) -> Self: - """Set the exception callback.""" - self._exception_callback = callback - return self - - def on_result(self, callback: Callable[[ChatMessage], Awaitable[None]]) -> Self: - """Set the result callback.""" - self._result_callback = callback - return self - - def on_event( - self, callback: CallbackSink, *, mode: MagenticCallbackMode = MagenticCallbackMode.NON_STREAMING - ) -> Self: - """Register a single sink for all workflow, orchestrator, and agent events. - - mode=STREAMING yields AgentDeltaEvent plus AgentMessageEvent at the end. - mode=NON_STREAMING only yields AgentMessageEvent at the end (no deltas). - """ - self._unified_callback = callback - self._callback_mode = mode - return self - - def build(self) -> "MagenticWorkflow": + def build(self) -> Workflow: """Build a Magentic workflow with the orchestrator and all agent executors.""" if not self._participants: raise ValueError("No participants added to Magentic workflow") @@ -1850,144 +2257,66 @@ def build(self) -> "MagenticWorkflow": description = f"Executor {name}" participant_descriptions[name] = description - # If unified sink is provided, map it to legacy callback surfaces - unified = self._unified_callback - mode = self._callback_mode - - if unified is not None: - prior_result = self._result_callback - - async def _on_result(msg: ChatMessage) -> None: - with contextlib.suppress(Exception): - await unified(MagenticFinalResultEvent(message=msg)) - if prior_result is not None: - with contextlib.suppress(Exception): - await prior_result(msg) + # Type narrowing: we already checked self._manager is not None above + manager: MagenticManagerBase = self._manager # type: ignore[assignment] - async def _on_orch(orch_id: str, msg: ChatMessage, kind: str) -> None: - with contextlib.suppress(Exception): - await unified(MagenticOrchestratorMessageEvent(orchestrator_id=orch_id, message=msg, kind=kind)) - - async def _on_agent_final(agent_id: str, message: ChatMessage) -> None: - with contextlib.suppress(Exception): - await unified(MagenticAgentMessageEvent(agent_id=agent_id, message=message)) - - async def _on_agent_delta(agent_id: str, update: AgentRunResponseUpdate, is_final: bool) -> None: - if mode == MagenticCallbackMode.STREAMING: - # TODO(evmattso): Make sure we surface other non-text streaming items - # (or per-type events) and plumb through consumers. - chunk: str | None = getattr(update, "text", None) - if not chunk: - with contextlib.suppress(Exception): - contents = getattr(update, "contents", []) or [] - chunk = "".join(getattr(c, "text", "") for c in contents) or None - if chunk: - with contextlib.suppress(Exception): - await unified( - MagenticAgentDeltaEvent( - agent_id=agent_id, - text=chunk, - role=getattr(update, "role", None), - ) - ) - # Emit function call/result items if present on the update - with contextlib.suppress(Exception): - content_items = getattr(update, "contents", []) or [] - for item in content_items: - if isinstance(item, FunctionCallContent): - await unified( - MagenticAgentDeltaEvent( - agent_id=agent_id, - function_call_id=getattr(item, "call_id", None), - function_call_name=getattr(item, "name", None), - function_call_arguments=getattr(item, "arguments", None), - role=getattr(update, "role", None), - ) - ) - elif isinstance(item, FunctionResultContent): - await unified( - MagenticAgentDeltaEvent( - agent_id=agent_id, - function_result_id=getattr(item, "call_id", None), - function_result=getattr(item, "result", None), - role=getattr(update, "role", None), - ) - ) - # final aggregation handled by _on_agent_final via agent_response_callback - - # Override delegates for orchestrator and agent callbacks - self._result_callback = _on_result - self._message_callback = _on_orch - self._agent_response_callback = _on_agent_final - self._agent_streaming_callback = _on_agent_delta if mode == MagenticCallbackMode.STREAMING else None - - # Create orchestrator executor - orchestrator_executor = MagenticOrchestratorExecutor( - manager=self._manager, - participants=participant_descriptions, - result_callback=self._result_callback, - message_callback=self._message_callback, - agent_response_callback=self._agent_response_callback, - streaming_agent_response_callback=self._agent_streaming_callback, - require_plan_signoff=self._enable_plan_review, - executor_id="magentic_orchestrator", - ) - - # Create workflow builder and set orchestrator as start - workflow_builder = WorkflowBuilder().set_start_executor(orchestrator_executor) - - if self._enable_plan_review: - from ._request_info_executor import RequestInfoExecutor - - request_info = RequestInfoExecutor(id="magentic_plan_review") - workflow_builder = ( - workflow_builder - # Only route plan review asks to request_info - .add_edge( - orchestrator_executor, - request_info, - condition=lambda msg: isinstance(msg, MagenticPlanReviewRequest), - ).add_edge(request_info, orchestrator_executor) + def _orchestrator_factory(wiring: GroupChatWiring) -> Executor: + return MagenticOrchestratorExecutor( + manager=manager, + participants=participant_descriptions, + result_callback=self._result_callback, + message_callback=self._message_callback, + agent_response_callback=self._agent_response_callback, + streaming_agent_response_callback=self._agent_streaming_callback, + require_plan_signoff=self._enable_plan_review, + executor_id="magentic_orchestrator", ) - def _route_to_agent(msg: object, *, agent_name: str) -> bool: - """Route only messages meant for this agent. - - - MagenticRequestMessage -> only to the named agent - - MagenticResponseMessage -> broadcast=True to all, or target_agent==agent_name - Everything else (e.g., RequestInfoMessage) -> do not route to agents. - """ - if isinstance(msg, MagenticRequestMessage): - return msg.agent_name == agent_name - if isinstance(msg, MagenticResponseMessage): - return bool(getattr(msg, "broadcast", False)) or getattr(msg, "target_agent", None) == agent_name - return False - - # Add agent executors and connect them - for name, participant in self._participants.items(): + def _participant_factory( + spec: GroupChatParticipantSpec, + wiring: GroupChatWiring, + ) -> GroupChatParticipantNodes: agent_executor = MagenticAgentExecutor( - participant, - name, + spec.participant, + spec.name, agent_response_callback=self._agent_response_callback, streaming_agent_response_callback=self._agent_streaming_callback, ) - # Register for internal control (e.g., reset) - orchestrator_executor.register_agent_executor(name, agent_executor) + orchestrator = wiring.orchestrator + if isinstance(orchestrator, MagenticOrchestratorExecutor): + orchestrator.register_agent_executor(spec.name, agent_executor) + return GroupChatParticipantNodes(entry=agent_executor, exit=agent_executor) + + group_builder = GroupChatBuilder( + _orchestrator_factory=_orchestrator_factory, + _participant_factory=_participant_factory, + ) + # Note: Magentic uses its own orchestrator factory that creates MagenticOrchestratorExecutor + # with MagenticManagerBase. However, GroupChatBuilder.build() requires a manager to be set, + # even though it won't be used (the factory overrides it). Set a dummy manager to satisfy validation. - # Add bidirectional edges between orchestrator and agent - def _cond(msg: object, _an: str = name) -> bool: - return _route_to_agent(msg, agent_name=_an) + class _DummyManager: + """Dummy manager to satisfy GroupChatBuilder validation when using custom factory.""" - workflow_builder = workflow_builder.add_edge( - orchestrator_executor, - agent_executor, - condition=_cond, - ).add_edge(agent_executor, orchestrator_executor) + name: str = MAGENTIC_MANAGER_NAME + + async def next_action(self, state: Any) -> Any: # type: ignore[misc] + raise NotImplementedError("Dummy manager should never be called") + + group_builder = group_builder.set_manager(_DummyManager(), display_name=MAGENTIC_MANAGER_NAME) # type: ignore[arg-type] + group_builder = group_builder.participants(self._participants) if self._checkpoint_storage is not None: - workflow_builder = workflow_builder.with_checkpointing(self._checkpoint_storage) + group_builder = group_builder.with_checkpointing(self._checkpoint_storage) + + if self._enable_plan_review: + request_info = RequestInfoExecutor(id="magentic_plan_review") + group_builder = group_builder.with_request_handler( + request_info, + condition=lambda msg: isinstance(msg, MagenticPlanReviewRequest), + ) - return MagenticWorkflow(workflow_builder.build()) + return group_builder.build() def start_with_string(self, task: str) -> "MagenticWorkflow": """Build a Magentic workflow and return a wrapper with convenience methods for string tasks. @@ -1998,7 +2327,7 @@ def start_with_string(self, task: str) -> "MagenticWorkflow": Returns: A MagenticWorkflow wrapper that provides convenience methods for starting with strings. """ - return MagenticWorkflow(self.build().workflow, task) + return MagenticWorkflow(self.build(), task) def start_with_message(self, task: ChatMessage) -> "MagenticWorkflow": """Build a Magentic workflow and return a wrapper with convenience methods for ChatMessage tasks. @@ -2009,7 +2338,7 @@ def start_with_message(self, task: ChatMessage) -> "MagenticWorkflow": Returns: A MagenticWorkflow wrapper that provides convenience methods. """ - return MagenticWorkflow(self.build().workflow, task.text) + return MagenticWorkflow(self.build(), task.text) def start_with(self, task: str | ChatMessage) -> "MagenticWorkflow": """Build a Magentic workflow and return a wrapper with convenience methods. diff --git a/python/packages/core/tests/workflow/test_group_chat_builder_spec.py b/python/packages/core/tests/workflow/test_group_chat_builder_spec.py new file mode 100644 index 00000000000..3e139530c7b --- /dev/null +++ b/python/packages/core/tests/workflow/test_group_chat_builder_spec.py @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable +from typing import Any + +from agent_framework import ( + AgentRunResponse, + AgentRunResponseUpdate, + AgentThread, + BaseAgent, + ChatMessage, + GroupChatBuilder, + GroupChatDirective, + GroupChatManagerProtocol, + GroupChatState, + MagenticAgentMessageEvent, + MagenticBuilder, + MagenticContext, + MagenticManagerBase, + MagenticOrchestratorMessageEvent, + MagenticProgressLedger, + MagenticProgressLedgerItem, + MagenticStartMessage, + Role, + TextContent, + Workflow, + WorkflowOutputEvent, +) + + +class StubAgent(BaseAgent): + def __init__(self, agent_name: str, reply_text: str, **kwargs: Any) -> None: + super().__init__(name=agent_name, description=f"Stub agent {agent_name}", **kwargs) + self._reply_text = reply_text + + async def run( # type: ignore[override] + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + response = ChatMessage(role=Role.ASSISTANT, text=self._reply_text, author_name=self.name) + return AgentRunResponse(messages=[response]) + + def run_stream( # type: ignore[override] + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + async def _stream() -> AsyncIterable[AgentRunResponseUpdate]: + yield AgentRunResponseUpdate( + contents=[TextContent(text=self._reply_text)], role=Role.ASSISTANT, author_name=self.name + ) + + return _stream() + + +class SequenceManager(GroupChatManagerProtocol): + def __init__(self) -> None: + self._step = 0 + + @property + def name(self) -> str: + return "manager" + + async def next_action(self, state: GroupChatState) -> GroupChatDirective: + participant_names = list(state.participants.keys()) + if self._step == 0: + self._step += 1 + return GroupChatDirective(agent_name=participant_names[0], instruction="start") + if self._step == 1 and len(participant_names) > 1: + self._step += 1 + return GroupChatDirective(agent_name=participant_names[1], instruction="continue") + return GroupChatDirective( + finish=True, + final_message=ChatMessage(role=Role.ASSISTANT, text="done", author_name=self.name), + ) + + +class StubMagenticManager(MagenticManagerBase): + def __init__(self) -> None: + super().__init__(max_stall_count=3, max_round_count=5) + self._round = 0 + + async def plan(self, magentic_context: MagenticContext) -> ChatMessage: + return ChatMessage(role=Role.ASSISTANT, text="plan", author_name="magentic_manager") + + async def replan(self, magentic_context: MagenticContext) -> ChatMessage: + return await self.plan(magentic_context) + + async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + participants = list(magentic_context.participant_descriptions.keys()) + target = participants[0] if participants else "agent" + if self._round == 0: + self._round += 1 + return MagenticProgressLedger( + is_request_satisfied=MagenticProgressLedgerItem(reason="", answer=False), + is_in_loop=MagenticProgressLedgerItem(reason="", answer=False), + is_progress_being_made=MagenticProgressLedgerItem(reason="", answer=True), + next_speaker=MagenticProgressLedgerItem(reason="", answer=target), + instruction_or_question=MagenticProgressLedgerItem(reason="", answer="respond"), + ) + return MagenticProgressLedger( + is_request_satisfied=MagenticProgressLedgerItem(reason="", answer=True), + is_in_loop=MagenticProgressLedgerItem(reason="", answer=False), + is_progress_being_made=MagenticProgressLedgerItem(reason="", answer=True), + next_speaker=MagenticProgressLedgerItem(reason="", answer=target), + instruction_or_question=MagenticProgressLedgerItem(reason="", answer=""), + ) + + async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: + return ChatMessage(role=Role.ASSISTANT, text="final", author_name="magentic_manager") + + +async def test_group_chat_builder_basic_flow() -> None: + manager = SequenceManager() + alpha = StubAgent("alpha", "ack from alpha") + beta = StubAgent("beta", "ack from beta") + + workflow = ( + GroupChatBuilder().set_manager(manager, display_name="manager").participants(alpha=alpha, beta=beta).build() + ) + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream("coordinate task"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + assert len(outputs) == 1 + assert outputs[0].text == "done" + assert outputs[0].author_name == "manager" + + +async def test_magentic_builder_returns_workflow_and_runs() -> None: + manager = StubMagenticManager() + agent = StubAgent("writer", "first draft") + + workflow = MagenticBuilder().participants(writer=agent).with_standard_manager(manager=manager).build() + + assert isinstance(workflow, Workflow) + + outputs: list[ChatMessage] = [] + orchestrator_events: list[MagenticOrchestratorMessageEvent] = [] + agent_events: list[MagenticAgentMessageEvent] = [] + start_message = MagenticStartMessage.from_string("compose summary") + async for event in workflow.run_stream(start_message): + if isinstance(event, MagenticOrchestratorMessageEvent): + orchestrator_events.append(event) + if isinstance(event, MagenticAgentMessageEvent): + agent_events.append(event) + if isinstance(event, WorkflowOutputEvent): + msg = event.data + if isinstance(msg, ChatMessage): + outputs.append(msg) + + assert outputs, "Expected a final output message" + final = outputs[-1] + assert final.text == "final" + assert final.author_name == "magentic_manager" + assert orchestrator_events, "Expected orchestrator events to be emitted" + assert agent_events, "Expected agent message events to be emitted" + + +async def test_group_chat_as_agent_accepts_conversation() -> None: + manager = SequenceManager() + alpha = StubAgent("alpha", "ack from alpha") + beta = StubAgent("beta", "ack from beta") + + workflow = ( + GroupChatBuilder().set_manager(manager, display_name="manager").participants(alpha=alpha, beta=beta).build() + ) + + agent = workflow.as_agent(name="group-chat-agent") + conversation = [ + ChatMessage(role=Role.USER, text="kickoff", author_name="user"), + ChatMessage(role=Role.ASSISTANT, text="noted", author_name="alpha"), + ] + response = await agent.run(conversation) + + assert response.messages, "Expected agent conversation output" + + +async def test_magentic_as_agent_accepts_conversation() -> None: + manager = StubMagenticManager() + writer = StubAgent("writer", "draft") + + workflow = MagenticBuilder().participants(writer=writer).with_standard_manager(manager=manager).build() + + agent = workflow.as_agent(name="magentic-agent") + conversation = [ + ChatMessage(role=Role.SYSTEM, text="Guidelines", author_name="system"), + ChatMessage(role=Role.USER, text="Summarize the findings", author_name="requester"), + ] + response = await agent.run(conversation) + + assert isinstance(response, AgentRunResponse) diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index b52449a928c..6f0f70949de 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -15,6 +15,7 @@ ChatResponse, ChatResponseUpdate, Executor, + MagenticAgentMessageEvent, MagenticBuilder, MagenticManagerBase, MagenticPlanReviewDecision, @@ -328,13 +329,11 @@ async def test_magentic_checkpoint_resume_round_trip(): .build() ) - orchestrator = next( - exec for exec in wf_resume.workflow.executors.values() if isinstance(exec, MagenticOrchestratorExecutor) - ) + orchestrator = next(exec for exec in wf_resume.executors.values() if isinstance(exec, MagenticOrchestratorExecutor)) reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) completed: WorkflowOutputEvent | None = None - async for event in wf_resume.workflow.run_stream_from_checkpoint( + async for event in wf_resume.run_stream_from_checkpoint( resume_checkpoint.checkpoint_id, responses={req_event.request_id: reply}, ): @@ -533,17 +532,10 @@ async def run(self, messages=None, *, thread=None, **kwargs): # type: ignore[ov async def _collect_agent_responses_setup(participant_obj: object): captured: list[ChatMessage] = [] - async def sink(event) -> None: # type: ignore[no-untyped-def] - from agent_framework._workflows._magentic import MagenticAgentMessageEvent - - if isinstance(event, MagenticAgentMessageEvent) and event.message is not None: - captured.append(event.message) - wf = ( MagenticBuilder() .participants(agentA=participant_obj) # type: ignore[arg-type] .with_standard_manager(InvokeOnceManager()) - .on_event(sink) # type: ignore .build() ) @@ -551,6 +543,10 @@ async def sink(event) -> None: # type: ignore[no-untyped-def] events: list[WorkflowEvent] = [] async for ev in wf.run_stream("task"): # plan review disabled events.append(ev) + if isinstance(ev, WorkflowOutputEvent): + break + if isinstance(ev, MagenticAgentMessageEvent) and ev.message is not None: + captured.append(ev.message) if len(events) > 50: break @@ -685,7 +681,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames(): .build() ) - with pytest.raises(RuntimeError, match="participant names do not match"): + with pytest.raises(ValueError, match="Workflow graph has changed"): async for _ in renamed_workflow.run_stream_from_checkpoint( target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType] responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)}, diff --git a/python/samples/README.md b/python/samples/README.md index 182c635c6c3..5504cdc3a00 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -288,6 +288,7 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | [`getting_started/workflows/orchestration/concurrent_agents.py`](./getting_started/workflows/orchestration/concurrent_agents.py) | Sample: Concurrent fan-out/fan-in (agent-only API) with default aggregator | | [`getting_started/workflows/orchestration/concurrent_custom_agent_executors.py`](./getting_started/workflows/orchestration/concurrent_custom_agent_executors.py) | Sample: Concurrent Orchestration with Custom Agent Executors | | [`getting_started/workflows/orchestration/concurrent_custom_aggregator.py`](./getting_started/workflows/orchestration/concurrent_custom_aggregator.py) | Sample: Concurrent Orchestration with Custom Aggregator | +| [`getting_started/workflows/orchestration/group_chat.py`](./getting_started/workflows/orchestration/group_chat.py) | Sample: Group Chat Orchestration with LLM manager | | [`getting_started/workflows/orchestration/magentic.py`](./getting_started/workflows/orchestration/magentic.py) | Sample: Magentic Orchestration (multi-agent) | | [`getting_started/workflows/orchestration/magentic_checkpoint.py`](./getting_started/workflows/orchestration/magentic_checkpoint.py) | Sample: Magentic Orchestration + Checkpointing | | [`getting_started/workflows/orchestration/magentic_human_plan_update.py`](./getting_started/workflows/orchestration/magentic_human_plan_update.py) | Sample: Magentic Orchestration + Human Plan Review | @@ -321,4 +322,3 @@ For information on creating new samples, see [SAMPLE_GUIDELINES.md](./SAMPLE_GUI ## More Information - [Python Package Documentation](../README.md) - diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 17780a7aac3..363f861e146 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -39,6 +39,9 @@ Once comfortable with these, explore the rest of the samples below. | Azure Chat Agents (Function Bridge) | [agents/azure_chat_agents_function_bridge.py](./agents/azure_chat_agents_function_bridge.py) | Chain two agents with a function executor that injects external context | | Azure Chat Agents (Tools + HITL) | [agents/azure_chat_agents_tool_calls_with_feedback.py](./agents/azure_chat_agents_tool_calls_with_feedback.py) | Tool-enabled writer/editor pipeline with human feedback gating via RequestInfoExecutor | | Custom Agent Executors | [agents/custom_agent_executors.py](./agents/custom_agent_executors.py) | Create executors to handle agent run methods | +| Sequential Workflow as Agent | [agents/sequential_workflow_as_agent.py](./agents/sequential_workflow_as_agent.py) | Build a sequential workflow orchestrating agents, then expose it as a reusable agent | +| Concurrent Workflow as Agent | [agents/concurrent_workflow_as_agent.py](./agents/concurrent_workflow_as_agent.py) | Build a concurrent fan-out/fan-in workflow, then expose it as a reusable agent | +| Magentic Workflow as Agent | [agents/magentic_workflow_as_agent.py](./agents/magentic_workflow_as_agent.py) | Configure Magentic orchestration with callbacks, then expose the workflow as an agent | | Workflow as Agent (Reflection Pattern) | [agents/workflow_as_agent_reflection_pattern.py](./agents/workflow_as_agent_reflection_pattern.py) | Wrap a workflow so it can behave like an agent (reflection pattern) | | Workflow as Agent + HITL | [agents/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability | @@ -89,6 +92,7 @@ Once comfortable with these, explore the rest of the samples below. | Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages | | Concurrent Orchestration (Custom Aggregator) | [orchestration/concurrent_custom_aggregator.py](./orchestration/concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM | | Concurrent Orchestration (Custom Agent Executors) | [orchestration/concurrent_custom_agent_executors.py](./orchestration/concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder | +| Group Chat Orchestration | [orchestration/group_chat.py](./orchestration/group_chat.py) | Manager-directed conversation using GroupChatBuilder and LLMGroupChatManager | | Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming | | Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution | | Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | diff --git a/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py new file mode 100644 index 00000000000..29dfc1874fb --- /dev/null +++ b/python/samples/getting_started/workflows/agents/concurrent_workflow_as_agent.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import ConcurrentBuilder +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +""" +Sample: Build a concurrent workflow orchestration and wrap it as an agent. + +This script wires up a fan-out/fan-in workflow using `ConcurrentBuilder`, and then +invokes the entire orchestration through the `workflow.as_agent(...)` interface so +downstream coordinators can reuse the orchestration as a single agent. + +Demonstrates: +- Fan-out to multiple agents, fan-in aggregation of final ChatMessages. +- Reusing the orchestrated workflow as an agent entry point with `workflow.as_agent(...)`. +- Workflow completion when idle with no pending work + +Prerequisites: +- Azure OpenAI access configured for AzureOpenAIChatClient (use az login + env vars) +- Familiarity with Workflow events (AgentRunEvent, WorkflowOutputEvent) +""" + + +async def main() -> None: + # 1) Create three domain agents using AzureOpenAIChatClient + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + researcher = chat_client.create_agent( + instructions=( + "You're an expert market and product researcher. Given a prompt, provide concise, factual insights," + " opportunities, and risks." + ), + name="researcher", + ) + + marketer = chat_client.create_agent( + instructions=( + "You're a creative marketing strategist. Craft compelling value propositions and target messaging" + " aligned to the prompt." + ), + name="marketer", + ) + + legal = chat_client.create_agent( + instructions=( + "You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns" + " based on the prompt." + ), + name="legal", + ) + + # 2) Build a concurrent workflow + workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build() + + # 3) Expose the concurrent workflow as an agent for easy reuse + agent = workflow.as_agent(name="ConcurrentWorkflowAgent") + prompt = "We are launching a new budget-friendly electric bike for urban commuters." + agent_response = await agent.run(prompt) + + if agent_response.messages: + print("\n===== Aggregated Messages =====") + for i, msg in enumerate(agent_response.messages, start=1): + role = getattr(msg.role, "value", msg.role) + name = msg.author_name if msg.author_name else role + print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}") + + """ + Sample Output: + + ===== Aggregated Messages ===== + ------------------------------------------------------------ + + 01 [user]: + We are launching a new budget-friendly electric bike for urban commuters. + ------------------------------------------------------------ + + 02 [researcher]: + **Insights:** + + - **Target Demographic:** Urban commuters seeking affordable, eco-friendly transport; + likely to include students, young professionals, and price-sensitive urban residents. + - **Market Trends:** E-bike sales are growing globally, with increasing urbanization, + higher fuel costs, and sustainability concerns driving adoption. + - **Competitive Landscape:** Key competitors include brands like Rad Power Bikes, Aventon, + Lectric, and domestic budget-focused manufacturers in North America, Europe, and Asia. + - **Feature Expectations:** Customers expect reliability, ease-of-use, theft protection, + lightweight design, sufficient battery range for daily city commutes (typically 25-40 miles), + and low-maintenance components. + + **Opportunities:** + + - **First-time Buyers:** Capture newcomers to e-biking by emphasizing affordability, ease of + operation, and cost savings vs. public transit/car ownership. + ... + ------------------------------------------------------------ + + 03 [marketer]: + **Value Proposition:** + "Empowering your city commute: Our new electric bike combines affordability, reliability, and + sustainable design—helping you conquer urban journeys without breaking the bank." + + **Target Messaging:** + + *For Young Professionals:* + ... + ------------------------------------------------------------ + + 04 [legal]: + **Constraints, Disclaimers, & Policy Concerns for Launching a Budget-Friendly Electric Bike for Urban Commuters:** + + **1. Regulatory Compliance** + - Verify that the electric bike meets all applicable federal, state, and local regulations + regarding e-bike classification, speed limits, power output, and safety features. + - Ensure necessary certifications (e.g., UL certification for batteries, CE markings if sold internationally) are obtained. + + **2. Product Safety** + - Include consumer safety warnings regarding use, battery handling, charging protocols, and age restrictions. + ... + """ # noqa: E501 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py new file mode 100644 index 00000000000..c6e0ce9d7e9 --- /dev/null +++ b/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import logging + +from agent_framework import ( + ChatAgent, + GroupChatBuilder, + StandardGroupChatManager, +) +from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient + +logging.basicConfig(level=logging.INFO) + +""" +Sample: Group Chat Orchestration (manager-directed) + +What it does: +- Demonstrates the generic GroupChatBuilder with a language-model manager directing two agents. +- The manager coordinates a researcher (chat completions) and a writer (responses API) to solve a task. +- Uses the default group chat orchestration pipeline shared with Magentic. + +Prerequisites: +- OpenAI environment variables configured for `OpenAIChatClient` and `OpenAIResponsesClient`. +""" + + +async def main() -> None: + researcher = ChatAgent( + name="Researcher", + description="Collects relevant background information.", + instructions="Gather concise facts that help a teammate answer the question.", + chat_client=OpenAIChatClient(model_id="gpt-4o-mini"), + ) + + writer = ChatAgent( + name="Writer", + description="Synthesizes a polished answer using the gathered notes.", + instructions="Compose clear and structured answers using any notes provided.", + chat_client=OpenAIResponsesClient(), + ) + + manager = StandardGroupChatManager( + chat_client=OpenAIChatClient(), + name="Coordinator", + ) + + workflow = ( + GroupChatBuilder() + .set_manager(manager, display_name="Coordinator") + .participants(researcher=researcher, writer=writer) + .build() + ) + + task = "Outline the core considerations for planning a community hackathon, and finish with a concise action plan." + + print("\nStarting Group Chat Workflow...\n") + print(f"Input: {task}\n") + + try: + workflow_agent = workflow.as_agent(name="GroupChatWorkflowAgent") + agent_result = await workflow_agent.run(task) + + if agent_result.messages: + print("\n===== as_agent() Transcript =====") + for i, msg in enumerate(agent_result.messages, start=1): + role_value = getattr(msg.role, "value", msg.role) + speaker = msg.author_name or role_value + print(f"{'-' * 50}\n{i:02d} [{speaker}]\n{msg.text}") + + except Exception as e: + print(f"Workflow execution failed: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py new file mode 100644 index 00000000000..6fab7c495c6 --- /dev/null +++ b/python/samples/getting_started/workflows/agents/magentic_workflow_as_agent.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import logging + +from agent_framework import ( + ChatAgent, + HostedCodeInterpreterTool, + MagenticAgentDeltaEvent, + MagenticAgentMessageEvent, + MagenticBuilder, + MagenticFinalResultEvent, + MagenticOrchestratorMessageEvent, + WorkflowOutputEvent, +) +from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +""" +Sample: Build a Magentic orchestration and wrap it as an agent. + +The script configures a Magentic workflow with streaming callbacks, then invokes the +orchestration through `workflow.as_agent(...)` so the entire Magentic loop can be reused +like any other agent while still emitting callback telemetry. + +Prerequisites: +- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`. +""" + + +async def main() -> None: + researcher_agent = ChatAgent( + name="ResearcherAgent", + description="Specialist in research and information gathering", + instructions=( + "You are a Researcher. You find information without additional computation or quantitative analysis." + ), + # This agent requires the gpt-4o-search-preview model to perform web searches. + # Feel free to explore with other agents that support web search, for example, + # the `OpenAIResponseAgent` or `AzureAgentProtocol` with bing grounding. + chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"), + ) + + coder_agent = ChatAgent( + name="CoderAgent", + description="A helpful assistant that writes and executes code to process and analyze data.", + instructions="You solve questions using code. Please provide detailed analysis and computation process.", + chat_client=OpenAIResponsesClient(), + tools=HostedCodeInterpreterTool(), + ) + + print("\nBuilding Magentic Workflow...") + + workflow = ( + MagenticBuilder() + .participants(researcher=researcher_agent, coder=coder_agent) + .with_standard_manager( + chat_client=OpenAIChatClient(), + max_round_count=10, + max_stall_count=3, + max_reset_count=2, + ) + .build() + ) + + task = ( + "I am preparing a report on the energy efficiency of different machine learning model architectures. " + "Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 " + "on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). " + "Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 " + "VM for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model " + "per task type (image classification, text classification, and text generation)." + ) + + print(f"\nTask: {task}") + print("\nStarting workflow execution...") + + try: + last_stream_agent_id: str | None = None + stream_line_open: bool = False + final_output: str | None = None + + async for event in workflow.run_stream(task): + if isinstance(event, MagenticOrchestratorMessageEvent): + print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}") + elif isinstance(event, MagenticAgentDeltaEvent): + if last_stream_agent_id != event.agent_id or not stream_line_open: + if stream_line_open: + print() + print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True) + last_stream_agent_id = event.agent_id + stream_line_open = True + if event.text: + print(event.text, end="", flush=True) + elif isinstance(event, MagenticAgentMessageEvent): + if stream_line_open: + print(" (final)") + stream_line_open = False + print() + msg = event.message + if msg is not None: + response_text = (msg.text or "").replace("\n", " ") + print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}") + elif isinstance(event, MagenticFinalResultEvent): + print("\n" + "=" * 50) + print("FINAL RESULT:") + print("=" * 50) + if event.message is not None: + print(event.message.text) + print("=" * 50) + elif isinstance(event, WorkflowOutputEvent): + final_output = str(event.data) if event.data is not None else None + + if stream_line_open: + print() + stream_line_open = False + + if final_output is not None: + print(f"\nWorkflow completed with result:\n\n{final_output}\n") + + # Wrap the workflow as an agent for composition scenarios + workflow_agent = workflow.as_agent(name="MagenticWorkflowAgent") + agent_result = await workflow_agent.run(task) + + if agent_result.messages: + print("\n===== as_agent() Transcript =====") + for i, msg in enumerate(agent_result.messages, start=1): + role_value = getattr(msg.role, "value", msg.role) + speaker = msg.author_name or role_value + print(f"{'-' * 50}\n{i:02d} [{speaker}]\n{msg.text}") + + except Exception as e: + print(f"Workflow execution failed: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py new file mode 100644 index 00000000000..a50337135ea --- /dev/null +++ b/python/samples/getting_started/workflows/agents/sequential_workflow_as_agent.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Role, SequentialBuilder +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + +""" +Sample: Build a sequential workflow orchestration and wrap it as an agent. + +The script assembles a sequential conversation flow with `SequentialBuilder`, then +invokes the entire orchestration through the `workflow.as_agent(...)` interface so +other coordinators can reuse the chain as a single participant. + +Note on internal adapters: +- Sequential orchestration includes small adapter nodes for input normalization + ("input-conversation"), agent-response conversion ("to-conversation:"), + and completion ("complete"). These may appear as ExecutorInvoke/Completed events in + the stream—similar to how concurrent orchestration includes a dispatcher/aggregator. + You can safely ignore them when focusing on agent progress. + +Prerequisites: +- Azure OpenAI access configured for AzureOpenAIChatClient (use az login + env vars) +""" + + +async def main() -> None: + # 1) Create agents + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + writer = chat_client.create_agent( + instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), + name="writer", + ) + + reviewer = chat_client.create_agent( + instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."), + name="reviewer", + ) + + # 2) Build sequential workflow: writer -> reviewer + workflow = SequentialBuilder().participants([writer, reviewer]).build() + + # 3) Treat the workflow itself as an agent for follow-up invocations + agent = workflow.as_agent(name="SequentialWorkflowAgent") + prompt = "Write a tagline for a budget-friendly eBike." + agent_response = await agent.run(prompt) + + if agent_response.messages: + print("\n===== Conversation =====") + for i, msg in enumerate(agent_response.messages, start=1): + role_value = getattr(msg.role, "value", msg.role) + normalized_role = str(role_value).lower() if role_value is not None else "assistant" + name = msg.author_name or ("assistant" if normalized_role == Role.ASSISTANT.value else "user") + print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}") + + """ + Sample Output: + + ===== Final Conversation ===== + ------------------------------------------------------------ + 01 [user] + Write a tagline for a budget-friendly eBike. + ------------------------------------------------------------ + 02 [writer] + Ride farther, spend less—your affordable eBike adventure starts here. + ------------------------------------------------------------ + 03 [reviewer] + This tagline clearly communicates affordability and the benefit of extended travel, making it + appealing to budget-conscious consumers. It has a friendly and motivating tone, though it could + be slightly shorter for more punch. Overall, a strong and effective suggestion! + + ===== as_agent() Conversation ===== + ------------------------------------------------------------ + 01 [writer] + Go electric, save big—your affordable ride awaits! + ------------------------------------------------------------ + 02 [reviewer] + Catchy and straightforward! The tagline clearly emphasizes both the electric aspect and the affordability of the + eBike. It's inviting and actionable. For even more impact, consider making it slightly shorter: + "Go electric, save big." Overall, this is an effective and appealing suggestion for a budget-friendly eBike. + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/orchestration/group_chat.py b/python/samples/getting_started/workflows/orchestration/group_chat.py new file mode 100644 index 00000000000..b29a0e2850d --- /dev/null +++ b/python/samples/getting_started/workflows/orchestration/group_chat.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import logging + +from agent_framework import ChatAgent, GroupChatBuilder, StandardGroupChatManager, WorkflowOutputEvent +from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient + +logging.basicConfig(level=logging.INFO) + +""" +Sample: Group Chat Orchestration (manager-directed) + +What it does: +- Demonstrates the generic GroupChatBuilder with a language-model manager directing two agents. +- The manager coordinates a researcher (chat completions) and a writer (responses API) to solve a task. +- Uses the default group chat orchestration pipeline shared with Magentic. + +Prerequisites: +- OpenAI environment variables configured for `OpenAIChatClient` and `OpenAIResponsesClient`. +""" + + +async def main() -> None: + researcher = ChatAgent( + name="Researcher", + description="Collects relevant background information.", + instructions="Gather concise facts that help a teammate answer the question.", + chat_client=OpenAIChatClient(model_id="gpt-4o-mini"), + ) + + writer = ChatAgent( + name="Writer", + description="Synthesizes a polished answer using the gathered notes.", + instructions="Compose clear and structured answers using any notes provided.", + chat_client=OpenAIResponsesClient(), + ) + + manager = StandardGroupChatManager( + chat_client=OpenAIChatClient(), + name="Coordinator", + ) + + workflow = ( + GroupChatBuilder() + .set_manager(manager, display_name="Coordinator") + .participants(researcher=researcher, writer=writer) + .build() + ) + + task = "Outline the core considerations for planning a community hackathon, and finish with a concise action plan." + + print("\nStarting Group Chat Workflow...\n") + print(f"TASK: {task}\n") + + final_response = None + async for event in workflow.run_stream(task): + if isinstance(event, WorkflowOutputEvent): + final_response = getattr(event.data, "text", str(event.data)) + + if final_response: + print("=" * 60) + print("FINAL RESPONSE") + print("=" * 60) + print(final_response) + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/orchestration/magentic.py b/python/samples/getting_started/workflows/orchestration/magentic.py index 95038cd0e4e..5010172e2ba 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic.py +++ b/python/samples/getting_started/workflows/orchestration/magentic.py @@ -9,8 +9,6 @@ MagenticAgentDeltaEvent, MagenticAgentMessageEvent, MagenticBuilder, - MagenticCallbackEvent, - MagenticCallbackMode, MagenticFinalResultEvent, MagenticOrchestratorMessageEvent, WorkflowOutputEvent, @@ -66,40 +64,6 @@ async def main() -> None: tools=HostedCodeInterpreterTool(), ) - # Unified callback - async def on_event(event: MagenticCallbackEvent) -> None: - """ - The `on_event` callback processes events emitted by the workflow. - Events include: orchestrator messages, agent delta updates, agent messages, and final result events. - """ - nonlocal last_stream_agent_id, stream_line_open - if isinstance(event, MagenticOrchestratorMessageEvent): - print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}") - elif isinstance(event, MagenticAgentDeltaEvent): - if last_stream_agent_id != event.agent_id or not stream_line_open: - if stream_line_open: - print() - print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True) - last_stream_agent_id = event.agent_id - stream_line_open = True - print(event.text, end="", flush=True) - elif isinstance(event, MagenticAgentMessageEvent): - if stream_line_open: - print(" (final)") - stream_line_open = False - print() - msg = event.message - if msg is not None: - response_text = (msg.text or "").replace("\n", " ") - print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}") - elif isinstance(event, MagenticFinalResultEvent): - print("\n" + "=" * 50) - print("FINAL RESULT:") - print("=" * 50) - if event.message is not None: - print(event.message.text) - print("=" * 50) - print("\nBuilding Magentic Workflow...") # State used by on_agent_stream callback @@ -109,7 +73,6 @@ async def on_event(event: MagenticCallbackEvent) -> None: workflow = ( MagenticBuilder() .participants(researcher=researcher_agent, coder=coder_agent) - .on_event(on_event, mode=MagenticCallbackMode.STREAMING) .with_standard_manager( chat_client=OpenAIChatClient(), max_round_count=10, @@ -134,9 +97,39 @@ async def on_event(event: MagenticCallbackEvent) -> None: try: output: str | None = None async for event in workflow.run_stream(task): - print(event) - if isinstance(event, WorkflowOutputEvent): - output = str(event.data) + if isinstance(event, MagenticOrchestratorMessageEvent): + print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}") + elif isinstance(event, MagenticAgentDeltaEvent): + if last_stream_agent_id != event.agent_id or not stream_line_open: + if stream_line_open: + print() + print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True) + last_stream_agent_id = event.agent_id + stream_line_open = True + if event.text: + print(event.text, end="", flush=True) + elif isinstance(event, MagenticAgentMessageEvent): + if stream_line_open: + print(" (final)") + stream_line_open = False + print() + msg = event.message + if msg is not None: + response_text = (msg.text or "").replace("\n", " ") + print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}") + elif isinstance(event, MagenticFinalResultEvent): + print("\n" + "=" * 50) + print("FINAL RESULT:") + print("=" * 50) + if event.message is not None: + print(event.message.text) + print("=" * 50) + elif isinstance(event, WorkflowOutputEvent): + output = str(event.data) if event.data is not None else None + + if stream_line_open: + print() + stream_line_open = False if output is not None: print(f"Workflow completed with result:\n\n{output}") diff --git a/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py b/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py index 2bec4c0f7d4..fcd6d760efc 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py +++ b/python/samples/getting_started/workflows/orchestration/magentic_checkpoint.py @@ -113,7 +113,7 @@ async def main() -> None: print("No plan review request emitted; nothing to resume.") return - checkpoints = await checkpoint_storage.list_checkpoints(workflow.workflow.id) + checkpoints = await checkpoint_storage.list_checkpoints(workflow.id) if not checkpoints: print("No checkpoints persisted.") return @@ -141,7 +141,7 @@ async def main() -> None: # and then continues the workflow. Because we only captured the initial plan review # checkpoint, the resumed run should complete almost immediately. final_event: WorkflowOutputEvent | None = None - async for event in resumed_workflow.workflow.run_stream_from_checkpoint( + async for event in resumed_workflow.run_stream_from_checkpoint( resume_checkpoint.checkpoint_id, responses={plan_review_request_id: approval}, ): @@ -204,7 +204,7 @@ def _pending_message_count(cp: WorkflowCheckpoint) -> int: final_event_post: WorkflowOutputEvent | None = None post_emitted_events = False post_plan_workflow = build_workflow(checkpoint_storage) - async for event in post_plan_workflow.workflow.run_stream_from_checkpoint( + async for event in post_plan_workflow.run_stream_from_checkpoint( post_plan_checkpoint.checkpoint_id, responses={}, ): diff --git a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py index 339554d3ec2..6153c56f58c 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py +++ b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py @@ -10,8 +10,6 @@ MagenticAgentDeltaEvent, MagenticAgentMessageEvent, MagenticBuilder, - MagenticCallbackEvent, - MagenticCallbackMode, MagenticFinalResultEvent, MagenticOrchestratorMessageEvent, MagenticPlanReviewDecision, @@ -77,43 +75,11 @@ def on_exception(exception: Exception) -> None: last_stream_agent_id: str | None = None stream_line_open: bool = False - # Unified callback - async def on_event(event: MagenticCallbackEvent) -> None: - nonlocal last_stream_agent_id, stream_line_open - if isinstance(event, MagenticOrchestratorMessageEvent): - print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}") - elif isinstance(event, MagenticAgentDeltaEvent): - if last_stream_agent_id != event.agent_id or not stream_line_open: - if stream_line_open: - print() - print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True) - last_stream_agent_id = event.agent_id - stream_line_open = True - print(event.text, end="", flush=True) - elif isinstance(event, MagenticAgentMessageEvent): - if stream_line_open: - print(" (final)") - stream_line_open = False - print() - msg = event.message - if msg is not None: - response_text = (msg.text or "").replace("\n", " ") - print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}") - elif isinstance(event, MagenticFinalResultEvent): - print("\n" + "=" * 50) - print("FINAL RESULT:") - print("=" * 50) - if event.message is not None: - print(event.message.text) - print("=" * 50) - print("\nBuilding Magentic Workflow...") workflow = ( MagenticBuilder() .participants(researcher=researcher_agent, coder=coder_agent) - .on_exception(on_exception) - .on_event(on_event, mode=MagenticCallbackMode.STREAMING) .with_standard_manager( chat_client=OpenAIChatClient(), max_round_count=10, @@ -150,11 +116,34 @@ async def on_event(event: MagenticCallbackEvent) -> None: stream = workflow.run_stream(task) # Collect events from the stream - events = [event async for event in stream] - pending_responses = None - - # Process events to find request info events, outputs, and completion status - for event in events: + async for event in stream: + if isinstance(event, MagenticOrchestratorMessageEvent): + print(f"\n[ORCH:{event.kind}]\n\n{getattr(event.message, 'text', '')}\n{'-' * 26}") + elif isinstance(event, MagenticAgentDeltaEvent): + if last_stream_agent_id != event.agent_id or not stream_line_open: + if stream_line_open: + print() + print(f"\n[STREAM:{event.agent_id}]: ", end="", flush=True) + last_stream_agent_id = event.agent_id + stream_line_open = True + if event.text: + print(event.text, end="", flush=True) + elif isinstance(event, MagenticAgentMessageEvent): + if stream_line_open: + print(" (final)") + stream_line_open = False + print() + msg = event.message + if msg is not None: + response_text = (msg.text or "").replace("\n", " ") + print(f"\n[AGENT:{event.agent_id}] {msg.role.value}\n\n{response_text}\n{'-' * 26}") + elif isinstance(event, MagenticFinalResultEvent): + print("\n" + "=" * 50) + print("FINAL RESULT:") + print("=" * 50) + if event.message is not None: + print(event.message.text) + print("=" * 50) if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest: pending_request = event review_req = cast(MagenticPlanReviewRequest, event.data) @@ -162,9 +151,14 @@ async def on_event(event: MagenticCallbackEvent) -> None: print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n") elif isinstance(event, WorkflowOutputEvent): # Capture workflow output during streaming - workflow_output = str(event.data) + workflow_output = str(event.data) if event.data is not None else None completed = True + if stream_line_open: + print() + stream_line_open = False + pending_responses = None + # Handle pending plan review request if pending_request is not None: # Get human input for plan review decision From 0bf23fb2adbfba381f73d98d9281472b2eb8ebc5 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 17 Oct 2025 17:30:02 +0900 Subject: [PATCH 02/15] Cleanup and improvements --- .../agent_framework/_workflows/_group_chat.py | 18 ++- .../agent_framework/_workflows/_magentic.py | 112 ++++-------------- .../magentic_human_plan_update.py | 2 +- 3 files changed, 34 insertions(+), 98 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index febc6ae90f1..599e31f810a 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -150,14 +150,14 @@ class GroupChatWiring: """Configuration passed to factories during workflow assembly. Attributes: - manager: Manager instance responsible for orchestration decisions + manager: Manager instance responsible for orchestration decisions (None when custom factory handles it) manager_name: Display name for the manager in conversation history participants: Mapping of participant names to their specifications max_rounds: Optional limit on manager selection rounds to prevent infinite loops orchestrator: Orchestrator executor instance (populated during build) """ - manager: GroupChatManagerProtocol + manager: GroupChatManagerProtocol | None manager_name: str participants: Mapping[str, GroupChatParticipantSpec] max_rounds: int | None = None @@ -815,7 +815,13 @@ def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: The manager needs participant descriptions (not full specs) to make informed selection decisions. The orchestrator doesn't need participant instances directly since routing is handled by the workflow graph. + + Raises: + RuntimeError: If manager is None (should not happen when using default factory) """ + if wiring.manager is None: + raise RuntimeError("Default orchestrator factory requires a manager to be set") + return GroupChatOrchestratorExecutor( manager=wiring.manager, participants={name: spec.description for name, spec in wiring.participants.items()}, @@ -1119,7 +1125,7 @@ def build(self) -> Workflow: Validated Workflow instance ready for execution Raises: - ValueError: If manager or participants are not configured + ValueError: If manager or participants are not configured (when using default factory) Wiring pattern: - Orchestrator receives initial input (str, ChatMessage, or list[ChatMessage]) @@ -1143,8 +1149,10 @@ def build(self) -> Workflow: async for message in workflow.run("Solve this problem collaboratively"): print(message.text) """ - if self._manager is None: - raise ValueError("manager must be configured before build()") + # Manager is only required when using the default orchestrator factory + # Custom factories (e.g., MagenticBuilder) provide their own orchestrator with embedded manager + if self._manager is None and self._orchestrator_factory == _default_orchestrator_factory: + raise ValueError("manager must be configured before build() when using default orchestrator") if not self._participants: raise ValueError("participants must be configured before build()") diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index e12027dd680..c05c48ef57d 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -7,7 +7,7 @@ import re import sys from abc import ABC, abstractmethod -from collections.abc import AsyncIterable, Awaitable, Callable, Sequence +from collections.abc import AsyncIterable, Sequence from dataclasses import dataclass, field from enum import Enum from typing import Any, Literal, Protocol, TypeVar, Union, cast @@ -354,32 +354,12 @@ def from_dict(cls, value: dict[str, Any]) -> "MagenticStartMessage": return cls(task=task) -@dataclass(slots=True, init=False) +@dataclass class MagenticRequestMessage(GroupChatRequestMessage): """A request message type for agents in a magentic workflow.""" task_context: str = "" - def __init__( - self, - *, - agent_name: str, - instruction: str = "", - task_context: str = "", - conversation: Sequence[ChatMessage] | None = None, - task: ChatMessage | None = None, - metadata: dict[str, Any] | None = None, - ) -> None: - GroupChatRequestMessage.__init__( - self, - agent_name=agent_name, - conversation=list(conversation or []), - instruction=instruction, - task=task, - metadata=metadata, - ) - self.task_context = task_context - class MagenticResponseMessage(GroupChatResponseMessage): """A response message type. @@ -967,11 +947,7 @@ def __init__( self, manager: MagenticManagerBase, participants: dict[str, str], - result_callback: Callable[[ChatMessage], Awaitable[None]] | None = None, - agent_response_callback: Callable[[str, ChatMessage], Awaitable[None]] | None = None, - streaming_agent_response_callback: Callable[[str, AgentRunResponseUpdate, bool], Awaitable[None]] | None = None, *, - message_callback: Callable[[str, ChatMessage, str], Awaitable[None]] | None = None, require_plan_signoff: bool = False, max_plan_review_rounds: int = 10, executor_id: str | None = None, @@ -981,11 +957,6 @@ def __init__( Args: manager: The Magentic manager instance. participants: A dictionary of participant IDs to their names. - result_callback: An optional callback for handling final results. - message_callback: An optional generic callback for orchestrator-emitted messages. The third - argument is a kind string, e.g., ORCH_MSG_KIND_USER_TASK or ORCH_MSG_KIND_TASK_LEDGER. - agent_response_callback: An optional callback for handling agent responses. - streaming_agent_response_callback: An optional callback for handling streaming agent responses. require_plan_signoff: Whether to require plan sign-off from a human. max_plan_review_rounds: The maximum number of plan review rounds. executor_id: An optional executor ID. @@ -993,10 +964,6 @@ def __init__( super().__init__(executor_id or f"magentic_orchestrator_{uuid4().hex[:8]}") self._manager = manager self._participants = participants - self._result_callback = result_callback - self._message_callback = message_callback - self._agent_response_callback = agent_response_callback - self._streaming_agent_response_callback = streaming_agent_response_callback self._context = None self._task_ledger = None self._require_plan_signoff = require_plan_signoff @@ -1028,15 +995,28 @@ async def _emit_orchestrator_message( message: ChatMessage, kind: str, ) -> None: + """Emit orchestrator message to the workflow event stream. + + Orchestrator messages flow through the unified workflow event stream as + MagenticOrchestratorMessageEvent instances. Consumers should subscribe to + these events via workflow.run_stream(). + + Args: + ctx: Workflow context for adding events to the stream + message: Orchestrator message to emit (task, plan, instruction, notice) + kind: Message classification (user_task, task_ledger, instruction, notice) + + Example: + async for event in workflow.run_stream("task"): + if isinstance(event, MagenticOrchestratorMessageEvent): + print(f"Orchestrator {event.kind}: {event.message.text}") + """ event = MagenticOrchestratorMessageEvent( orchestrator_id=self.id, message=message, kind=kind, ) await ctx.add_event(event) - if self._message_callback: - with contextlib.suppress(Exception): - await self._message_callback(self.id, message, kind) def snapshot_state(self) -> dict[str, Any]: state: dict[str, Any] = { @@ -1564,9 +1544,6 @@ async def _prepare_final_answer( await context.yield_output(final_answer) await context.add_event(MagenticFinalResultEvent(message=final_answer)) - if self._result_callback: - await self._result_callback(final_answer) - async def _check_within_limits_or_complete( self, context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], @@ -1598,9 +1575,6 @@ async def _check_within_limits_or_complete( # Yield the partial result and signal completion await context.yield_output(partial_result) await context.add_event(MagenticFinalResultEvent(message=partial_result)) - - if self._result_callback: - await self._result_callback(partial_result) return False return True @@ -1642,15 +1616,11 @@ def __init__( self, agent: AgentProtocol | Executor, agent_id: str, - agent_response_callback: Callable[[str, ChatMessage], Awaitable[None]] | None = None, - streaming_agent_response_callback: Callable[[str, AgentRunResponseUpdate, bool], Awaitable[None]] | None = None, ) -> None: super().__init__(f"agent_{agent_id}") self._agent = agent self._agent_id = agent_id self._chat_history: list[ChatMessage] = [] - self._agent_response_callback = agent_response_callback - self._streaming_agent_response_callback = streaming_agent_response_callback self._state_restored = False def snapshot_state(self) -> dict[str, Any]: @@ -1846,20 +1816,9 @@ async def _invoke_agent( async for update in agent.run_stream(messages=self._chat_history): # type: ignore[attr-defined] updates.append(update) await self._emit_agent_delta_event(ctx, update) - if self._streaming_agent_response_callback is not None: - with contextlib.suppress(Exception): - await self._streaming_agent_response_callback( - self._agent_id, - update, - False, - ) run_result: AgentRunResponse = AgentRunResponse.from_agent_run_response_updates(updates) - # mark final using last update if available - if updates and self._streaming_agent_response_callback is not None: - with contextlib.suppress(Exception): - await self._streaming_agent_response_callback(self._agent_id, updates[-1], True) messages: list[ChatMessage] | None = None with contextlib.suppress(Exception): messages = list(run_result.messages) # type: ignore[assignment] @@ -1870,9 +1829,6 @@ async def _invoke_agent( text = last.text or str(last) msg = ChatMessage(role=role, text=text, author_name=author) await self._emit_agent_message_event(ctx, msg) - if self._agent_response_callback is not None: - with contextlib.suppress(Exception): - await self._agent_response_callback(self._agent_id, msg) return msg msg = ChatMessage( @@ -1881,9 +1837,6 @@ async def _invoke_agent( author_name=self._agent_id, ) await self._emit_agent_message_event(ctx, msg) - if self._agent_response_callback is not None: - with contextlib.suppress(Exception): - await self._agent_response_callback(self._agent_id, msg) return msg @@ -1951,12 +1904,6 @@ async def plan(self, context: MagenticContext) -> ChatMessage: def __init__(self) -> None: self._participants: dict[str, AgentProtocol | Executor] = {} self._manager: MagenticManagerBase | None = None - self._exception_callback: Callable[[Exception], None] | None = None - self._result_callback: Callable[[ChatMessage], Awaitable[None]] | None = None - # Orchestrator-emitted message callback: (orchestrator_id, message, kind) - self._message_callback: Callable[[str, ChatMessage, str], Awaitable[None]] | None = None - self._agent_response_callback: Callable[[str, ChatMessage], Awaitable[None]] | None = None - self._agent_streaming_callback: Callable[[str, AgentRunResponseUpdate, bool], Awaitable[None]] | None = None self._enable_plan_review: bool = False self._checkpoint_storage: CheckpointStorage | None = None @@ -2264,10 +2211,6 @@ def _orchestrator_factory(wiring: GroupChatWiring) -> Executor: return MagenticOrchestratorExecutor( manager=manager, participants=participant_descriptions, - result_callback=self._result_callback, - message_callback=self._message_callback, - agent_response_callback=self._agent_response_callback, - streaming_agent_response_callback=self._agent_streaming_callback, require_plan_signoff=self._enable_plan_review, executor_id="magentic_orchestrator", ) @@ -2279,32 +2222,17 @@ def _participant_factory( agent_executor = MagenticAgentExecutor( spec.participant, spec.name, - agent_response_callback=self._agent_response_callback, - streaming_agent_response_callback=self._agent_streaming_callback, ) orchestrator = wiring.orchestrator if isinstance(orchestrator, MagenticOrchestratorExecutor): orchestrator.register_agent_executor(spec.name, agent_executor) return GroupChatParticipantNodes(entry=agent_executor, exit=agent_executor) + # Magentic provides its own orchestrator via custom factory, so no manager is needed group_builder = GroupChatBuilder( _orchestrator_factory=_orchestrator_factory, _participant_factory=_participant_factory, - ) - # Note: Magentic uses its own orchestrator factory that creates MagenticOrchestratorExecutor - # with MagenticManagerBase. However, GroupChatBuilder.build() requires a manager to be set, - # even though it won't be used (the factory overrides it). Set a dummy manager to satisfy validation. - - class _DummyManager: - """Dummy manager to satisfy GroupChatBuilder validation when using custom factory.""" - - name: str = MAGENTIC_MANAGER_NAME - - async def next_action(self, state: Any) -> Any: # type: ignore[misc] - raise NotImplementedError("Dummy manager should never be called") - - group_builder = group_builder.set_manager(_DummyManager(), display_name=MAGENTIC_MANAGER_NAME) # type: ignore[arg-type] - group_builder = group_builder.participants(self._participants) + ).participants(self._participants) if self._checkpoint_storage is not None: group_builder = group_builder.with_checkpointing(self._checkpoint_storage) diff --git a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py index 6153c56f58c..5ba8b5cc235 100644 --- a/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py +++ b/python/samples/getting_started/workflows/orchestration/magentic_human_plan_update.py @@ -151,7 +151,7 @@ def on_exception(exception: Exception) -> None: print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n") elif isinstance(event, WorkflowOutputEvent): # Capture workflow output during streaming - workflow_output = str(event.data) if event.data is not None else None + workflow_output = str(event.data) if event.data else None completed = True if stream_line_open: From 66754e950b7d7329a24959a435faf29263c7a353 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 17 Oct 2025 17:32:56 +0900 Subject: [PATCH 03/15] Add as_agent docstring clarification --- .../core/agent_framework/_workflows/_workflow.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index d9270bfe02b..1208fd550e0 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -848,11 +848,24 @@ def output_types(self) -> list[type[Any]]: def as_agent(self, name: str | None = None) -> WorkflowAgent: """Create a WorkflowAgent that wraps this workflow. + The returned agent converts standard agent inputs (strings, ChatMessage, or lists of these) + into a list[ChatMessage] that is passed to the workflow's start executor. This conversion + happens in WorkflowAgent._normalize_messages() which transforms: + - str -> [ChatMessage(role=USER, text=str)] + - ChatMessage -> [ChatMessage] + - list[str | ChatMessage] -> list[ChatMessage] (with string elements converted) + + The workflow's start executor must accept list[ChatMessage] as an input type, otherwise + initialization will fail with a ValueError. + Args: name: Optional name for the agent. If None, a default name will be generated. Returns: A WorkflowAgent instance that wraps this workflow. + + Raises: + ValueError: If the workflow's start executor cannot handle list[ChatMessage] input. """ # Import here to avoid circular imports from ._agent import WorkflowAgent From 37b8d4b7dc45948b54bce4304b34ba07a93eff65 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 20 Oct 2025 12:32:06 +0900 Subject: [PATCH 04/15] Standardize orchestration messages to use agent-style inputs. --- .../core/agent_framework/_workflows/_agent.py | 30 +---- .../_workflows/_agent_executor.py | 11 +- .../agent_framework/_workflows/_concurrent.py | 15 ++- .../agent_framework/_workflows/_magentic.py | 123 +++++++----------- .../_workflows/_message_utils.py | 43 ++++++ .../agent_framework/_workflows/_sequential.py | 18 ++- .../core/tests/workflow/test_magentic.py | 4 +- .../devui/agent_framework_devui/_utils.py | 26 +++- 8 files changed, 147 insertions(+), 123 deletions(-) create mode 100644 python/packages/core/agent_framework/_workflows/_message_utils.py diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 9e766d354c8..aedc2bc0115 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -3,7 +3,7 @@ import json import logging import uuid -from collections.abc import AsyncIterable, Sequence +from collections.abc import AsyncIterable from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast @@ -17,7 +17,6 @@ FunctionCallContent, FunctionResultContent, Role, - TextContent, UsageDetails, ) @@ -27,6 +26,7 @@ RequestInfoEvent, WorkflowEvent, ) +from ._message_utils import normalize_messages_input if TYPE_CHECKING: from ._workflow import Workflow @@ -129,7 +129,7 @@ async def run( """ # Collect all streaming updates response_updates: list[AgentRunResponseUpdate] = [] - input_messages = self._normalize_messages(messages) + input_messages = normalize_messages_input(messages) thread = thread or self.get_new_thread() response_id = str(uuid.uuid4()) @@ -163,7 +163,7 @@ async def run_stream( Yields: AgentRunResponseUpdate objects representing the workflow execution progress. """ - input_messages = self._normalize_messages(messages) + input_messages = normalize_messages_input(messages) thread = thread or self.get_new_thread() response_updates: list[AgentRunResponseUpdate] = [] response_id = str(uuid.uuid4()) @@ -223,28 +223,6 @@ async def _run_stream_impl( if update: yield update - def _normalize_messages( - self, - messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None, - ) -> list[ChatMessage]: - """Normalize input messages to a list of ChatMessage objects.""" - if messages is None: - return [] - - if isinstance(messages, str): - return [ChatMessage(role=Role.USER, contents=[TextContent(text=messages)])] - - if isinstance(messages, ChatMessage): - return [messages] - - normalized: list[ChatMessage] = [] - for msg in messages: - if isinstance(msg, str): - normalized.append(ChatMessage(role=Role.USER, contents=[TextContent(text=msg)])) - elif isinstance(msg, ChatMessage): - normalized.append(msg) - return normalized - def _convert_workflow_event_to_agent_update( self, response_id: str, diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index b92c845a4d8..ca09086c651 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -12,6 +12,7 @@ AgentRunUpdateEvent, # type: ignore[reportPrivateUsage] ) from ._executor import Executor, handler +from ._message_utils import normalize_messages_input from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -167,7 +168,7 @@ async def from_response( @handler async def from_str(self, text: str, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]) -> None: """Accept a raw user prompt string and run the agent (one-shot).""" - self._cache = [ChatMessage(role="user", text=text)] # type: ignore[arg-type] + self._cache = normalize_messages_input(text) await self._run_agent_and_emit(ctx) @handler @@ -177,15 +178,15 @@ async def from_message( ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse], ) -> None: """Accept a single ChatMessage as input.""" - self._cache = [message] + self._cache = normalize_messages_input(message) await self._run_agent_and_emit(ctx) @handler async def from_messages( self, - messages: list[ChatMessage], + messages: list[str | ChatMessage], ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse], ) -> None: - """Accept a list of ChatMessage objects as conversation context.""" - self._cache = list(messages) + """Accept a list of chat inputs (strings or ChatMessage) as conversation context.""" + self._cache = normalize_messages_input(messages) await self._run_agent_and_emit(ctx) diff --git a/python/packages/core/agent_framework/_workflows/_concurrent.py b/python/packages/core/agent_framework/_workflows/_concurrent.py index 4429e1c0876..ac573e57a68 100644 --- a/python/packages/core/agent_framework/_workflows/_concurrent.py +++ b/python/packages/core/agent_framework/_workflows/_concurrent.py @@ -13,6 +13,7 @@ from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse from ._checkpoint import CheckpointStorage from ._executor import Executor, handler +from ._message_utils import normalize_messages_input from ._workflow import Workflow, WorkflowBuilder from ._workflow_context import WorkflowContext @@ -49,17 +50,21 @@ async def from_request(self, request: AgentExecutorRequest, ctx: WorkflowContext @handler async def from_str(self, prompt: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: - request = AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=prompt)], should_respond=True) + request = AgentExecutorRequest(messages=normalize_messages_input(prompt), should_respond=True) await ctx.send_message(request) @handler - async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorRequest]) -> None: # type: ignore[name-defined] - request = AgentExecutorRequest(messages=[message], should_respond=True) + async def from_message(self, message: ChatMessage, ctx: WorkflowContext[AgentExecutorRequest]) -> None: + request = AgentExecutorRequest(messages=normalize_messages_input(message), should_respond=True) await ctx.send_message(request) @handler - async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[AgentExecutorRequest]) -> None: # type: ignore[name-defined] - request = AgentExecutorRequest(messages=list(messages), should_respond=True) + async def from_messages( + self, + messages: list[str | ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest], + ) -> None: + request = AgentExecutorRequest(messages=normalize_messages_input(messages), should_respond=True) await ctx.send_message(request) diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index c05c48ef57d..541d9c7576b 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -36,6 +36,7 @@ GroupChatResponseMessage, GroupChatWiring, ) +from ._message_utils import normalize_messages_input from ._model_utils import DictConvertible, encode_value from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse from ._workflow import Workflow, WorkflowRunResult @@ -324,34 +325,54 @@ def _new_participant_descriptions() -> dict[str, str]: @dataclass -class MagenticStartMessage: +class MagenticStartMessage(DictConvertible): """A message to start a magentic workflow.""" - def __init__(self, task: ChatMessage) -> None: - """Create the start message.""" - self.task = task + messages: list[ChatMessage] = field(default_factory=list) - @classmethod - def from_string(cls, task_text: str) -> "MagenticStartMessage": - """Create a MagenticStartMessage from a simple string. + def __init__( + self, + messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None, + *, + task: ChatMessage | None = None, + ) -> None: + normalized = normalize_messages_input(messages) + if task is not None: + normalized += normalize_messages_input(task) + if not normalized: + raise ValueError("MagenticStartMessage requires at least one message input.") + self.messages = normalized - Args: - task_text: The task description as a string. + @property + def task(self) -> ChatMessage: + """Final user message for the task.""" + return self.messages[-1] - Returns: - A MagenticStartMessage with the string converted to a ChatMessage. - """ - return cls(task=ChatMessage(role=Role.USER, text=task_text)) + @classmethod + def from_string(cls, task_text: str) -> "MagenticStartMessage": + """Create a MagenticStartMessage from a simple string.""" + return cls(task_text) def to_dict(self) -> dict[str, Any]: """Create a dict representation of the message.""" - return {"task": self.task.to_dict()} + return { + "messages": [message.to_dict() for message in self.messages], + "task": self.task.to_dict(), + } @classmethod - def from_dict(cls, value: dict[str, Any]) -> "MagenticStartMessage": + def from_dict(cls, data: dict[str, Any]) -> "MagenticStartMessage": """Create from a dict.""" - task = ChatMessage.from_dict(value["task"]) - return cls(task=task) + if "messages" in data: + raw_messages = data["messages"] + if not isinstance(raw_messages, Sequence) or isinstance(raw_messages, (str, bytes)): + raise TypeError("MagenticStartMessage 'messages' must be a sequence.") + messages = [ChatMessage.from_dict(raw) for raw in raw_messages] + return cls(messages) + if "task" in data: + task = ChatMessage.from_dict(data["task"]) + return cls(task) + raise KeyError("Expected 'messages' or 'task' in MagenticStartMessage payload.") @dataclass @@ -975,15 +996,6 @@ def __init__( self._terminated = False # Tracks whether checkpoint state has been applied for this run self._state_restored = False - self._initial_history: list[ChatMessage] | None = None - - @staticmethod - def _select_task_message(conversation: Sequence[ChatMessage]) -> ChatMessage: - for msg in reversed(conversation): - role_value = getattr(msg.role, "value", None) or str(msg.role) - if str(role_value).lower() == Role.USER.value: - return msg - return conversation[-1] def register_agent_executor(self, name: str, executor: "MagenticAgentExecutor") -> None: """Register an agent executor for internal control (no messages).""" @@ -1138,10 +1150,8 @@ async def handle_start_message( task=message.task, participant_descriptions=self._participants, ) - initial_history = self._initial_history - self._initial_history = None - if initial_history: - self._context.chat_history.extend(list(initial_history)) + if message.messages: + self._context.chat_history.extend(message.messages) self._state_restored = True # Non-streaming callback for the orchestrator receipt of the task await self._emit_orchestrator_message(context, message.task, ORCH_MSG_KIND_USER_TASK) @@ -1176,44 +1186,7 @@ async def handle_task_text( MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage ], ) -> None: - message = MagenticStartMessage.from_string(task_text) - if getattr(self, "_terminated", False): - return - logger.info("Magentic Orchestrator: Received start message") - - self._context = MagenticContext( - task=message.task, - participant_descriptions=self._participants, - ) - initial_history = self._initial_history - self._initial_history = None - if initial_history: - self._context.chat_history.extend(list(initial_history)) - self._state_restored = True - # Non-streaming callback for the orchestrator receipt of the task - await self._emit_orchestrator_message(context, message.task, ORCH_MSG_KIND_USER_TASK) - - # Initial planning using the manager with real model calls - self._task_ledger = await self._manager.plan(self._context.clone(deep=True)) - self._context.chat_history.append(self._task_ledger) - await self._emit_orchestrator_message(context, self._task_ledger, ORCH_MSG_KIND_TASK_LEDGER) - - # If plan review is required, send plan review request - if self._require_plan_signoff: - plan_text = getattr(self._task_ledger, "text", "") - request = MagenticPlanReviewRequest( - task_text=message.task.text, - plan_text=plan_text, - round_index=self._plan_review_round, - ) - await context.send_message(request) - return - - # Otherwise start inner loop immediately - ctx2: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage] = cast( - WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], context - ) - await self._run_inner_loop(ctx2) + await self.handle_start_message(MagenticStartMessage.from_string(task_text), context) @handler async def handle_task_message( @@ -1223,7 +1196,7 @@ async def handle_task_message( MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage ], ) -> None: - await self.handle_start_message(MagenticStartMessage(task=task_message), context) + await self.handle_start_message(MagenticStartMessage(task_message), context) @handler async def handle_task_messages( @@ -1233,11 +1206,7 @@ async def handle_task_messages( MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage ], ) -> None: - if not conversation: - raise ValueError("Magentic workflow requires at least one chat message.") - self._initial_history = list(conversation) - task_message = self._select_task_message(conversation) - await self.handle_task_message(task_message, context) + await self.handle_start_message(MagenticStartMessage(conversation), context) @handler async def handle_response_message( @@ -2322,7 +2291,7 @@ async def run_streaming_with_message(self, task_message: ChatMessage) -> AsyncIt Yields: WorkflowEvent: The events generated during the workflow execution. """ - start_message = MagenticStartMessage(task=task_message) + start_message = MagenticStartMessage(task_message) async for event in self._workflow.run_stream(start_message): yield event @@ -2342,8 +2311,8 @@ async def run_stream(self, message: Any | None = None) -> AsyncIterable[Workflow message = MagenticStartMessage.from_string(self._task_text) elif isinstance(message, str): message = MagenticStartMessage.from_string(message) - elif isinstance(message, ChatMessage): - message = MagenticStartMessage(task=message) + elif isinstance(message, (ChatMessage, list)): + message = MagenticStartMessage(message) async for event in self._workflow.run_stream(message): yield event diff --git a/python/packages/core/agent_framework/_workflows/_message_utils.py b/python/packages/core/agent_framework/_workflows/_message_utils.py new file mode 100644 index 00000000000..ad4a9b55f69 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_message_utils.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared helpers for normalizing workflow message inputs.""" + +from collections.abc import Sequence + +from agent_framework import ChatMessage, Role + + +def normalize_messages_input( + messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None, +) -> list[ChatMessage]: + """Normalize heterogeneous message inputs to a list of ChatMessage objects. + + Args: + messages: String, ChatMessage, or sequence of either. None yields empty list. + + Returns: + List of ChatMessage instances suitable for workflow consumption. + """ + if messages is None: + return [] + + if isinstance(messages, str): + return [ChatMessage(role=Role.USER, text=messages)] + + if isinstance(messages, ChatMessage): + return [messages] + + normalized: list[ChatMessage] = [] + for item in messages: + if isinstance(item, str): + normalized.append(ChatMessage(role=Role.USER, text=item)) + elif isinstance(item, ChatMessage): + normalized.append(item) + else: + raise TypeError( + f"Messages sequence must contain only str or ChatMessage instances; found {type(item).__name__}." + ) + return normalized + + +__all__ = ["normalize_messages_input"] diff --git a/python/packages/core/agent_framework/_workflows/_sequential.py b/python/packages/core/agent_framework/_workflows/_sequential.py index f2e81110878..a2ef6b88d4a 100644 --- a/python/packages/core/agent_framework/_workflows/_sequential.py +++ b/python/packages/core/agent_framework/_workflows/_sequential.py @@ -40,7 +40,7 @@ from collections.abc import Sequence from typing import Any -from agent_framework import AgentProtocol, ChatMessage, Role +from agent_framework import AgentProtocol, ChatMessage from ._agent_executor import ( AgentExecutor, @@ -51,6 +51,7 @@ Executor, handler, ) +from ._message_utils import normalize_messages_input from ._workflow import Workflow, WorkflowBuilder from ._workflow_context import WorkflowContext @@ -62,16 +63,21 @@ class _InputToConversation(Executor): @handler async def from_str(self, prompt: str, ctx: WorkflowContext[list[ChatMessage]]) -> None: - await ctx.send_message([ChatMessage(Role.USER, text=prompt)]) + await ctx.send_message(normalize_messages_input(prompt)) @handler - async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined] - await ctx.send_message([message]) + async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: + await ctx.send_message(normalize_messages_input(message)) @handler - async def from_messages(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined] + async def from_messages( + self, + messages: list[str | ChatMessage], + ctx: WorkflowContext[list[ChatMessage]], + ) -> None: # Make a copy to avoid mutation downstream - await ctx.send_message(list(messages)) + normalized = normalize_messages_input(messages) + await ctx.send_message(list(normalized)) class _ResponseToConversation(Executor): diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index 6f0f70949de..da9e0969ff2 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -345,8 +345,8 @@ async def test_magentic_checkpoint_resume_round_trip(): assert orchestrator._context.chat_history # type: ignore[reportPrivateUsage] assert orchestrator._task_ledger is not None # type: ignore[reportPrivateUsage] assert manager2.task_ledger is not None - # Initial message should be the task ledger plan - assert orchestrator._context.chat_history[0].text == orchestrator._task_ledger.text # type: ignore[reportPrivateUsage] + # Latest entry in chat history should be the task ledger plan + assert orchestrator._context.chat_history[-1].text == orchestrator._task_ledger.text # type: ignore[reportPrivateUsage] class _DummyExec(Executor): diff --git a/python/packages/devui/agent_framework_devui/_utils.py b/python/packages/devui/agent_framework_devui/_utils.py index 58aedbd2f3a..19be9d5f354 100644 --- a/python/packages/devui/agent_framework_devui/_utils.py +++ b/python/packages/devui/agent_framework_devui/_utils.py @@ -6,7 +6,10 @@ import json import logging from dataclasses import fields, is_dataclass -from typing import Any, get_args, get_origin +from types import UnionType +from typing import Any, Union, get_args, get_origin + +from agent_framework import ChatMessage logger = logging.getLogger(__name__) @@ -110,10 +113,25 @@ def extract_executor_message_types(executor: Any) -> list[Any]: return message_types +def _contains_chat_message(type_hint: Any) -> bool: + """Check whether the provided type hint directly or indirectly references ChatMessage.""" + if type_hint is ChatMessage: + return True + + origin = get_origin(type_hint) + if origin in (list, tuple): + return any(_contains_chat_message(arg) for arg in get_args(type_hint)) + + if origin in (Union, UnionType): + return any(_contains_chat_message(arg) for arg in get_args(type_hint)) + + return False + + def select_primary_input_type(message_types: list[Any]) -> Any | None: """Choose the most user-friendly input type for workflow inputs. - Prefers str and dict types for better user experience. + Prefers ChatMessage (or containers thereof) and then falls back to primitives. Args: message_types: List of possible message types @@ -124,6 +142,10 @@ def select_primary_input_type(message_types: list[Any]) -> Any | None: if not message_types: return None + for message_type in message_types: + if _contains_chat_message(message_type): + return ChatMessage + preferred = (str, dict) for candidate in preferred: From 86df5aac6528675c0647677cb119f5e941acf72b Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 21 Oct 2025 12:37:46 +0900 Subject: [PATCH 05/15] Simplify group chat constructs --- .../agent_framework/_workflows/__init__.py | 12 +- .../agent_framework/_workflows/__init__.pyi | 18 +- .../agent_framework/_workflows/_group_chat.py | 526 +++++++----------- .../agent_framework/_workflows/_magentic.py | 6 +- ...hat_builder_spec.py => test_group_chat.py} | 10 +- 5 files changed, 225 insertions(+), 347 deletions(-) rename python/packages/core/tests/workflow/{test_group_chat_builder_spec.py => test_group_chat.py} (96%) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 0cefbc9daac..d098a9308d7 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -56,13 +56,13 @@ DEFAULT_MANAGER_INSTRUCTIONS, GroupChatBuilder, GroupChatDirective, - GroupChatManagerProtocol, + GroupChatManagerFn, GroupChatOrchestratorExecutor, - GroupChatParticipantNodes, + GroupChatParticipantPipeline, GroupChatParticipantSpec, GroupChatRequestMessage, GroupChatResponseMessage, - GroupChatState, + GroupChatStateSnapshot, GroupChatTurn, GroupChatWiring, StandardGroupChatManager, @@ -143,13 +143,13 @@ "GraphConnectivityError", "GroupChatBuilder", "GroupChatDirective", - "GroupChatManagerProtocol", + "GroupChatManagerFn", "GroupChatOrchestratorExecutor", - "GroupChatParticipantNodes", + "GroupChatParticipantPipeline", "GroupChatParticipantSpec", "GroupChatRequestMessage", "GroupChatResponseMessage", - "GroupChatState", + "GroupChatStateSnapshot", "GroupChatTurn", "GroupChatWiring", "InMemoryCheckpointStorage", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index b46f0fb3bb5..bd8c3b1aa2e 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -51,13 +51,18 @@ from ._executor import ( ) from ._function_executor import FunctionExecutor, executor from ._group_chat import ( + DEFAULT_MANAGER_INSTRUCTIONS, GroupChatBuilder, GroupChatDirective, - GroupChatManagerProtocol, + GroupChatManagerFn, GroupChatOrchestratorExecutor, + GroupChatParticipantPipeline, + GroupChatParticipantSpec, GroupChatRequestMessage, GroupChatResponseMessage, - GroupChatState, + GroupChatStateSnapshot, + GroupChatTurn, + GroupChatWiring, StandardGroupChatManager, ) from ._magentic import ( @@ -110,6 +115,7 @@ from ._workflow_context import WorkflowContext from ._workflow_executor import WorkflowExecutor __all__ = [ + "DEFAULT_MANAGER_INSTRUCTIONS", "DEFAULT_MAX_ITERATIONS", "AgentExecutor", "AgentExecutorRequest", @@ -135,11 +141,15 @@ __all__ = [ "GraphConnectivityError", "GroupChatBuilder", "GroupChatDirective", - "GroupChatManagerProtocol", + "GroupChatManagerFn", "GroupChatOrchestratorExecutor", + "GroupChatParticipantPipeline", + "GroupChatParticipantSpec", "GroupChatRequestMessage", "GroupChatResponseMessage", - "GroupChatState", + "GroupChatStateSnapshot", + "GroupChatTurn", + "GroupChatWiring", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 91f33ecab32..70e0395008f 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -7,7 +7,7 @@ - GroupChatRequestMessage / GroupChatResponseMessage: canonical envelopes used between the orchestrator and participants. -- GroupChatManagerProtocol: minimal contract for pluggable coordination logic. +- GroupChatManagerFn: minimal asynchronous callable contract for pluggable coordination logic. - GroupChatOrchestratorExecutor: runtime state machine that delegates to a manager to select the next participant or complete the task. - GroupChatBuilder: high-level builder that wires managers and participants @@ -21,9 +21,10 @@ import itertools import json import logging -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable +from types import MappingProxyType +from typing import Any, TypeAlias from uuid import uuid4 from pydantic import BaseModel, ValidationError @@ -75,21 +76,9 @@ class GroupChatTurn: message: ChatMessage -@dataclass -class GroupChatState: - """Snapshot of the current orchestration state provided to managers.""" - - task: ChatMessage - participants: Mapping[str, str] - conversation: Sequence[ChatMessage] - history: Sequence[GroupChatTurn] - pending_agent: str | None - round_index: int - - @dataclass class GroupChatDirective: - """Instruction emitted by a GroupChatManagerProtocol implementation.""" + """Instruction emitted by a GroupChatManagerFn implementation.""" agent_name: str | None = None instruction: str | None = None @@ -101,19 +90,11 @@ class GroupChatDirective: # endregion -# region Manager protocol - - -@runtime_checkable -class GroupChatManagerProtocol(Protocol): - """Interface for orchestration managers that drive group chat workflows.""" +# region Manager callable - @property - def name(self) -> str: ... - async def next_action(self, state: GroupChatState) -> GroupChatDirective: - """Return the next directive based on current conversation state.""" - ... +GroupChatStateSnapshot = Mapping[str, Any] +GroupChatManagerFn = Callable[[GroupChatStateSnapshot], Awaitable[GroupChatDirective]] @dataclass @@ -131,19 +112,7 @@ class GroupChatParticipantSpec: description: str -@dataclass -class GroupChatParticipantNodes: - """Nodes that implement a participant pipeline in the workflow graph. - - Attributes: - entry: First executor in the participant pipeline that receives orchestrator requests - exit: Final executor in the participant pipeline that sends responses back - intermediates: Optional sequence of executors between entry and exit (e.g., AgentExecutor) - """ - - entry: Executor - exit: Executor - intermediates: Sequence[Executor] = field(default_factory=tuple) +GroupChatParticipantPipeline: TypeAlias = Sequence[Executor] @dataclass @@ -158,7 +127,7 @@ class GroupChatWiring: orchestrator: Orchestrator executor instance (populated during build) """ - manager: GroupChatManagerProtocol | None + manager: GroupChatManagerFn | None manager_name: str participants: Mapping[str, GroupChatParticipantSpec] max_rounds: int | None = None @@ -168,187 +137,37 @@ class GroupChatWiring: # endregion -# region Default participant adapters - - -class _GroupChatAgentIngress(Executor): - """Adapter that converts orchestrator requests into agent-compatible execution requests. - - This internal executor sits at the entry point of each agent participant's pipeline, - translating GroupChatRequestMessage envelopes from the orchestrator into - AgentExecutorRequest format that AgentExecutor understands. - - Responsibilities: - - Filter messages by participant name (ignores requests for other participants) - - Extract conversation history from the request envelope - - Append manager instructions as a user message when present - - Forward the formatted request to AgentExecutor - - Pipeline position: orchestrator -> ingress -> AgentExecutor -> egress -> orchestrator - - Why this adapter exists: - The orchestrator operates on a broadcast model where all participants receive - GroupChatRequestMessage envelopes, but each ingress filters for its specific - agent_name. This keeps routing logic simple and makes the graph structure explicit. - - Args: - agent_name: Unique name of the participant this ingress serves - """ - - def __init__(self, agent_name: str) -> None: - super().__init__(f"groupchat_ingress:{agent_name}") - self._agent_name = agent_name - - @handler - async def handle_request( - self, - message: GroupChatRequestMessage, - ctx: WorkflowContext[AgentExecutorRequest], - ) -> None: - """Process GroupChatRequestMessage and forward to AgentExecutor if targeted. - - Args: - message: Request envelope from the orchestrator - ctx: Workflow context for sending the transformed request - - Behavior: - - Silently ignores messages not addressed to this participant - - Clones conversation to avoid shared state mutation - - Appends manager instruction as USER message if provided - - Always sets should_respond=True to ensure agent produces output - """ - if message.agent_name != self._agent_name: - return - conversation = list(message.conversation) - if message.instruction: - conversation.append(ChatMessage(role=Role.USER, text=message.instruction)) - await ctx.send_message(AgentExecutorRequest(messages=conversation, should_respond=True)) - - -class _GroupChatAgentEgress(Executor): - """Adapter that converts agent responses into orchestrator-compatible response envelopes. - - This internal executor sits at the exit point of each agent participant's pipeline, - translating AgentExecutorResponse into GroupChatResponseMessage format that the - orchestrator expects. - - Responsibilities: - - Extract the final assistant message from the agent's response - - Ensure author_name is populated for conversation tracking - - Wrap the message in a GroupChatResponseMessage envelope - - Send the envelope back to the orchestrator - - Pipeline position: orchestrator -> ingress -> AgentExecutor -> egress -> orchestrator - - Why this adapter exists: - AgentExecutorResponse contains rich metadata (full_conversation, streaming events) - but the orchestrator only needs the final assistant message. The egress adapter - normalizes this and ensures consistent author attribution for multi-agent tracking. - - Args: - agent_name: Unique name of the participant this egress serves - """ - - def __init__(self, agent_name: str) -> None: - super().__init__(f"groupchat_egress:{agent_name}") - self._agent_name = agent_name - - @handler - async def handle_response( - self, - response: AgentExecutorResponse, - ctx: WorkflowContext[GroupChatResponseMessage], - ) -> None: - """Extract final assistant message and send to orchestrator as response envelope. - - Args: - response: Response from AgentExecutor containing agent output - ctx: Workflow context for sending the response envelope - - Behavior: - - Searches agent_run_response.messages first, then full_conversation - - Scans backwards to find the most recent ASSISTANT role message - - Creates empty assistant message if no output found (defensive) - - Populates author_name if missing to preserve conversation attribution - - Wraps message in GroupChatResponseMessage for orchestrator routing - """ - # Prefer the final assistant message from the agent run. - final_message: ChatMessage | None = None - candidate_sequences: tuple[Sequence[ChatMessage] | None, ...] = ( - response.agent_run_response.messages, - response.full_conversation, - ) - for sequence in candidate_sequences: - if not sequence: - continue - for candidate in reversed(sequence): - if getattr(candidate, "role", None) == Role.ASSISTANT: - final_message = candidate - break - if final_message is not None: - break - - if final_message is None: - final_message = ChatMessage(role=Role.ASSISTANT, text="", author_name=self._agent_name) - elif not final_message.author_name: - message_dict = final_message.to_dict() - message_dict["author_name"] = self._agent_name - final_message = ChatMessage.from_dict(message_dict) - - await ctx.send_message( - GroupChatResponseMessage( - agent_name=self._agent_name, - message=final_message, - ) - ) +# region Default participant factory def _default_participant_factory( spec: GroupChatParticipantSpec, _: GroupChatWiring, -) -> GroupChatParticipantNodes: +) -> GroupChatParticipantPipeline: """Default factory for constructing participant pipeline nodes in the workflow graph. - Creates a three-node pipeline for AgentProtocol participants (ingress -> executor -> egress) - or a single-node passthrough for Executor participants. - - This is the internal implementation used by GroupChatBuilder when no custom factory - is provided. It wires agents with the standard adapters that handle protocol translation - between the orchestrator's envelope format and AgentExecutor's request/response format. + Creates a single AgentExecutor node for AgentProtocol participants or a passthrough executor + for custom participants. Translation between group-chat envelopes and the agent runtime is now + handled inside the orchestrator, removing the need for dedicated ingress/egress adapters. Args: spec: Participant specification containing name, instance, and description _: GroupChatWiring configuration (unused by default implementation) Returns: - GroupChatParticipantNodes with entry/exit executors and optional intermediates - - Behavior for AgentProtocol participants: - - Creates _GroupChatAgentIngress to translate orchestrator requests - - Wraps agent in AgentExecutor for streaming and observability - - Creates _GroupChatAgentEgress to translate agent responses - - Returns three-node pipeline: ingress -> executor -> egress - - Behavior for Executor participants: - - Assumes executor handles GroupChatRequestMessage directly - - Returns executor as both entry and exit (single node, no adapters) - - Expects executor to emit GroupChatResponseMessage - - Pipeline topology (agent case): - orchestrator --GroupChatRequestMessage--> ingress - ingress --AgentExecutorRequest--> agent_executor - agent_executor --AgentExecutorResponse--> egress - egress --GroupChatResponseMessage--> orchestrator + Sequence of executors representing the participant pipeline in execution order + + Behavior: + - AgentProtocol participants are wrapped in AgentExecutor with deterministic IDs + - Executor participants are wired directly without additional adapters """ participant = spec.participant if isinstance(participant, Executor): - return GroupChatParticipantNodes(entry=participant, exit=participant) + return (participant,) agent = participant - ingress = _GroupChatAgentIngress(spec.name) agent_executor = AgentExecutor(agent, id=f"groupchat_agent:{spec.name}") - egress = _GroupChatAgentEgress(spec.name) - return GroupChatParticipantNodes(entry=ingress, exit=egress, intermediates=[agent_executor]) + return (agent_executor,) # endregion @@ -368,7 +187,7 @@ class GroupChatOrchestratorExecutor(Executor): - Accept initial input as str, ChatMessage, or list[ChatMessage] - Maintain conversation history and turn tracking - Query manager for next action (select participant or finish) - - Route requests to selected participants via GroupChatRequestMessage + - Route requests to selected participants using AgentExecutorRequest or GroupChatRequestMessage - Collect participant responses and append to conversation - Enforce optional round limits to prevent infinite loops - Yield final completion message and transition to idle state @@ -381,8 +200,8 @@ class GroupChatOrchestratorExecutor(Executor): - _round_index: Count of manager selection rounds for limit enforcement Manager interaction: - The orchestrator builds GroupChatState snapshots and passes them to the manager's - next_action() method. The manager returns a GroupChatDirective indicating either: + The orchestrator builds immutable state snapshots and passes them to the manager + callable. The manager returns a GroupChatDirective indicating either: - Next participant to speak (with optional instruction) - Finish signal (with optional final message) @@ -397,7 +216,7 @@ class GroupChatOrchestratorExecutor(Executor): - Broadcast routing to participants keeps graph structure simple Args: - manager: Manager instance implementing next_action() for speaker selection + manager: Callable that selects the next participant or finishes based on state snapshot participants: Mapping of participant names to descriptions (for manager context) manager_name: Display name for manager in conversation history max_rounds: Optional limit on manager selection rounds (None = unlimited) @@ -406,7 +225,7 @@ class GroupChatOrchestratorExecutor(Executor): def __init__( self, - manager: GroupChatManagerProtocol, + manager: GroupChatManagerFn, *, participants: Mapping[str, str], manager_name: str, @@ -424,6 +243,10 @@ def __init__( self._round_index = 0 self._max_rounds = max_rounds self._pending_initial_conversation: list[ChatMessage] | None = None + self._participant_entry_ids: dict[str, str] = {} + self._agent_executor_ids: dict[str, str] = {} + self._executor_id_to_participant: dict[str, str] = {} + self._non_agent_participants: set[str] = set() @staticmethod def _select_task_message(conversation: Sequence[ChatMessage]) -> ChatMessage: @@ -466,14 +289,14 @@ def _role_value(message: ChatMessage) -> str: role = getattr(message.role, "value", None) or str(message.role) return str(role) - def _build_state(self) -> GroupChatState: + def _build_state(self) -> GroupChatStateSnapshot: """Build a snapshot of current orchestration state for the manager. Packages conversation history, participant metadata, and round tracking into - a GroupChatState that the manager uses to make speaker selection decisions. + an immutable mapping that the manager uses to make speaker selection decisions. Returns: - GroupChatState containing all context needed for manager decision-making + Mapping containing all context needed for manager decision-making Raises: RuntimeError: If called before task message initialization (defensive check) @@ -484,19 +307,29 @@ def _build_state(self) -> GroupChatState: """ if self._task_message is None: raise RuntimeError("GroupChatOrchestratorExecutor state not initialized with task message.") - return GroupChatState( - task=self._task_message, - participants=self._participants, - conversation=tuple(self._conversation), - history=tuple(self._history), - pending_agent=self._pending_agent, - round_index=self._round_index, - ) + snapshot: dict[str, Any] = { + "task": self._task_message, + "participants": dict(self._participants), + "conversation": tuple(self._conversation), + "history": tuple(self._history), + "pending_agent": self._pending_agent, + "round_index": self._round_index, + } + return MappingProxyType(snapshot) + + def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: + """Record routing details for a participant's entry executor.""" + self._participant_entry_ids[name] = entry_id + if is_agent: + self._agent_executor_ids[name] = entry_id + self._executor_id_to_participant[entry_id] = name + else: + self._non_agent_participants.add(name) async def _apply_directive( self, directive: GroupChatDirective, - ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: """Execute a manager directive by either finishing the workflow or routing to a participant. @@ -518,8 +351,8 @@ async def _apply_directive( Behavior for agent selection: - Validates agent_name exists in participants - Optionally appends manager instruction as USER message - - Builds GroupChatRequestMessage with full conversation context - - Sends request to workflow (participant ingress filters for agent_name) + - Prepares full conversation context for the participant + - Routes request directly to the participant entry executor - Increments round counter and enforces max_rounds if configured Round limit enforcement: @@ -555,6 +388,10 @@ async def _apply_directive( if agent_name not in self._participants: raise ValueError(f"Manager selected unknown participant '{agent_name}'.") + entry_id = self._participant_entry_ids.get(agent_name) + if entry_id is None: + raise ValueError(f"No registered entry executor for participant '{agent_name}'.") + instruction = directive.instruction or "" conversation = list(self._conversation) if instruction: @@ -567,15 +404,22 @@ async def _apply_directive( self._conversation.append(manager_message) self._history.append(GroupChatTurn(self._manager_name, "manager", manager_message)) - request = GroupChatRequestMessage( - agent_name=agent_name, - conversation=conversation, - task=self._task_message, - metadata=dict(directive.metadata or {}), - ) self._pending_agent = agent_name self._round_index += 1 - await ctx.send_message(request) + + if agent_name in self._agent_executor_ids: + await ctx.send_message( + AgentExecutorRequest(messages=conversation, should_respond=True), + target_id=entry_id, + ) + else: + request = GroupChatRequestMessage( + agent_name=agent_name, + conversation=conversation, + task=self._task_message, + metadata=dict(directive.metadata or {}), + ) + await ctx.send_message(request, target_id=entry_id) if self._max_rounds is not None and self._round_index >= self._max_rounds: logger.warning( @@ -594,10 +438,73 @@ async def _apply_directive( ctx, ) + async def _ingest_participant_message( + self, + participant_name: str, + message: ChatMessage, + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ) -> None: + """Common response ingestion logic shared by agent and custom participants.""" + if participant_name not in self._participants: + logger.debug("Ignoring response from unknown participant '%s'.", participant_name) + return + + if not message.author_name: + message_dict = message.to_dict() + message_dict["author_name"] = participant_name + message = ChatMessage.from_dict(message_dict) + + self._conversation.append(message) + self._history.append(GroupChatTurn(participant_name, "agent", message)) + self._pending_agent = None + + if self._max_rounds is not None and self._round_index >= self._max_rounds: + logger.warning( + "GroupChatOrchestratorExecutor reached max_rounds=%s after receiving agent response.", + self._max_rounds, + ) + await ctx.yield_output( + ChatMessage( + role=Role.ASSISTANT, + text="Conversation halted after reaching manager round limit.", + author_name=self._manager_name, + ) + ) + return + + directive = await self._manager(self._build_state()) + await self._apply_directive(directive, ctx) + + @staticmethod + def _extract_agent_message(response: AgentExecutorResponse, participant_name: str) -> ChatMessage: + """Select the final assistant message from an AgentExecutor response.""" + final_message: ChatMessage | None = None + candidate_sequences: tuple[Sequence[ChatMessage] | None, ...] = ( + response.agent_run_response.messages, + response.full_conversation, + ) + for sequence in candidate_sequences: + if not sequence: + continue + for candidate in reversed(sequence): + if getattr(candidate, "role", None) == Role.ASSISTANT: + final_message = candidate + break + if final_message is not None: + break + + if final_message is None: + final_message = ChatMessage(role=Role.ASSISTANT, text="", author_name=participant_name) + elif not final_message.author_name: + message_dict = final_message.to_dict() + message_dict["author_name"] = participant_name + final_message = ChatMessage.from_dict(message_dict) + return final_message + async def _handle_task_message( self, task_message: ChatMessage, - ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: """Initialize orchestrator state and start the manager-directed conversation loop. @@ -647,14 +554,14 @@ async def _handle_task_message( self._history = [GroupChatTurn("user", "user", task_message)] self._pending_agent = None self._round_index = 0 - directive = await self._manager.next_action(self._build_state()) + directive = await self._manager(self._build_state()) await self._apply_directive(directive, ctx) @handler async def handle_str( self, task: str, - ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: """Handler for string input as workflow entry point. @@ -673,7 +580,7 @@ async def handle_str( async def handle_chat_message( self, task_message: ChatMessage, - ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: """Handler for ChatMessage input as workflow entry point. @@ -692,7 +599,7 @@ async def handle_chat_message( async def handle_conversation( self, conversation: list[ChatMessage], - ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: """Handler for conversation history as workflow entry point. @@ -730,68 +637,27 @@ async def handle_conversation( async def handle_agent_response( self, response: GroupChatResponseMessage, - ctx: WorkflowContext[GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: - """Handler for participant responses returning to the orchestrator. - - This is the completion point of the participant->orchestrator loop. After a - participant processes a request and returns a response, this handler updates - orchestrator state and queries the manager for the next action. + """Handle responses from custom participant executors.""" + await self._ingest_participant_message(response.agent_name, response.message, ctx) - Args: - response: Response envelope from participant egress - ctx: Workflow context - - Behavior: - - Validates agent_name matches a known participant (defensive) - - Ensures message has author_name for conversation attribution - - Appends message to conversation history - - Records turn in history with agent name and role - - Clears pending_agent (request fulfilled) - - Checks if max_rounds reached (yields completion if so) - - Queries manager for next action - - Applies directive to continue or finish - - Round limit handling: - If max_rounds is reached after receiving a response, yields a default - completion message instead of querying the manager. This prevents the - manager from selecting another participant when the limit is exhausted. - - Defensive behavior: - Silently ignores responses from unknown participants (shouldn't happen - in normal operation, but protects against graph misconfiguration). - """ - agent_name = response.agent_name - if agent_name not in self._participants: - logger.debug("Ignoring response from unknown participant '%s'.", agent_name) - return - - message = response.message - if not message.author_name: - message_dict = message.to_dict() - message_dict["author_name"] = agent_name - message = ChatMessage.from_dict(message_dict) - - self._conversation.append(message) - self._history.append(GroupChatTurn(agent_name, "agent", message)) - self._pending_agent = None - - if self._max_rounds is not None and self._round_index >= self._max_rounds: - logger.warning( - "GroupChatOrchestratorExecutor reached max_rounds=%s after receiving agent response.", - self._max_rounds, - ) - await ctx.yield_output( - ChatMessage( - role=Role.ASSISTANT, - text="Conversation halted after reaching manager round limit.", - author_name=self._manager_name, - ) + @handler + async def handle_agent_executor_response( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ) -> None: + """Handle direct AgentExecutor responses.""" + participant_name = self._executor_id_to_participant.get(response.executor_id) + if participant_name is None: + logger.debug( + "Ignoring response from unregistered agent executor '%s'.", + response.executor_id, ) return - - directive = await self._manager.next_action(self._build_state()) - await self._apply_directive(directive, ctx) + message = self._extract_agent_message(response, participant_name) + await self._ingest_participant_message(participant_name, message, ctx) def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: @@ -881,7 +747,7 @@ def __init__( self, *, _orchestrator_factory: Callable[[GroupChatWiring], Executor] | None = None, - _participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantNodes] + _participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantPipeline] | None = None, ) -> None: """Initialize the GroupChatBuilder. @@ -894,7 +760,7 @@ def __init__( """ self._participants: dict[str, AgentProtocol | Executor] = {} self._participant_descriptions: dict[str, str] = {} - self._manager: GroupChatManagerProtocol | None = None + self._manager: GroupChatManagerFn | None = None self._manager_name: str = "manager" self._checkpoint_storage: CheckpointStorage | None = None self._max_rounds: int | None = None @@ -902,27 +768,18 @@ def __init__( self._orchestrator_factory = _orchestrator_factory or _default_orchestrator_factory self._participant_factory = _participant_factory or _default_participant_factory - def set_manager(self, manager: GroupChatManagerProtocol, *, display_name: str | None = None) -> "GroupChatBuilder": - """Configure the orchestration manager that selects participants and completes tasks. + def set_manager(self, manager: GroupChatManagerFn, *, display_name: str | None = None) -> "GroupChatBuilder": + """Configure the orchestration manager callable that selects participants and completes tasks. - The manager receives conversation state and returns directives indicating which + The callable receives an immutable conversation snapshot and returns directives indicating which participant should speak next or whether the task is complete. Args: - manager: Implementation of GroupChatManagerProtocol for orchestration logic + manager: Awaitable callable accepting the state snapshot and returning a GroupChatDirective display_name: Optional custom name for the manager in conversation history Returns: Self for fluent chaining - - Usage: - - .. code-block:: python - - from agent_framework import GroupChatBuilder, StandardGroupChatManager - - manager = StandardGroupChatManager(chat_client, instructions="Custom instructions") - workflow = GroupChatBuilder().set_manager(manager, display_name="coordinator").build() """ self._manager = manager resolved_name = display_name or getattr(manager, "name", None) or "manager" @@ -1131,9 +988,9 @@ def build(self) -> Workflow: Wiring pattern: - Orchestrator receives initial input (str, ChatMessage, or list[ChatMessage]) - Orchestrator queries manager for next action (participant selection or finish) - - If participant selected: request routed to participant entry node - - Participant pipeline: ingress -> (agent executor) -> egress - - Egress sends response back to orchestrator + - If participant selected: request routed directly to participant entry node + - Participant pipeline: AgentExecutor for agents or custom executor chains + - Participant response flows back to orchestrator - Orchestrator updates state and queries manager again - When manager returns finish directive: orchestrator yields final message and becomes idle @@ -1171,18 +1028,27 @@ def build(self) -> Workflow: workflow_builder = WorkflowBuilder().set_start_executor(orchestrator) for name, spec in participant_specs.items(): - nodes = self._participant_factory(spec, wiring) - chain: list[Executor] = [nodes.entry, *nodes.intermediates, nodes.exit] - target_name = name - - def _route(msg: Any, expected: str = target_name) -> bool: - return isinstance(msg, GroupChatRequestMessage) and msg.agent_name == expected + pipeline = list(self._participant_factory(spec, wiring)) + if not pipeline: + raise ValueError( + f"Participant factory returned an empty pipeline for '{name}'. " + "Provide at least one executor per participant." + ) + entry_executor = pipeline[0] + exit_executor = pipeline[-1] + register_entry = getattr(orchestrator, "register_participant_entry", None) + if callable(register_entry): + register_entry( + name, + entry_id=entry_executor.id, + is_agent=not isinstance(spec.participant, Executor), + ) - workflow_builder = workflow_builder.add_edge(orchestrator, nodes.entry, condition=_route) - for upstream, downstream in itertools.pairwise(chain): + workflow_builder = workflow_builder.add_edge(orchestrator, entry_executor) + for upstream, downstream in itertools.pairwise(pipeline): workflow_builder = workflow_builder.add_edge(upstream, downstream) - if nodes.exit is not orchestrator: - workflow_builder = workflow_builder.add_edge(nodes.exit, orchestrator) + if exit_executor is not orchestrator: + workflow_builder = workflow_builder.add_edge(exit_executor, orchestrator) if self._request_handler is not None: handler_executor, condition = self._request_handler @@ -1235,7 +1101,7 @@ class _ManagerDirectiveModel(BaseModel): """ -class StandardGroupChatManager(GroupChatManagerProtocol): +class StandardGroupChatManager: """LLM-backed manager that produces directives via structured output. This is the default manager implementation for group chat workflows. It uses an LLM @@ -1243,7 +1109,7 @@ class StandardGroupChatManager(GroupChatManagerProtocol): descriptions, and custom instructions. Coordination strategy: - - Receives GroupChatState snapshot with full conversation history + - Receives immutable state snapshot with full conversation history - Formats system prompt with instructions, task, and participant descriptions - Appends conversation context and structured output prompt - Calls LLM with response_format=_ManagerDirectiveModel for type safety @@ -1285,17 +1151,19 @@ def __init__( def name(self) -> str: return self._name - async def next_action(self, state: GroupChatState) -> GroupChatDirective: - participants_section = "\n".join( - f"- {agent}: {description}" for agent, description in state.participants.items() - ) + async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: + participants = state["participants"] + task_message = state["task"] + conversation = state["conversation"] + + participants_section = "\n".join(f"- {agent}: {description}" for agent, description in participants.items()) system_message = ChatMessage( role=Role.SYSTEM, - text=(f"{self._instructions}\n\nTask:\n{state.task.text}\n\nParticipants:\n{participants_section}"), + text=(f"{self._instructions}\n\nTask:\n{task_message.text}\n\nParticipants:\n{participants_section}"), ) - messages: list[ChatMessage] = [system_message, *state.conversation] + messages: list[ChatMessage] = [system_message, *conversation] messages.append( ChatMessage( role=Role.USER, @@ -1337,7 +1205,7 @@ async def next_action(self, state: GroupChatState) -> GroupChatDirective: next_agent = directive_obj.next_agent if not next_agent: raise RuntimeError("Manager directive missing next_agent while finish is False.") - if next_agent not in state.participants: + if next_agent not in participants: raise RuntimeError(f"Manager selected unknown participant '{next_agent}'.") return GroupChatDirective( diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 03dce242b52..9c4ca0090ca 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -30,7 +30,7 @@ from ._executor import Executor, handler from ._group_chat import ( GroupChatBuilder, - GroupChatParticipantNodes, + GroupChatParticipantPipeline, GroupChatParticipantSpec, GroupChatRequestMessage, GroupChatResponseMessage, @@ -2187,7 +2187,7 @@ def _orchestrator_factory(wiring: GroupChatWiring) -> Executor: def _participant_factory( spec: GroupChatParticipantSpec, wiring: GroupChatWiring, - ) -> GroupChatParticipantNodes: + ) -> GroupChatParticipantPipeline: agent_executor = MagenticAgentExecutor( spec.participant, spec.name, @@ -2195,7 +2195,7 @@ def _participant_factory( orchestrator = wiring.orchestrator if isinstance(orchestrator, MagenticOrchestratorExecutor): orchestrator.register_agent_executor(spec.name, agent_executor) - return GroupChatParticipantNodes(entry=agent_executor, exit=agent_executor) + return (agent_executor,) # Magentic provides its own orchestrator via custom factory, so no manager is needed group_builder = GroupChatBuilder( diff --git a/python/packages/core/tests/workflow/test_group_chat_builder_spec.py b/python/packages/core/tests/workflow/test_group_chat.py similarity index 96% rename from python/packages/core/tests/workflow/test_group_chat_builder_spec.py rename to python/packages/core/tests/workflow/test_group_chat.py index 3e139530c7b..46fd03a1ae3 100644 --- a/python/packages/core/tests/workflow/test_group_chat_builder_spec.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -11,8 +11,7 @@ ChatMessage, GroupChatBuilder, GroupChatDirective, - GroupChatManagerProtocol, - GroupChatState, + GroupChatStateSnapshot, MagenticAgentMessageEvent, MagenticBuilder, MagenticContext, @@ -58,7 +57,7 @@ async def _stream() -> AsyncIterable[AgentRunResponseUpdate]: return _stream() -class SequenceManager(GroupChatManagerProtocol): +class SequenceManager: def __init__(self) -> None: self._step = 0 @@ -66,8 +65,9 @@ def __init__(self) -> None: def name(self) -> str: return "manager" - async def next_action(self, state: GroupChatState) -> GroupChatDirective: - participant_names = list(state.participants.keys()) + async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: + participants = state["participants"] + participant_names = list(participants.keys()) if self._step == 0: self._step += 1 return GroupChatDirective(agent_name=participant_names[0], instruction="start") From 567e37df742d640f8e83a29f7b795154d29c3a8b Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 21 Oct 2025 12:53:52 +0900 Subject: [PATCH 06/15] Further cleanup --- .../agent_framework/_workflows/_group_chat.py | 53 +++++++------------ .../agent_framework/_workflows/_magentic.py | 6 +-- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 70e0395008f..eb7db4120f5 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -242,36 +242,13 @@ def __init__( self._pending_agent: str | None = None self._round_index = 0 self._max_rounds = max_rounds + # Stashes the initial conversation list until _handle_task_message normalizes it into _conversation. self._pending_initial_conversation: list[ChatMessage] | None = None self._participant_entry_ids: dict[str, str] = {} self._agent_executor_ids: dict[str, str] = {} self._executor_id_to_participant: dict[str, str] = {} self._non_agent_participants: set[str] = set() - @staticmethod - def _select_task_message(conversation: Sequence[ChatMessage]) -> ChatMessage: - """Extract the primary user task message from a conversation history. - - Scans backwards through the conversation to find the most recent USER role message, - which is treated as the main task description. Falls back to the last message if - no user message is found. - - Args: - conversation: Sequence of chat messages (may include system, user, assistant) - - Returns: - The task message to provide to the manager for context - - Usage: - Called when workflow receives a list[ChatMessage] as initial input to identify - which message represents the user's task request. - """ - for msg in reversed(conversation): - role_value = getattr(msg.role, "value", None) or str(msg.role) - if str(role_value).lower() == Role.USER.value: - return msg - return conversation[-1] - @staticmethod def _role_value(message: ChatMessage) -> str: """Extract string role value from a ChatMessage, handling enum and string cases. @@ -532,9 +509,9 @@ async def _handle_task_message( - _round_index: 0 (first manager query) Why pending_initial_conversation exists: - The handle_conversation handler receives a list[ChatMessage] and needs to - extract the task message before calling this method. The full list is stashed - in _pending_initial_conversation to preserve all context when initializing state. + The handle_conversation handler supplies an explicit task (the first message in + the list) but still forwards the entire conversation for context. The full list is + stashed in _pending_initial_conversation to preserve all context when initializing state. """ self._task_message = task_message if self._pending_initial_conversation: @@ -603,7 +580,7 @@ async def handle_conversation( ) -> None: """Handler for conversation history as workflow entry point. - Accepts a pre-existing conversation and extracts the primary task message. + Accepts a pre-existing conversation and uses the first message in the list as the task. Preserves the full conversation for state initialization. Args: @@ -630,7 +607,7 @@ async def handle_conversation( if not conversation: raise ValueError("GroupChat workflow requires at least one chat message.") self._pending_initial_conversation = list(conversation) - task_message = self._select_task_message(conversation) + task_message = conversation[0] await self._handle_task_message(task_message, ctx) @handler @@ -835,18 +812,24 @@ def participants( ) """ combined: dict[str, AgentProtocol | Executor] = {} + + def _add(name: str, participant: AgentProtocol | Executor) -> None: + if not name: + raise ValueError("participant names must be non-empty strings") + if name in combined or name in self._participants: + raise ValueError(f"Duplicate participant name '{name}' supplied.") + combined[name] = participant + if participants: - combined.update(participants) - combined.update(named_participants) + for name, participant in participants.items(): + _add(name, participant) + for name, participant in named_participants.items(): + _add(name, participant) if not combined: raise ValueError("participants cannot be empty") for name, participant in combined.items(): - if not name: - raise ValueError("participant names must be non-empty strings") - if name in self._participants: - raise ValueError(f"Duplicate participant name '{name}' supplied.") self._participants[name] = participant description = "" if isinstance(participant, Executor): diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 9c4ca0090ca..7917b934046 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterable, Sequence from dataclasses import dataclass, field from enum import Enum -from typing import Any, Literal, Protocol, TypeVar, Union, cast +from typing import Any, Protocol, TypeVar, Union, cast from uuid import uuid4 from agent_framework import ( @@ -104,7 +104,6 @@ class MagenticOrchestratorMessageEvent(WorkflowEvent): orchestrator_id: str = "" message: ChatMessage | None = None kind: str = "" - source: Literal["orchestrator"] = field(init=False, default="orchestrator") def __post_init__(self) -> None: super().__init__(data=self.message) @@ -120,7 +119,6 @@ class MagenticAgentDeltaEvent(WorkflowEvent): function_result_id: str | None = None function_result: Any | None = None role: Role | None = None - source: Literal["agent"] = field(init=False, default="agent") def __post_init__(self) -> None: super().__init__(data=self.text) @@ -130,7 +128,6 @@ def __post_init__(self) -> None: class MagenticAgentMessageEvent(WorkflowEvent): agent_id: str = "" message: ChatMessage | None = None - source: Literal["agent"] = field(init=False, default="agent") def __post_init__(self) -> None: super().__init__(data=self.message) @@ -139,7 +136,6 @@ def __post_init__(self) -> None: @dataclass class MagenticFinalResultEvent(WorkflowEvent): message: ChatMessage | None = None - source: Literal["workflow"] = field(init=False, default="workflow") def __post_init__(self) -> None: super().__init__(data=self.message) From 0e41a334f4ee4b0350d654f0009b86665502dadd Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 21 Oct 2025 16:03:33 +0900 Subject: [PATCH 07/15] Add sk to af group chat migration sample. Update README. --- .../semantic-kernel-migration/README.md | 47 ++- .../orchestrations/group_chat.py | 276 ++++++++++++++++++ 2 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 python/samples/semantic-kernel-migration/orchestrations/group_chat.py diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index e3261a58e3f..6b8ec8cd19f 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -4,13 +4,40 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent Framework (AF) with minimal guesswork. Each script pairs SK code with its AF equivalent so you can compare primitives, tooling, and orchestration patterns side by side while you migrate production workloads. ## What’s Included -- `chat_completion/` – SK `ChatCompletionAgent` scenarios and their AF `ChatAgent` counterparts (basic chat, tooling, threading/streaming). -- `azure_ai_agent/` – Remote Azure AI agent examples, including hosted code interpreter and explicit thread reuse. -- `openai_assistant/` – Assistants API migrations covering basic usage, code interpreter, and custom function tools. -- `openai_responses/` – Responses API parity samples with tooling and structured JSON output. -- `copilot_studio/` – Copilot Studio agent parity, tools, and streaming examples. -- `orchestrations/` – Sequential, Concurrent, and Magentic workflow migrations that mirror SK Team abstractions. -- `processes/` – Fan-out/fan-in and nested process examples that contrast SK’s Process Framework with AF workflows. + +### Chat completion parity +- [01_basic_chat_completion.py](chat_completion/01_basic_chat_completion.py) — Minimal SK `ChatCompletionAgent` and AF `ChatAgent` conversation. +- [02_chat_completion_with_tool.py](chat_completion/02_chat_completion_with_tool.py) — Adds a simple tool/function call in both SDKs. +- [03_chat_completion_thread_and_stream.py](chat_completion/03_chat_completion_thread_and_stream.py) — Demonstrates thread reuse and streaming prompts. + +### Azure AI agent parity +- [01_basic_azure_ai_agent.py](azure_ai_agent/01_basic_azure_ai_agent.py) — Create and run an Azure AI agent end to end. +- [02_azure_ai_agent_with_code_interpreter.py](azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py) — Enable hosted code interpreter/tool execution. +- [03_azure_ai_agent_threads_and_followups.py](azure_ai_agent/03_azure_ai_agent_threads_and_followups.py) — Persist threads and follow-ups across invocations. + +### OpenAI Assistants API parity +- [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison. +- [02_openai_assistant_with_code_interpreter.py](openai_assistant/02_openai_assistant_with_code_interpreter.py) — Code interpreter tool usage. +- [03_openai_assistant_function_tool.py](openai_assistant/03_openai_assistant_function_tool.py) — Custom function tooling. + +### OpenAI Responses API parity +- [01_basic_responses_agent.py](openai_responses/01_basic_responses_agent.py) — Basic responses agent migration. +- [02_responses_agent_with_tool.py](openai_responses/02_responses_agent_with_tool.py) — Tool-augmented responses workflows. +- [03_responses_agent_structured_output.py](openai_responses/03_responses_agent_structured_output.py) — Structured JSON output alignment. + +### Copilot Studio parity +- [01_basic_copilot_studio_agent.py](copilot_studio/01_basic_copilot_studio_agent.py) — Minimal Copilot Studio agent invocation. +- [02_copilot_studio_streaming.py](copilot_studio/02_copilot_studio_streaming.py) — Streaming responses from Copilot Studio agents. + +### Orchestrations +- [sequential.py](orchestrations/sequential.py) — Step-by-step SK Team → AF `SequentialBuilder` migration. +- [concurrent_basic.py](orchestrations/concurrent_basic.py) — Concurrent orchestration parity. +- [group_chat.py](orchestrations/group_chat.py) — Group chat coordination with an LLM-backed manager in both SDKs. +- [magentic.py](orchestrations/magentic.py) — Magentic Team orchestration vs. AF builder wiring. + +### Processes +- [fan_out_fan_in_process.py](processes/fan_out_fan_in_process.py) — Fan-out/fan-in comparison between SK Process Framework and AF workflows. +- [nested_process.py](processes/nested_process.py) — Nested process orchestration vs. AF sub-workflows. Each script is fully async and the `main()` routine runs both implementations back to back so you can observe their outputs in a single execution. @@ -23,14 +50,14 @@ Each script is fully async and the `main()` routine runs both implementations ba ## Running Single-Agent Samples From the repository root: ``` -python samantic-kernel-migration/chat_completion/01_basic_chat_completion.py +python samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py ``` Every script accepts no CLI arguments and will first call the SK implementation, followed by the AF version. Adjust the prompt or credentials inside the file as necessary before running. ## Running Orchestration & Workflow Samples -Advanced comparisons are split between `samantic-kernel-migration/orchestrations` (Sequential, Concurrent, Magentic) and `samantic-kernel-migration/processes` (fan-out/fan-in, nested). You can run them directly, or isolate dependencies in a throwaway virtual environment: +Advanced comparisons are split between `samples/semantic-kernel-migration/orchestrations` (Sequential, Concurrent, Group Chat, Magentic) and `samples/semantic-kernel-migration/processes` (fan-out/fan-in, nested). You can run them directly, or isolate dependencies in a throwaway virtual environment: ``` -cd samantic-kernel-migration +cd samples/semantic-kernel-migration uv venv --python 3.10 .venv-migration source .venv-migration/bin/activate uv pip install semantic-kernel agent-framework diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py new file mode 100644 index 00000000000..46a2355b900 --- /dev/null +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -0,0 +1,276 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Side-by-side group chat orchestrations for Agent Framework and Semantic Kernel.""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Sequence +from typing import Any, cast + +from agent_framework import ( + ChatAgent, + ChatMessage, + GroupChatBuilder, + StandardGroupChatManager, + WorkflowOutputEvent, +) +from agent_framework.azure import AzureOpenAIChatClient, AzureOpenAIResponsesClient +from azure.identity import AzureCliCredential +from semantic_kernel.agents import Agent, ChatCompletionAgent, GroupChatOrchestration +from semantic_kernel.agents.orchestration.group_chat import ( + BooleanResult, + GroupChatManager, + MessageResult, + StringResult, +) +from semantic_kernel.agents.runtime import InProcessRuntime +from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.contents import AuthorRole, ChatHistory, ChatMessageContent +from semantic_kernel.functions import KernelArguments +from semantic_kernel.kernel import Kernel +from semantic_kernel.prompt_template import KernelPromptTemplate, PromptTemplateConfig + +if sys.version_info >= (3, 12): + from typing import override # pragma: no cover +else: + from typing_extensions import override # pragma: no cover + + +DISCUSSION_TOPIC = "What are the essential steps for launching a community hackathon?" + + +###################################################################### +# Semantic Kernel orchestration path +###################################################################### + + +def build_semantic_kernel_agents() -> list[Agent]: + credential = AzureCliCredential() + + researcher = ChatCompletionAgent( + name="Researcher", + description="Collects background information and potential resources.", + instructions=( + "Gather concise facts or considerations that help plan a community hackathon. " + "Keep your responses factual and scannable." + ), + service=AzureChatCompletion(credential=credential), + ) + + planner = ChatCompletionAgent( + name="Planner", + description="Synthesizes an actionable plan from available notes.", + instructions=( + "Use the running conversation to draft a structured action plan. Emphasize logistics and sequencing." + ), + service=AzureChatCompletion(credential=credential), + ) + + return [researcher, planner] + + +class ChatCompletionGroupChatManager(GroupChatManager): + """Group chat manager that delegates orchestration decisions to an Azure OpenAI deployment.""" + + service: ChatCompletionClientBase + topic: str + + termination_prompt: str = ( + "You are coordinating a conversation about '{{topic}}'. " + "Decide if the discussion has produced a solid answer. " + 'Respond using JSON: {"result": true|false, "reason": "..."}.' + ) + + selection_prompt: str = ( + "You are coordinating a conversation about '{{topic}}'. " + "Choose the next participant by returning JSON with keys (result, reason). " + "The result must match one of: {{participants}}." + ) + + summary_prompt: str = ( + "You have just finished a discussion about '{{topic}}'. " + "Summarize the plan and highlight key takeaways. Return JSON with keys (result, reason) where " + "result is the final response text." + ) + + def __init__(self, *, topic: str, service: ChatCompletionClientBase) -> None: + super().__init__(topic=topic, service=service) + self._round_robin_index = 0 + + async def _render_prompt(self, template: str, **kwargs: Any) -> str: + prompt_template = KernelPromptTemplate(prompt_template_config=PromptTemplateConfig(template=template)) + return await prompt_template.render(Kernel(), arguments=KernelArguments(**kwargs)) + + @override + async def should_request_user_input(self, chat_history: ChatHistory) -> BooleanResult: + return BooleanResult(result=False, reason="This orchestration is fully automated.") + + @override + async def should_terminate(self, chat_history: ChatHistory) -> BooleanResult: + rendered_prompt = await self._render_prompt(self.termination_prompt, topic=self.topic) + chat_history.messages.insert( + 0, + ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), + ) + chat_history.add_message( + ChatMessageContent(role=AuthorRole.USER, content="Decide if the discussion is complete."), + ) + + response = await self.service.get_chat_message_content( + chat_history, + settings=PromptExecutionSettings(response_format=BooleanResult), + ) + result = BooleanResult.model_validate_json(response.content) + return result + + @override + async def select_next_agent( + self, + chat_history: ChatHistory, + participant_descriptions: dict[str, str], + ) -> StringResult: + rendered_prompt = await self._render_prompt( + self.selection_prompt, + topic=self.topic, + participants=", ".join(participant_descriptions.keys()), + ) + chat_history.messages.insert( + 0, + ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), + ) + chat_history.add_message( + ChatMessageContent(role=AuthorRole.USER, content="Pick the next participant to speak."), + ) + + response = await self.service.get_chat_message_content( + chat_history, + settings=PromptExecutionSettings(response_format=StringResult), + ) + result = StringResult.model_validate_json(response.content) + if result.result not in participant_descriptions: + raise RuntimeError(f"Unknown participant selected: {result.result}") + return result + + @override + async def filter_results(self, chat_history: ChatHistory) -> MessageResult: + rendered_prompt = await self._render_prompt(self.summary_prompt, topic=self.topic) + chat_history.messages.insert( + 0, + ChatMessageContent(role=AuthorRole.SYSTEM, content=rendered_prompt), + ) + chat_history.add_message( + ChatMessageContent(role=AuthorRole.USER, content="Summarize the plan."), + ) + + response = await self.service.get_chat_message_content( + chat_history, + settings=PromptExecutionSettings(response_format=StringResult), + ) + string_result = StringResult.model_validate_json(response.content) + return MessageResult( + result=ChatMessageContent(role=AuthorRole.ASSISTANT, content=string_result.result), + reason=string_result.reason, + ) + + +async def sk_agent_response_callback(message: ChatMessageContent | Sequence[ChatMessageContent]) -> None: + if isinstance(message, ChatMessageContent): + messages: Sequence[ChatMessageContent] = [message] + elif isinstance(message, Sequence) and not isinstance(message, (str, bytes)): + messages = list(message) + else: + messages = [cast(ChatMessageContent, message)] + + for item in messages: + print(f"# {item.name}\n{item.content}\n") + + +async def run_semantic_kernel_example(task: str) -> str: + credential = AzureCliCredential() + orchestration = GroupChatOrchestration( + members=build_semantic_kernel_agents(), + manager=ChatCompletionGroupChatManager( + topic=DISCUSSION_TOPIC, + service=AzureChatCompletion(credential=credential), + max_rounds=8, + ), + agent_response_callback=sk_agent_response_callback, + ) + + runtime = InProcessRuntime() + runtime.start() + + try: + orchestration_result = await orchestration.invoke(task=task, runtime=runtime) + final_message = await orchestration_result.get(timeout=30) + if isinstance(final_message, ChatMessageContent): + return final_message.content or "" + return str(final_message) + finally: + await runtime.stop_when_idle() + + +###################################################################### +# Agent Framework orchestration path +###################################################################### + + +async def run_agent_framework_example(task: str) -> str: + credential = AzureCliCredential() + + researcher = ChatAgent( + name="Researcher", + description="Collects background information and potential resources.", + instructions=( + "Gather concise facts or considerations that help plan a community hackathon. " + "Keep your responses factual and scannable." + ), + chat_client=AzureOpenAIChatClient(credential=credential), + ) + + planner = ChatAgent( + name="Planner", + description="Turns the collected notes into a concrete action plan.", + instructions=("Propose a structured action plan that accounts for logistics, roles, and timeline."), + chat_client=AzureOpenAIResponsesClient(credential=credential), + ) + + manager = StandardGroupChatManager( + chat_client=AzureOpenAIChatClient(credential=credential), + name="Coordinator", + ) + + workflow = ( + GroupChatBuilder() + .set_manager(manager, display_name="Coordinator") + .participants(researcher=researcher, planner=planner) + .build() + ) + + final_response = "" + async for event in workflow.run_stream(task): + if isinstance(event, WorkflowOutputEvent): + data = event.data + final_response = data.text or "" if isinstance(data, ChatMessage) else str(data) + return final_response + + +async def main() -> None: + task = "Kick off the group discussion." + + print("===== Agent Framework Group Chat =====") + af_response = await run_agent_framework_example(task) + print(af_response or "No response returned.") + print() + + print("===== Semantic Kernel Group Chat =====") + sk_response = await run_semantic_kernel_example(task) + print(sk_response or "No response returned.") + + +if __name__ == "__main__": + asyncio.run(main()) From 169822d3d9d00cad89c0f06925abe9f0ef629ac2 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 09:23:40 +0900 Subject: [PATCH 08/15] Improvements and simplifications --- .../agent_framework/_workflows/__init__.py | 6 - .../agent_framework/_workflows/__init__.pyi | 6 - .../agent_framework/_workflows/_group_chat.py | 278 +++++++++++++----- .../core/tests/workflow/test_group_chat.py | 54 ++-- .../agents/group_chat_workflow_as_agent.py | 13 +- .../workflows/orchestration/group_chat.py | 9 +- .../orchestrations/group_chat.py | 18 +- 7 files changed, 245 insertions(+), 139 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index d098a9308d7..645ca41e94c 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -56,16 +56,13 @@ DEFAULT_MANAGER_INSTRUCTIONS, GroupChatBuilder, GroupChatDirective, - GroupChatManagerFn, GroupChatOrchestratorExecutor, - GroupChatParticipantPipeline, GroupChatParticipantSpec, GroupChatRequestMessage, GroupChatResponseMessage, GroupChatStateSnapshot, GroupChatTurn, GroupChatWiring, - StandardGroupChatManager, ) from ._magentic import ( MagenticAgentDeltaEvent, @@ -143,9 +140,7 @@ "GraphConnectivityError", "GroupChatBuilder", "GroupChatDirective", - "GroupChatManagerFn", "GroupChatOrchestratorExecutor", - "GroupChatParticipantPipeline", "GroupChatParticipantSpec", "GroupChatRequestMessage", "GroupChatResponseMessage", @@ -182,7 +177,6 @@ "SequentialBuilder", "SharedState", "SingleEdgeGroup", - "StandardGroupChatManager", "StandardMagenticManager", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index bd8c3b1aa2e..445221eaa1a 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -54,16 +54,13 @@ from ._group_chat import ( DEFAULT_MANAGER_INSTRUCTIONS, GroupChatBuilder, GroupChatDirective, - GroupChatManagerFn, GroupChatOrchestratorExecutor, - GroupChatParticipantPipeline, GroupChatParticipantSpec, GroupChatRequestMessage, GroupChatResponseMessage, GroupChatStateSnapshot, GroupChatTurn, GroupChatWiring, - StandardGroupChatManager, ) from ._magentic import ( MagenticAgentDeltaEvent, @@ -141,9 +138,7 @@ __all__ = [ "GraphConnectivityError", "GroupChatBuilder", "GroupChatDirective", - "GroupChatManagerFn", "GroupChatOrchestratorExecutor", - "GroupChatParticipantPipeline", "GroupChatParticipantSpec", "GroupChatRequestMessage", "GroupChatResponseMessage", @@ -180,7 +175,6 @@ __all__ = [ "SequentialBuilder", "SharedState", "SingleEdgeGroup", - "StandardGroupChatManager", "StandardMagenticManager", "SwitchCaseEdgeGroup", "SwitchCaseEdgeGroupCase", diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index eb7db4120f5..3a822bb260f 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -7,7 +7,7 @@ - GroupChatRequestMessage / GroupChatResponseMessage: canonical envelopes used between the orchestrator and participants. -- GroupChatManagerFn: minimal asynchronous callable contract for pluggable coordination logic. +- Group chat managers: minimal asynchronous callables for pluggable coordination logic. - GroupChatOrchestratorExecutor: runtime state machine that delegates to a manager to select the next participant or complete the task. - GroupChatBuilder: high-level builder that wires managers and participants @@ -18,6 +18,7 @@ existing observability and streaming semantics continue to apply. """ +import inspect import itertools import json import logging @@ -78,7 +79,7 @@ class GroupChatTurn: @dataclass class GroupChatDirective: - """Instruction emitted by a GroupChatManagerFn implementation.""" + """Instruction emitted by a group chat manager implementation.""" agent_name: str | None = None instruction: str | None = None @@ -94,7 +95,14 @@ class GroupChatDirective: GroupChatStateSnapshot = Mapping[str, Any] -GroupChatManagerFn = Callable[[GroupChatStateSnapshot], Awaitable[GroupChatDirective]] +_GroupChatManagerFn = Callable[[GroupChatStateSnapshot], Awaitable[GroupChatDirective]] + + +async def _maybe_await(value: Any) -> Any: + """Await value if it is awaitable; otherwise return as-is.""" + if inspect.isawaitable(value): + return await value + return value @dataclass @@ -127,7 +135,7 @@ class GroupChatWiring: orchestrator: Orchestrator executor instance (populated during build) """ - manager: GroupChatManagerFn | None + manager: _GroupChatManagerFn | None manager_name: str participants: Mapping[str, GroupChatParticipantSpec] max_rounds: int | None = None @@ -225,7 +233,7 @@ class GroupChatOrchestratorExecutor(Executor): def __init__( self, - manager: GroupChatManagerFn, + manager: _GroupChatManagerFn, *, participants: Mapping[str, str], manager_name: str, @@ -683,38 +691,33 @@ def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: class GroupChatBuilder: r"""High-level builder for manager-directed group chat workflows with dynamic orchestration. - - `set_manager(...)` configures the orchestration manager (required) - - `participants({...})` accepts a mapping of named AgentProtocol or Executor instances - - The workflow wires an orchestrator that delegates speaker selection to the manager + - Call exactly one of `set_prompt_based_manager(...)` or `set_speaker_selector(...)` to configure coordination + - `participants({...})` accepts a mapping (or list) of AgentProtocol/Executor instances + - The workflow delegates speaker selection to the manager and requests completion when finished - Agents are automatically wrapped as AgentExecutor for consistent observability - - The manager receives conversation state and returns directives (next speaker or finish) - - The final output is the manager's completion message when the task is finished Usage: .. code-block:: python - from agent_framework import GroupChatBuilder, StandardGroupChatManager + from agent_framework.azure import AzureOpenAIChatClient, AzureOpenAIResponsesClient + from agent_framework import ChatAgent, GroupChatBuilder - manager = StandardGroupChatManager(chat_client) - workflow = ( - GroupChatBuilder().set_manager(manager).participants(writer=writer_agent, reviewer=reviewer_agent).build() + researcher = ChatAgent( + name="Researcher", + chat_client=AzureOpenAIChatClient(), + instructions="Collect useful notes.", ) - - # Enable checkpoint persistence - workflow = ( - GroupChatBuilder() - .set_manager(manager) - .participants({"analyst": analyst_agent, "coder": coder_agent}) - .with_checkpointing(storage) - .build() + writer = ChatAgent( + name="Writer", + chat_client=AzureOpenAIResponsesClient(), + instructions="Draft a polished answer.", ) - # Limit conversation rounds workflow = ( GroupChatBuilder() - .set_manager(manager) - .participants(agent1=agent1, agent2=agent2) + .set_prompt_based_manager(chat_client=AzureOpenAIChatClient(), display_name="Coordinator") + .participants(researcher=researcher, writer=writer) .with_max_rounds(10) .build() ) @@ -737,7 +740,7 @@ def __init__( """ self._participants: dict[str, AgentProtocol | Executor] = {} self._participant_descriptions: dict[str, str] = {} - self._manager: GroupChatManagerFn | None = None + self._manager: _GroupChatManagerFn | None = None self._manager_name: str = "manager" self._checkpoint_storage: CheckpointStorage | None = None self._max_rounds: int | None = None @@ -745,38 +748,103 @@ def __init__( self._orchestrator_factory = _orchestrator_factory or _default_orchestrator_factory self._participant_factory = _participant_factory or _default_participant_factory - def set_manager(self, manager: GroupChatManagerFn, *, display_name: str | None = None) -> "GroupChatBuilder": - """Configure the orchestration manager callable that selects participants and completes tasks. + def _set_manager_function( + self, + manager: _GroupChatManagerFn, + display_name: str | None, + ) -> "GroupChatBuilder": + if self._manager is not None: + raise ValueError( + "GroupChatBuilder already has a manager configured. " + "Call set_prompt_based_manager(...) or set_speaker_selector(...) at most once." + ) + resolved_name = display_name or getattr(manager, "name", None) or "manager" + self._manager = manager + self._manager_name = resolved_name + return self - The callable receives an immutable conversation snapshot and returns directives indicating which - participant should speak next or whether the task is complete. + def set_prompt_based_manager( + self, + chat_client: ChatClientProtocol, + *, + instructions: str | None = None, + display_name: str | None = None, + ) -> "GroupChatBuilder": + """Configure the default prompt-based manager driven by an LLM chat client. Args: - manager: Awaitable callable accepting the state snapshot and returning a GroupChatDirective - display_name: Optional custom name for the manager in conversation history + chat_client: Chat completion client used to run the coordinator LLM. + instructions: Optional system instructions to steer the coordinator prompt. + display_name: Optional conversational display name for manager messages. Returns: - Self for fluent chaining + Self for fluent chaining. + + Note: + Calling this method and :meth:`set_speaker_selector` together is not allowed; choose one. + + Example: + + .. code-block:: python + + workflow = ( + GroupChatBuilder() + .set_prompt_based_manager(chat_client, display_name="Coordinator") + .participants(researcher=researcher, writer=writer) + .build() + ) """ - self._manager = manager - resolved_name = display_name or getattr(manager, "name", None) or "manager" - self._manager_name = resolved_name - return self + manager = _PromptBasedGroupChatManager( + chat_client, + instructions=instructions, + name=display_name, + ) + return self._set_manager_function(manager, display_name) + + def set_speaker_selector( + self, + selector: Callable[[GroupChatStateSnapshot], Awaitable[Any]] | Callable[[GroupChatStateSnapshot], Any], + *, + display_name: str | None = None, + final_message: ChatMessage | str | Callable[[GroupChatStateSnapshot], Any] | None = None, + ) -> "GroupChatBuilder": + """Configure a lightweight selector function that picks the next speaker. + + Args: + selector: Callable receiving the conversation snapshot. Return a participant name to + continue the conversation or None to finish. The callable may be sync or async. + display_name: Optional name shown in conversation history for manager messages. + final_message: Optional final message (or factory) emitted when the selector returns None + (defaults to ``"Conversation completed."`` authored by the manager). + + Returns: + Self for fluent chaining. + + Note: + Calling this method and :meth:`set_prompt_based_manager` together is not allowed; choose one. + """ + manager_name = display_name or "manager" + adapter = _SpeakerSelectorAdapter( + selector, + manager_name=manager_name, + final_message=final_message, + ) + return self._set_manager_function(adapter, display_name) def participants( self, - participants: Mapping[str, AgentProtocol | Executor] | None = None, + participants: Mapping[str, AgentProtocol | Executor] | Sequence[AgentProtocol | Executor] | None = None, /, **named_participants: AgentProtocol | Executor, ) -> "GroupChatBuilder": - """Define the named participants for this group chat workflow. + """Define participants for this group chat workflow. Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances. - Participant names must be unique and non-empty. The manager uses these names when - selecting the next speaker. + Provide a mapping of name → participant for explicit control, or pass a sequence and + names will be inferred from the agent's ``name`` attribute (or executor ``id``). Args: - participants: Optional mapping of participant names to agent/executor instances + participants: Optional mapping or sequence of participant definitions **named_participants: Keyword arguments mapping names to agent/executor instances Returns: @@ -791,23 +859,10 @@ def participants( from agent_framework import GroupChatBuilder - # Using keyword arguments workflow = ( GroupChatBuilder() - .set_manager(manager) - .participants(writer=writer_agent, editor=editor_agent, reviewer=reviewer_agent) - .build() - ) - - # Using dictionary - participants_dict = {"analyst": analyst_agent, "coder": coder_agent} - workflow = GroupChatBuilder().set_manager(manager).participants(participants_dict).build() - - # Combining both approaches - workflow = ( - GroupChatBuilder() - .set_manager(manager) - .participants({"agent1": agent1}, agent2=agent2, agent3=agent3) + .set_prompt_based_manager(chat_client) + .participants([writer_agent, reviewer_agent]) .build() ) """ @@ -821,8 +876,21 @@ def _add(name: str, participant: AgentProtocol | Executor) -> None: combined[name] = participant if participants: - for name, participant in participants.items(): - _add(name, participant) + if isinstance(participants, Mapping): + for name, participant in participants.items(): + _add(name, participant) + else: + for participant in participants: + if isinstance(participant, Executor): + inferred_name = participant.id + else: + inferred_name = getattr(participant, "name", None) + if not inferred_name: + raise ValueError( + "Agent participants supplied via sequence must define a non-empty 'name' attribute." + ) + _add(str(inferred_name), participant) + for name, participant in named_participants.items(): _add(name, participant) @@ -860,7 +928,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupCha storage = MemoryCheckpointStorage() workflow = ( GroupChatBuilder() - .set_manager(manager) + .set_prompt_based_manager(chat_client) .participants(agent1=agent1, agent2=agent2) .with_checkpointing(storage) .build() @@ -902,7 +970,7 @@ def is_plan_review(msg: Any) -> bool: review_executor = PlanReviewExecutor() workflow = ( GroupChatBuilder() - .set_manager(manager) + .set_prompt_based_manager(chat_client) .participants(agent1=agent1) .with_request_handler(review_executor, condition=is_plan_review) .build() @@ -932,14 +1000,20 @@ def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": # Limit to 15 rounds workflow = ( GroupChatBuilder() - .set_manager(manager) + .set_prompt_based_manager(chat_client) .participants(agent1=agent1, agent2=agent2) .with_max_rounds(15) .build() ) # Unlimited rounds - workflow = GroupChatBuilder().set_manager(manager).participants(agent1=agent1).with_max_rounds(None).build() + workflow = ( + GroupChatBuilder() + .set_prompt_based_manager(chat_client) + .participants(agent1=agent1) + .with_max_rounds(None) + .build() + ) """ self._max_rounds = max_rounds return self @@ -981,12 +1055,15 @@ def build(self) -> Workflow: .. code-block:: python - from agent_framework import GroupChatBuilder, StandardGroupChatManager - - manager = StandardGroupChatManager(chat_client) - workflow = GroupChatBuilder().set_manager(manager).participants(agent1=agent1, agent2=agent2).build() + from agent_framework import GroupChatBuilder # Execute the workflow + workflow = ( + GroupChatBuilder() + .set_prompt_based_manager(chat_client) + .participants(agent1=agent1, agent2=agent2) + .build() + ) async for message in workflow.run("Solve this problem collaboratively"): print(message.text) """ @@ -1053,7 +1130,7 @@ def build(self) -> Workflow: class _ManagerDirectiveModel(BaseModel): """Pydantic model for structured output from LLM manager decisions. - Defines the JSON schema that StandardGroupChatManager expects from the LLM's + Defines the JSON schema that the prompt-based manager expects from the LLM's response_format output. This ensures type-safe parsing and validation of manager directives. @@ -1084,7 +1161,7 @@ class _ManagerDirectiveModel(BaseModel): """ -class StandardGroupChatManager: +class _PromptBasedGroupChatManager: """LLM-backed manager that produces directives via structured output. This is the default manager implementation for group chat workflows. It uses an LLM @@ -1197,4 +1274,67 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: ) +class _SpeakerSelectorAdapter: + """Adapter that turns a simple speaker selector into a full manager directive.""" + + def __init__( + self, + selector: Callable[[GroupChatStateSnapshot], Awaitable[Any]] | Callable[[GroupChatStateSnapshot], Any], + *, + manager_name: str, + final_message: ChatMessage | str | Callable[[GroupChatStateSnapshot], Any] | None = None, + ) -> None: + self._selector = selector + self._manager_name = manager_name + self._final_message = final_message + self.name = manager_name + + async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: + result = await _maybe_await(self._selector(state)) + if result is None: + message = await self._resolve_final_message(state) + return GroupChatDirective(finish=True, final_message=message) + + if isinstance(result, Sequence) and not isinstance(result, (str, bytes, bytearray)): + if not result: + message = await self._resolve_final_message(state) + return GroupChatDirective(finish=True, final_message=message) + if len(result) != 1: + raise ValueError("Speaker selector must return a single participant name or None.") + result = result[0] + + if not isinstance(result, str): + raise TypeError("Speaker selector must return a participant name (str) or None.") + + return GroupChatDirective(agent_name=result) + + async def _resolve_final_message(self, state: GroupChatStateSnapshot) -> ChatMessage: + final_message = self._final_message + if callable(final_message): + value = await _maybe_await(final_message(state)) + else: + value = final_message + + if value is None: + message = ChatMessage( + role=Role.ASSISTANT, + text="Conversation completed.", + author_name=self._manager_name, + ) + elif isinstance(value, ChatMessage): + message = value + else: + message = ChatMessage( + role=Role.ASSISTANT, + text=str(value), + author_name=self._manager_name, + ) + + if not message.author_name: + patch = message.to_dict() + patch["author_name"] = self._manager_name + message = ChatMessage.from_dict(patch) + return message + + # endregion diff --git a/python/packages/core/tests/workflow/test_group_chat.py b/python/packages/core/tests/workflow/test_group_chat.py index 46fd03a1ae3..f8f8f10b603 100644 --- a/python/packages/core/tests/workflow/test_group_chat.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Callable from typing import Any from agent_framework import ( @@ -10,7 +10,6 @@ BaseAgent, ChatMessage, GroupChatBuilder, - GroupChatDirective, GroupChatStateSnapshot, MagenticAgentMessageEvent, MagenticBuilder, @@ -57,27 +56,22 @@ async def _stream() -> AsyncIterable[AgentRunResponseUpdate]: return _stream() -class SequenceManager: - def __init__(self) -> None: - self._step = 0 - - @property - def name(self) -> str: - return "manager" - - async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: - participants = state["participants"] - participant_names = list(participants.keys()) - if self._step == 0: - self._step += 1 - return GroupChatDirective(agent_name=participant_names[0], instruction="start") - if self._step == 1 and len(participant_names) > 1: - self._step += 1 - return GroupChatDirective(agent_name=participant_names[1], instruction="continue") - return GroupChatDirective( - finish=True, - final_message=ChatMessage(role=Role.ASSISTANT, text="done", author_name=self.name), - ) +def make_sequence_selector() -> Callable[[GroupChatStateSnapshot], Any]: + state_counter = {"value": 0} + + async def _selector(state: GroupChatStateSnapshot) -> str | None: + participants = list(state["participants"].keys()) + step = state_counter["value"] + if step == 0: + state_counter["value"] = step + 1 + return participants[0] + if step == 1 and len(participants) > 1: + state_counter["value"] = step + 1 + return participants[1] + return None + + _selector.name = "manager" # type: ignore[attr-defined] + return _selector class StubMagenticManager(MagenticManagerBase): @@ -116,12 +110,15 @@ async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatM async def test_group_chat_builder_basic_flow() -> None: - manager = SequenceManager() + selector = make_sequence_selector() alpha = StubAgent("alpha", "ack from alpha") beta = StubAgent("beta", "ack from beta") workflow = ( - GroupChatBuilder().set_manager(manager, display_name="manager").participants(alpha=alpha, beta=beta).build() + GroupChatBuilder() + .set_speaker_selector(selector, display_name="manager", final_message="done") + .participants(alpha=alpha, beta=beta) + .build() ) outputs: list[ChatMessage] = [] @@ -167,12 +164,15 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: async def test_group_chat_as_agent_accepts_conversation() -> None: - manager = SequenceManager() + selector = make_sequence_selector() alpha = StubAgent("alpha", "ack from alpha") beta = StubAgent("beta", "ack from beta") workflow = ( - GroupChatBuilder().set_manager(manager, display_name="manager").participants(alpha=alpha, beta=beta).build() + GroupChatBuilder() + .set_speaker_selector(selector, display_name="manager", final_message="done") + .participants(alpha=alpha, beta=beta) + .build() ) agent = workflow.as_agent(name="group-chat-agent") diff --git a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py b/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py index c6e0ce9d7e9..ff147df453f 100644 --- a/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py +++ b/python/samples/getting_started/workflows/agents/group_chat_workflow_as_agent.py @@ -3,11 +3,7 @@ import asyncio import logging -from agent_framework import ( - ChatAgent, - GroupChatBuilder, - StandardGroupChatManager, -) +from agent_framework import ChatAgent, GroupChatBuilder from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient logging.basicConfig(level=logging.INFO) @@ -40,14 +36,9 @@ async def main() -> None: chat_client=OpenAIResponsesClient(), ) - manager = StandardGroupChatManager( - chat_client=OpenAIChatClient(), - name="Coordinator", - ) - workflow = ( GroupChatBuilder() - .set_manager(manager, display_name="Coordinator") + .set_prompt_based_manager(chat_client=OpenAIChatClient(), display_name="Coordinator") .participants(researcher=researcher, writer=writer) .build() ) diff --git a/python/samples/getting_started/workflows/orchestration/group_chat.py b/python/samples/getting_started/workflows/orchestration/group_chat.py index b29a0e2850d..fdb139b5dbd 100644 --- a/python/samples/getting_started/workflows/orchestration/group_chat.py +++ b/python/samples/getting_started/workflows/orchestration/group_chat.py @@ -3,7 +3,7 @@ import asyncio import logging -from agent_framework import ChatAgent, GroupChatBuilder, StandardGroupChatManager, WorkflowOutputEvent +from agent_framework import ChatAgent, GroupChatBuilder, WorkflowOutputEvent from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient logging.basicConfig(level=logging.INFO) @@ -36,14 +36,9 @@ async def main() -> None: chat_client=OpenAIResponsesClient(), ) - manager = StandardGroupChatManager( - chat_client=OpenAIChatClient(), - name="Coordinator", - ) - workflow = ( GroupChatBuilder() - .set_manager(manager, display_name="Coordinator") + .set_prompt_based_manager(chat_client=OpenAIChatClient(), display_name="Coordinator") .participants(researcher=researcher, writer=writer) .build() ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py index 46a2355b900..72bd24c1e8e 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -9,13 +9,7 @@ from collections.abc import Sequence from typing import Any, cast -from agent_framework import ( - ChatAgent, - ChatMessage, - GroupChatBuilder, - StandardGroupChatManager, - WorkflowOutputEvent, -) +from agent_framework import ChatAgent, ChatMessage, GroupChatBuilder, WorkflowOutputEvent from agent_framework.azure import AzureOpenAIChatClient, AzureOpenAIResponsesClient from azure.identity import AzureCliCredential from semantic_kernel.agents import Agent, ChatCompletionAgent, GroupChatOrchestration @@ -239,14 +233,12 @@ async def run_agent_framework_example(task: str) -> str: chat_client=AzureOpenAIResponsesClient(credential=credential), ) - manager = StandardGroupChatManager( - chat_client=AzureOpenAIChatClient(credential=credential), - name="Coordinator", - ) - workflow = ( GroupChatBuilder() - .set_manager(manager, display_name="Coordinator") + .set_prompt_based_manager( + chat_client=AzureOpenAIChatClient(credential=credential), + display_name="Coordinator", + ) .participants(researcher=researcher, planner=planner) .build() ) From aa03e63b2b89123a48f27ab05c053fe1996e9f09 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 17:31:23 +0900 Subject: [PATCH 09/15] consolidating shared orchestration logic --- .../agent_framework/_workflows/__init__.py | 4 + .../agent_framework/_workflows/__init__.pyi | 4 + .../_workflows/_agent_executor.py | 71 ++- .../_workflows/_base_orchestrator.py | 148 +++++ .../_workflows/_conversation_history.py | 73 +++ .../agent_framework/_workflows/_group_chat.py | 514 +++++++++++------- .../agent_framework/_workflows/_handoff.py | 278 +++++----- .../agent_framework/_workflows/_magentic.py | 90 ++- .../_workflows/_model_utils.py | 4 +- .../_workflows/_orchestration_state.py | 92 ++++ .../_workflows/_orchestrator_helpers.py | 223 ++++++++ .../_workflows/_participant_utils.py | 116 ++++ .../_workflows/_typing_utils.py | 90 +-- .../_workflows/_workflow_context.py | 2 +- .../workflows/orchestration/group_chat.py | 14 +- 15 files changed, 1309 insertions(+), 414 deletions(-) create mode 100644 python/packages/core/agent_framework/_workflows/_base_orchestrator.py create mode 100644 python/packages/core/agent_framework/_workflows/_conversation_history.py create mode 100644 python/packages/core/agent_framework/_workflows/_orchestration_state.py create mode 100644 python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py create mode 100644 python/packages/core/agent_framework/_workflows/_participant_utils.py diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 532d46bc233..2c7188f7f82 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -6,6 +6,7 @@ AgentExecutorRequest, AgentExecutorResponse, ) +from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import ( CheckpointStorage, FileCheckpointStorage, @@ -85,6 +86,7 @@ MagenticStartMessage, StandardMagenticManager, ) +from ._orchestration_state import OrchestrationState from ._request_info_executor import ( PendingRequestDetails, RequestInfoExecutor, @@ -122,6 +124,7 @@ "AgentExecutorResponse", "AgentRunEvent", "AgentRunUpdateEvent", + "BaseGroupChatOrchestrator", "Case", "CheckpointStorage", "ConcurrentBuilder", @@ -170,6 +173,7 @@ "MagenticResponseMessage", "MagenticStartMessage", "Message", + "OrchestrationState", "PendingRequestDetails", "RequestInfoEvent", "RequestInfoExecutor", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index 65e8641293c..780bca069a4 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -6,6 +6,7 @@ from ._agent_executor import ( AgentExecutorRequest, AgentExecutorResponse, ) +from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import ( CheckpointStorage, FileCheckpointStorage, @@ -83,6 +84,7 @@ from ._magentic import ( MagenticStartMessage, StandardMagenticManager, ) +from ._orchestration_state import OrchestrationState from ._request_info_executor import ( PendingRequestDetails, RequestInfoExecutor, @@ -120,6 +122,7 @@ __all__ = [ "AgentExecutorResponse", "AgentRunEvent", "AgentRunUpdateEvent", + "BaseGroupChatOrchestrator", "Case", "CheckpointStorage", "ConcurrentBuilder", @@ -168,6 +171,7 @@ __all__ = [ "MagenticResponseMessage", "MagenticStartMessage", "Message", + "OrchestrationState", "PendingRequestDetails", "RequestInfoEvent", "RequestInfoExecutor", diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index ca09086c651..149fed93713 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import logging +from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -57,6 +58,11 @@ class AgentExecutor(Executor): - run(): Emits a single AgentRunEvent containing the complete response The executor automatically detects the mode via WorkflowContext.is_streaming(). + + Supports conversation injection hooks for advanced orchestration patterns: + - inject_conversation: Optional callback to inject/modify conversation before agent invocation + - on_message_event: Optional callback to emit custom events for each response message + - on_delta_event: Optional callback to emit custom events for streaming updates """ def __init__( @@ -66,6 +72,9 @@ def __init__( agent_thread: AgentThread | None = None, output_response: bool = False, id: str | None = None, + inject_conversation: Callable[[list[ChatMessage]], list[ChatMessage]] | None = None, + on_message_event: Callable[[WorkflowContext[Any, Any], ChatMessage], Any] | None = None, + on_delta_event: Callable[[WorkflowContext[Any, Any], AgentRunResponseUpdate], Any] | None = None, ): """Initialize the executor with a unique identifier. @@ -74,6 +83,12 @@ def __init__( agent_thread: The thread to use for running the agent. If None, a new thread will be created. output_response: Whether to yield an AgentRunResponse as a workflow output when the agent completes. id: A unique identifier for the executor. If None, the agent's name will be used if available. + inject_conversation: Optional callback to inject or modify conversation before agent invocation. + Takes the current cache and returns the conversation to pass to the agent. + on_message_event: Optional async callback to emit custom events for each response message. + Called with (context, message) for each message in the agent's response. + on_delta_event: Optional async callback to emit custom events for streaming updates. + Called with (context, update) for each streaming update. """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or agent.name @@ -84,6 +99,9 @@ def __init__( self._agent_thread = agent_thread or self._agent.get_new_thread() self._output_response = output_response self._cache: list[ChatMessage] = [] + self._inject_conversation = inject_conversation + self._on_message_event = on_message_event + self._on_delta_event = on_delta_event @property def workflow_output_types(self) -> list[type[Any]]: @@ -97,15 +115,24 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent events (streaming mode) or a single AgentRunEvent (non-streaming mode). + + Supports conversation injection and custom event callbacks for advanced orchestration. """ + # Apply conversation injection if provided + conversation = self._inject_conversation(self._cache) if self._inject_conversation else self._cache + if ctx.is_streaming(): # Streaming mode: emit incremental updates updates: list[AgentRunResponseUpdate] = [] async for update in self._agent.run_stream( - self._cache, + conversation, thread=self._agent_thread, ): updates.append(update) + # Emit custom delta event if callback provided + if self._on_delta_event: + await self._on_delta_event(ctx, update) + # Always emit standard update event await ctx.add_event(AgentRunUpdateEvent(self.id, update)) if isinstance(self._agent, ChatAgent): @@ -119,11 +146,16 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, else: # Non-streaming mode: use run() and emit single event response = await self._agent.run( - self._cache, + conversation, thread=self._agent_thread, ) await ctx.add_event(AgentRunEvent(self.id, response)) + # Emit custom message events if callback provided + if self._on_message_event: + for message in response.messages: + await self._on_message_event(ctx, message) + if self._output_response: await ctx.yield_output(response) @@ -190,3 +222,38 @@ async def from_messages( """Accept a list of chat inputs (strings or ChatMessage) as conversation context.""" self._cache = normalize_messages_input(messages) await self._run_agent_and_emit(ctx) + + def snapshot_state(self) -> dict[str, Any]: + """Capture current executor state for checkpointing. + + Returns: + Dict containing serialized cache state + """ + from ._conversation_state import encode_chat_messages + + return { + "cache": encode_chat_messages(self._cache), + } + + def restore_state(self, state: dict[str, Any]) -> None: + """Restore executor state from checkpoint. + + Args: + state: Checkpoint data dict + """ + from ._conversation_state import decode_chat_messages + + cache_payload = state.get("cache") + if cache_payload: + try: + self._cache = decode_chat_messages(cache_payload) + except Exception as exc: + logger.warning("Failed to restore cache: %s", exc) + self._cache = [] + else: + self._cache = [] + + def reset(self) -> None: + """Reset the internal cache of the executor.""" + logger.debug("AgentExecutor %s: Resetting cache", self.id) + self._cache.clear() diff --git a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py b/python/packages/core/agent_framework/_workflows/_base_orchestrator.py new file mode 100644 index 00000000000..31e0cccc31d --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_base_orchestrator.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Base orchestrator class for group chat patterns. + +This module provides BaseGroupChatOrchestrator, an abstract base class that +consolidates shared orchestration logic across GroupChat, Handoff, and Magentic patterns. +""" + +import logging +from abc import ABC +from typing import Any + +from .._types import ChatMessage +from ._executor import Executor +from ._orchestrator_helpers import ParticipantRegistry +from ._workflow_context import WorkflowContext + +logger = logging.getLogger(__name__) + + +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. + """ + + def __init__(self, executor_id: str) -> None: + """Initialize base orchestrator. + + Args: + executor_id: Unique identifier for this orchestrator executor + """ + super().__init__(executor_id) + self._registry = ParticipantRegistry() + + def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: + """Record routing details for a participant's entry executor. + + This method provides a unified interface for registering participants + across all orchestrator patterns, whether they are agents or custom executors. + + Args: + name: Participant name (used for selection and tracking) + entry_id: Executor ID for this participant's entry point + is_agent: Whether this is an AgentExecutor (True) or custom Executor (False) + """ + self._registry.register(name, entry_id=entry_id, is_agent=is_agent) + + async def _route_to_participant( + self, + participant_name: str, + conversation: list[ChatMessage], + ctx: WorkflowContext[Any, Any], + *, + instruction: str | None = None, + task: ChatMessage | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """Route a conversation to a participant. + + This method handles the dual envelope pattern: + - AgentExecutors receive AgentExecutorRequest (messages only) + - Custom executors receive GroupChatRequestMessage (full context) + + Args: + participant_name: Name of the participant to route to + conversation: Conversation history to send + ctx: Workflow context for message routing + instruction: Optional instruction from manager/orchestrator + task: Optional task context + metadata: Optional metadata dict + + Raises: + ValueError: If participant is not registered + """ + from ._agent_executor import AgentExecutorRequest + from ._orchestrator_helpers import prepare_participant_request + + entry_id = self._registry.get_entry_id(participant_name) + if entry_id is None: + raise ValueError(f"No registered entry executor for participant '{participant_name}'.") + + if self._registry.is_agent(participant_name): + # AgentExecutors receive simple message list + await ctx.send_message( + AgentExecutorRequest(messages=conversation, should_respond=True), + target_id=entry_id, + ) + else: + # Custom executors receive full context envelope + request = prepare_participant_request( + participant_name=participant_name, + conversation=conversation, + instruction=instruction or "", + task=task, + metadata=metadata, + ) + await ctx.send_message(request, target_id=entry_id) + + def _check_round_limit( + self, + current_round: int, + max_rounds: int | None, + *, + pattern_name: str = "orchestrator", + ) -> bool: + """Check if round limit has been reached. + + Args: + current_round: Current round index + max_rounds: Maximum allowed rounds (None = no limit) + pattern_name: Name for logging (e.g., "GroupChat", "Handoff") + + Returns: + True if limit reached, False otherwise + """ + if max_rounds is None: + return False + + if current_round >= max_rounds: + logger.warning( + "%s reached max_rounds=%s; forcing completion.", + pattern_name, + max_rounds, + ) + return True + + return False + + def snapshot_state(self) -> dict[str, Any]: + """Capture current orchestrator state for checkpointing. + + Subclasses should override this to serialize pattern-specific state. + Default implementation returns empty dict. + """ + return {} + + def restore_state(self, state: dict[str, Any]) -> None: + """Restore orchestrator state from checkpoint. + + Subclasses should override this to deserialize pattern-specific state. + Default implementation does nothing. + """ + pass diff --git a/python/packages/core/agent_framework/_workflows/_conversation_history.py b/python/packages/core/agent_framework/_workflows/_conversation_history.py new file mode 100644 index 00000000000..7f44986ec94 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_conversation_history.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Helpers for managing chat conversation history. + +These utilities operate on standard `list[ChatMessage]` collections and simple +dictionary snapshots so orchestrators can share logic without new mixins. +""" + +import json +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from .._types import ChatMessage, Role + + +def clone_conversation(messages: Iterable[ChatMessage]) -> list[ChatMessage]: + """Return a shallow copy of `messages` as a list.""" + return list(messages) + + +def append_messages(conversation: list[ChatMessage], messages: Iterable[ChatMessage]) -> None: + """Extend `conversation` with `messages` in order.""" + conversation.extend(messages) + + +def latest_user_message(conversation: Sequence[ChatMessage]) -> ChatMessage: + """Return the most recent user-authored message from `conversation`.""" + for message in reversed(conversation): + role_value = getattr(message.role, "value", message.role) + if str(role_value).lower() == "user": + return message + if not conversation: + raise ValueError("Conversation is empty; cannot determine user message.") + return conversation[-1] + + +def ensure_author(message: ChatMessage, fallback: str) -> ChatMessage: + """Attach `fallback` author if message is missing `author_name`.""" + author = getattr(message, "author_name", None) + if author: + return message + if hasattr(message, "to_dict") and callable(message.to_dict): # type: ignore[attr-defined] + payload = message.to_dict() # type: ignore[attr-defined] + else: + payload = getattr(message, "__dict__", {}).copy() + payload["author_name"] = fallback + if hasattr(ChatMessage, "from_dict") and callable(getattr(ChatMessage, "from_dict", None)): + return ChatMessage.from_dict(payload) # type: ignore[attr-defined,return-value] + return ChatMessage( + role=getattr(message, "role", Role.ASSISTANT), text=payload.get("text", ""), author_name=fallback + ) + + +def snapshot_state(conversation: Sequence[ChatMessage]) -> dict[str, Any]: + """Build an immutable snapshot for checkpoint storage.""" + if hasattr(conversation, "to_dict"): + result = conversation.to_dict() # type: ignore[attr-defined] + if isinstance(result, dict): + return result # type: ignore[return-value] + if isinstance(result, Mapping): + return dict(result) # type: ignore[arg-type] + serialisable: list[dict[str, Any]] = [] + for message in conversation: + if hasattr(message, "to_dict") and callable(message.to_dict): # type: ignore[attr-defined] + msg_dict = message.to_dict() # type: ignore[attr-defined] + serialisable.append(dict(msg_dict) if isinstance(msg_dict, Mapping) else msg_dict) # type: ignore[arg-type] + elif hasattr(message, "to_json") and callable(message.to_json): # type: ignore[attr-defined] + json_payload = message.to_json() # type: ignore[attr-defined] + parsed = json.loads(json_payload) if isinstance(json_payload, str) else json_payload + serialisable.append(dict(parsed) if isinstance(parsed, Mapping) else parsed) # type: ignore[arg-type] + else: + serialisable.append(dict(getattr(message, "__dict__", {}))) # type: ignore[arg-type] + return {"messages": serialisable} diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 3a822bb260f..642e3031736 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -25,17 +25,19 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from types import MappingProxyType -from typing import Any, TypeAlias +from typing import Any, TypeAlias, TypedDict from uuid import uuid4 -from pydantic import BaseModel, ValidationError - from .._agents import AgentProtocol from .._clients import ChatClientProtocol from .._types import ChatMessage, Role -from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse +from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse +from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage +from ._conversation_history import append_messages, clone_conversation, ensure_author, latest_user_message from ._executor import Executor, handler +from ._orchestrator_helpers import ParticipantRegistry, create_completion_message +from ._participant_utils import prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder from ._workflow_context import WorkflowContext @@ -140,6 +142,8 @@ class GroupChatWiring: participants: Mapping[str, GroupChatParticipantSpec] max_rounds: int | None = None orchestrator: Executor | None = None + participant_aliases: dict[str, str] = field(default_factory=dict) # type: ignore[type-arg] + participant_executors: dict[str, Executor] = field(default_factory=dict) # type: ignore[type-arg] # endregion @@ -147,10 +151,13 @@ class GroupChatWiring: # region Default participant factory +GroupChatOrchestratorFactory: TypeAlias = Callable[[GroupChatWiring], Executor] +InterceptorSpec: TypeAlias = tuple[Callable[[GroupChatWiring], Executor], Callable[[Any], bool]] + def _default_participant_factory( spec: GroupChatParticipantSpec, - _: GroupChatWiring, + wiring: GroupChatWiring, ) -> GroupChatParticipantPipeline: """Default factory for constructing participant pipeline nodes in the workflow graph. @@ -160,7 +167,7 @@ def _default_participant_factory( Args: spec: Participant specification containing name, instance, and description - _: GroupChatWiring configuration (unused by default implementation) + wiring: GroupChatWiring configuration for accessing cached executors Returns: Sequence of executors representing the participant pipeline in execution order @@ -173,8 +180,11 @@ def _default_participant_factory( if isinstance(participant, Executor): return (participant,) - agent = participant - agent_executor = AgentExecutor(agent, id=f"groupchat_agent:{spec.name}") + cached = wiring.participant_executors.get(spec.name) + if cached is not None: + return (cached,) + + agent_executor = wrap_participant(participant, executor_id=f"groupchat_agent:{spec.name}") return (agent_executor,) @@ -184,8 +194,8 @@ def _default_participant_factory( # region Default orchestrator -class GroupChatOrchestratorExecutor(Executor): - """Default orchestrator executor that implements manager-directed group chat coordination. +class GroupChatOrchestratorExecutor(BaseGroupChatOrchestrator): + """Executor that orchestrates a group chat between multiple participants using a manager. This is the central runtime state machine that drives multi-agent conversations. It maintains conversation state, delegates speaker selection to a manager, routes messages @@ -252,10 +262,8 @@ def __init__( self._max_rounds = max_rounds # Stashes the initial conversation list until _handle_task_message normalizes it into _conversation. self._pending_initial_conversation: list[ChatMessage] | None = None - self._participant_entry_ids: dict[str, str] = {} - self._agent_executor_ids: dict[str, str] = {} - self._executor_id_to_participant: dict[str, str] = {} - self._non_agent_participants: set[str] = set() + # Use the simple registry helper instead of tracking separately + self._registry = ParticipantRegistry() @staticmethod def _role_value(message: ChatMessage) -> str: @@ -302,14 +310,65 @@ def _build_state(self) -> GroupChatStateSnapshot: } return MappingProxyType(snapshot) - def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: - """Record routing details for a participant's entry executor.""" - self._participant_entry_ids[name] = entry_id - if is_agent: - self._agent_executor_ids[name] = entry_id - self._executor_id_to_participant[entry_id] = name - else: - self._non_agent_participants.add(name) + def snapshot_state(self) -> dict[str, Any]: + """Capture current orchestrator state for checkpointing. + + Serializes conversation history, task, round index, and pattern-specific + metadata into a dict using the unified OrchestrationState structure. + + Returns: + Dict ready for checkpoint persistence + """ + from ._orchestration_state import OrchestrationState + + state = OrchestrationState( + conversation=list(self._conversation), + round_index=self._round_index, + task=self._task_message, + metadata={ + "participants": dict(self._participants), + "manager_name": self._manager_name, + "pending_agent": self._pending_agent, + "history": [ + {"speaker": turn.speaker, "role": turn.role, "message": turn.message.to_dict()} + for turn in self._history + ], + }, + ) + return state.to_dict() + + def restore_state(self, state: dict[str, Any]) -> None: + """Restore orchestrator state from checkpoint. + + Deserializes checkpointed state using OrchestrationState and restores + internal conversation history, task, and round tracking. + + Args: + state: Checkpoint data dict + """ + from ._orchestration_state import OrchestrationState + + orch_state = OrchestrationState.from_dict(state) + self._conversation = list(orch_state.conversation) + self._round_index = orch_state.round_index + self._task_message = orch_state.task + + # Restore pattern-specific metadata + if "participants" in orch_state.metadata: + self._participants = dict(orch_state.metadata["participants"]) + if "manager_name" in orch_state.metadata: + self._manager_name = orch_state.metadata["manager_name"] + if "pending_agent" in orch_state.metadata: + self._pending_agent = orch_state.metadata["pending_agent"] + if "history" in orch_state.metadata: + self._history = [ + GroupChatTurn( + speaker=turn["speaker"], + role=turn["role"], + message=ChatMessage.from_dict(turn["message"]), + ) + for turn in orch_state.metadata["history"] + ] async def _apply_directive( self, @@ -351,17 +410,14 @@ async def _apply_directive( if directive.finish: final_message = directive.final_message if final_message is None: - final_message = ChatMessage( - role=Role.ASSISTANT, + final_message = create_completion_message( text="Completed without final summary.", author_name=self._manager_name, + reason="no summary provided", ) - elif not final_message.author_name: - message_dict = final_message.to_dict() - message_dict["author_name"] = self._manager_name - final_message = ChatMessage.from_dict(message_dict) + final_message = ensure_author(final_message, self._manager_name) - self._conversation.append(final_message) + append_messages(self._conversation, (final_message,)) self._history.append(GroupChatTurn(self._manager_name, "manager", final_message)) self._pending_agent = None await ctx.yield_output(final_message) @@ -373,51 +429,42 @@ async def _apply_directive( if agent_name not in self._participants: raise ValueError(f"Manager selected unknown participant '{agent_name}'.") - entry_id = self._participant_entry_ids.get(agent_name) + entry_id = self._registry.get_entry_id(agent_name) if entry_id is None: raise ValueError(f"No registered entry executor for participant '{agent_name}'.") instruction = directive.instruction or "" - conversation = list(self._conversation) + conversation = clone_conversation(self._conversation) if instruction: - manager_message = ChatMessage( - role=Role.USER, - text=instruction, - author_name=self._manager_name, + manager_message = ensure_author( + create_completion_message(text=instruction, author_name=self._manager_name), + self._manager_name, ) - conversation.append(manager_message) - self._conversation.append(manager_message) + append_messages(conversation, (manager_message,)) + append_messages(self._conversation, (manager_message,)) self._history.append(GroupChatTurn(self._manager_name, "manager", manager_message)) self._pending_agent = agent_name self._round_index += 1 - if agent_name in self._agent_executor_ids: - await ctx.send_message( - AgentExecutorRequest(messages=conversation, should_respond=True), - target_id=entry_id, - ) - else: - request = GroupChatRequestMessage( - agent_name=agent_name, - conversation=conversation, - task=self._task_message, - metadata=dict(directive.metadata or {}), - ) - await ctx.send_message(request, target_id=entry_id) + # Use inherited routing method from BaseGroupChatOrchestrator + await self._route_to_participant( + participant_name=agent_name, + conversation=conversation, + ctx=ctx, + instruction=instruction, + task=self._task_message, + metadata=directive.metadata, + ) - if self._max_rounds is not None and self._round_index >= self._max_rounds: - logger.warning( - "GroupChatOrchestratorExecutor reached max_rounds=%s; forcing completion.", - self._max_rounds, - ) + if self._check_round_limit(self._round_index, self._max_rounds, pattern_name="GroupChat"): await self._apply_directive( GroupChatDirective( finish=True, - final_message=ChatMessage( - role=Role.ASSISTANT, + final_message=create_completion_message( text="Conversation halted after reaching manager round limit.", author_name=self._manager_name, + reason="max_rounds reached", ), ), ctx, @@ -434,12 +481,8 @@ async def _ingest_participant_message( logger.debug("Ignoring response from unknown participant '%s'.", participant_name) return - if not message.author_name: - message_dict = message.to_dict() - message_dict["author_name"] = participant_name - message = ChatMessage.from_dict(message_dict) - - self._conversation.append(message) + message = ensure_author(message, participant_name) + append_messages(self._conversation, (message,)) self._history.append(GroupChatTurn(participant_name, "agent", message)) self._pending_agent = None @@ -449,10 +492,10 @@ async def _ingest_participant_message( self._max_rounds, ) await ctx.yield_output( - ChatMessage( - role=Role.ASSISTANT, + create_completion_message( text="Conversation halted after reaching manager round limit.", author_name=self._manager_name, + reason="max_rounds reached after response", ) ) return @@ -479,12 +522,8 @@ def _extract_agent_message(response: AgentExecutorResponse, participant_name: st break if final_message is None: - final_message = ChatMessage(role=Role.ASSISTANT, text="", author_name=participant_name) - elif not final_message.author_name: - message_dict = final_message.to_dict() - message_dict["author_name"] = participant_name - final_message = ChatMessage.from_dict(message_dict) - return final_message + final_message = create_completion_message(text="", author_name=participant_name) + return ensure_author(final_message, participant_name) async def _handle_task_message( self, @@ -523,7 +562,7 @@ async def _handle_task_message( """ self._task_message = task_message if self._pending_initial_conversation: - initial_conversation = list(self._pending_initial_conversation) + initial_conversation = clone_conversation(self._pending_initial_conversation) self._pending_initial_conversation = None self._conversation = initial_conversation self._history = [ @@ -614,8 +653,8 @@ async def handle_conversation( """ if not conversation: raise ValueError("GroupChat workflow requires at least one chat message.") - self._pending_initial_conversation = list(conversation) - task_message = conversation[0] + self._pending_initial_conversation = clone_conversation(conversation) + task_message = latest_user_message(conversation) await self._handle_task_message(task_message, ctx) @handler @@ -634,7 +673,7 @@ async def handle_agent_executor_response( ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], ) -> None: """Handle direct AgentExecutor responses.""" - participant_name = self._executor_id_to_participant.get(response.executor_id) + participant_name = self._registry.get_participant_name(response.executor_id) if participant_name is None: logger.debug( "Ignoring response from unregistered agent executor '%s'.", @@ -682,6 +721,67 @@ def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: ) +def group_chat_orchestrator(factory: GroupChatOrchestratorFactory | None = None) -> GroupChatOrchestratorFactory: + """Return a callable orchestrator factory, defaulting to the built-in implementation.""" + return factory or _default_orchestrator_factory + + +def assemble_group_chat_workflow( + *, + wiring: GroupChatWiring, + participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantPipeline], + orchestrator_factory: GroupChatOrchestratorFactory = _default_orchestrator_factory, + interceptors: Sequence[InterceptorSpec] | None = None, + checkpoint_storage: CheckpointStorage | None = None, + builder: WorkflowBuilder | None = None, + return_builder: bool = False, +) -> Workflow | tuple[WorkflowBuilder, Executor]: + """Build the workflow graph shared by group-chat style orchestrators.""" + interceptor_specs = interceptors or () + + orchestrator = wiring.orchestrator or orchestrator_factory(wiring) + wiring.orchestrator = orchestrator + + workflow_builder = builder or WorkflowBuilder() + workflow_builder = workflow_builder.set_start_executor(orchestrator) + + for name, spec in wiring.participants.items(): + pipeline = list(participant_factory(spec, wiring)) + if not pipeline: + raise ValueError( + f"Participant factory returned an empty pipeline for '{name}'. " + "Provide at least one executor per participant." + ) + entry_executor = pipeline[0] + exit_executor = pipeline[-1] + + register_entry = getattr(orchestrator, "register_participant_entry", None) + if callable(register_entry): + register_entry( + name, + entry_id=entry_executor.id, + is_agent=not isinstance(spec.participant, Executor), + ) + + workflow_builder = workflow_builder.add_edge(orchestrator, entry_executor) + for upstream, downstream in itertools.pairwise(pipeline): + workflow_builder = workflow_builder.add_edge(upstream, downstream) + if exit_executor is not orchestrator: + workflow_builder = workflow_builder.add_edge(exit_executor, orchestrator) + + for factory, condition in interceptor_specs: + interceptor_executor = factory(wiring) + workflow_builder = workflow_builder.add_edge(orchestrator, interceptor_executor, condition=condition) + workflow_builder = workflow_builder.add_edge(interceptor_executor, orchestrator) + + if checkpoint_storage is not None: + workflow_builder = workflow_builder.with_checkpointing(checkpoint_storage) + + if return_builder: + return workflow_builder, orchestrator + return workflow_builder.build() + + # endregion @@ -726,7 +826,7 @@ class GroupChatBuilder: def __init__( self, *, - _orchestrator_factory: Callable[[GroupChatWiring], Executor] | None = None, + _orchestrator_factory: GroupChatOrchestratorFactory | None = None, _participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantPipeline] | None = None, ) -> None: @@ -739,13 +839,13 @@ def __init__( Used by Magentic. Not part of public API - subject to change. """ self._participants: dict[str, AgentProtocol | Executor] = {} - self._participant_descriptions: dict[str, str] = {} + self._participant_metadata: dict[str, Any] | None = None self._manager: _GroupChatManagerFn | None = None self._manager_name: str = "manager" self._checkpoint_storage: CheckpointStorage | None = None self._max_rounds: int | None = None - self._request_handler: tuple[Executor, Callable[[Any], bool]] | None = None - self._orchestrator_factory = _orchestrator_factory or _default_orchestrator_factory + self._interceptors: list[InterceptorSpec] = [] + self._orchestrator_factory = group_chat_orchestrator(_orchestrator_factory) self._participant_factory = _participant_factory or _default_participant_factory def _set_manager_function( @@ -881,15 +981,17 @@ def _add(name: str, participant: AgentProtocol | Executor) -> None: _add(name, participant) else: for participant in participants: + inferred_name: str if isinstance(participant, Executor): inferred_name = participant.id else: - inferred_name = getattr(participant, "name", None) - if not inferred_name: - raise ValueError( - "Agent participants supplied via sequence must define a non-empty 'name' attribute." - ) - _add(str(inferred_name), participant) + name_attr = getattr(participant, "name", None) + if not name_attr: + raise ValueError( + "Agent participants supplied via sequence must define a non-empty 'name' attribute." + ) + inferred_name = str(name_attr) + _add(inferred_name, participant) for name, participant in named_participants.items(): _add(name, participant) @@ -899,12 +1001,7 @@ def _add(name: str, participant: AgentProtocol | Executor) -> None: for name, participant in combined.items(): self._participants[name] = participant - description = "" - if isinstance(participant, Executor): - description = participant.id - else: - description = getattr(participant, "description", None) or participant.__class__.__name__ - self._participant_descriptions[name] = description + self._participant_metadata = None return self def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupChatBuilder": @@ -939,44 +1036,31 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupCha def with_request_handler( self, - executor: Executor, + handler: Callable[[GroupChatWiring], Executor] | Executor, *, condition: Callable[[Any], bool], ) -> "GroupChatBuilder": - """Register an executor that intercepts and handles special orchestrator requests. - - This advanced feature allows custom executors to process specific messages - emitted by the orchestrator before they reach participants. Useful for - implementing plan review, validation gates, or custom routing logic. + """Register an interceptor factory that creates executors for special requests. Args: - executor: Executor instance that handles intercepted requests - condition: Callable that returns True for messages this executor should handle + handler: Callable that receives the wiring and returns an executor, or a pre-built executor + condition: Filter determining which orchestrator messages the interceptor should process Returns: Self for fluent chaining + """ + factory: Callable[[GroupChatWiring], Executor] + if isinstance(handler, Executor): + executor = handler - Usage: - - .. code-block:: python - - from agent_framework import GroupChatBuilder, Executor - - - def is_plan_review(msg: Any) -> bool: - return isinstance(msg, dict) and msg.get("type") == "plan_review" + def _factory(_: GroupChatWiring) -> Executor: + return executor + factory = _factory + else: + factory = handler - review_executor = PlanReviewExecutor() - workflow = ( - GroupChatBuilder() - .set_prompt_based_manager(chat_client) - .participants(agent1=agent1) - .with_request_handler(review_executor, condition=is_plan_review) - .build() - ) - """ - self._request_handler = (executor, condition) + self._interceptors.append((factory, condition)) return self def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": @@ -1018,13 +1102,28 @@ def with_max_rounds(self, max_rounds: int | None) -> "GroupChatBuilder": self._max_rounds = max_rounds return self + def _get_participant_metadata(self) -> dict[str, Any]: + if self._participant_metadata is None: + self._participant_metadata = prepare_participant_metadata( + self._participants, + executor_id_factory=lambda name, participant: ( + participant.id if isinstance(participant, Executor) else f"groupchat_agent:{name}" + ), + description_factory=lambda name, participant: ( + participant.id if isinstance(participant, Executor) else participant.__class__.__name__ + ), + ) + return self._participant_metadata + def _build_participant_specs(self) -> dict[str, GroupChatParticipantSpec]: + metadata = self._get_participant_metadata() + descriptions: Mapping[str, str] = metadata["descriptions"] specs: dict[str, GroupChatParticipantSpec] = {} for name, participant in self._participants.items(): specs[name] = GroupChatParticipantSpec( name=name, participant=participant, - description=self._participant_descriptions[name], + description=descriptions[name], ) return specs @@ -1074,51 +1173,27 @@ def build(self) -> Workflow: if not self._participants: raise ValueError("participants must be configured before build()") + metadata = self._get_participant_metadata() participant_specs = self._build_participant_specs() wiring = GroupChatWiring( manager=self._manager, manager_name=self._manager_name, participants=participant_specs, max_rounds=self._max_rounds, + participant_aliases=metadata["aliases"], + participant_executors=metadata["executors"], ) - orchestrator = self._orchestrator_factory(wiring) - wiring.orchestrator = orchestrator - - workflow_builder = WorkflowBuilder().set_start_executor(orchestrator) - - for name, spec in participant_specs.items(): - pipeline = list(self._participant_factory(spec, wiring)) - if not pipeline: - raise ValueError( - f"Participant factory returned an empty pipeline for '{name}'. " - "Provide at least one executor per participant." - ) - entry_executor = pipeline[0] - exit_executor = pipeline[-1] - register_entry = getattr(orchestrator, "register_participant_entry", None) - if callable(register_entry): - register_entry( - name, - entry_id=entry_executor.id, - is_agent=not isinstance(spec.participant, Executor), - ) - - workflow_builder = workflow_builder.add_edge(orchestrator, entry_executor) - for upstream, downstream in itertools.pairwise(pipeline): - workflow_builder = workflow_builder.add_edge(upstream, downstream) - if exit_executor is not orchestrator: - workflow_builder = workflow_builder.add_edge(exit_executor, orchestrator) - - if self._request_handler is not None: - handler_executor, condition = self._request_handler - workflow_builder = workflow_builder.add_edge(orchestrator, handler_executor, condition=condition) - workflow_builder = workflow_builder.add_edge(handler_executor, orchestrator) - - if self._checkpoint_storage is not None: - workflow_builder = workflow_builder.with_checkpointing(self._checkpoint_storage) - - return workflow_builder.build() + result = assemble_group_chat_workflow( + wiring=wiring, + participant_factory=self._participant_factory, + orchestrator_factory=self._orchestrator_factory, + interceptors=self._interceptors, + checkpoint_storage=self._checkpoint_storage, + ) + if not isinstance(result, Workflow): + raise TypeError("Expected Workflow from assemble_group_chat_workflow") + return result # endregion @@ -1127,28 +1202,67 @@ def build(self) -> Workflow: # region Default manager implementation -class _ManagerDirectiveModel(BaseModel): - """Pydantic model for structured output from LLM manager decisions. - - Defines the JSON schema that the prompt-based manager expects from the LLM's - response_format output. This ensures type-safe parsing and validation of manager - directives. - - Attributes: - next_agent: Name of participant to speak next (null when finishing) - message: Optional instruction for the selected participant - finish: Boolean indicating if the task is complete - final_response: Final answer to the user (only when finish=True) - - Usage: - The LLM is prompted to return this exact structure via structured output, - which is then parsed and converted to GroupChatDirective for orchestrator routing. - """ - - next_agent: str | None = None - message: str | None = None - finish: bool = False - final_response: str | None = None +class ManagerDirectivePayload(TypedDict, total=False): + """Typed mapping describing a manager directive.""" + + next_agent: str | None + message: str | None + finish: bool + final_response: str | None + + +def _coerce_directive_source(value: Any) -> dict[str, Any]: + """Attempt to convert structured output into a plain mapping.""" + if isinstance(value, dict): + return value # type: ignore[return-value,no-any-return] + if isinstance(value, Mapping): + return dict(value) # type: ignore[arg-type] + if hasattr(value, "model_dump") and callable(value.model_dump): # type: ignore[attr-defined] + result = value.model_dump() # type: ignore[attr-defined] + return dict(result) if isinstance(result, Mapping) else result # type: ignore[arg-type,return-value] + if hasattr(value, "to_dict") and callable(value.to_dict): # type: ignore[attr-defined] + result = value.to_dict() # type: ignore[attr-defined] + return dict(result) if isinstance(result, Mapping) else result # type: ignore[arg-type,return-value] + if isinstance(value, str): + parsed = json.loads(value) + return dict(parsed) if isinstance(parsed, Mapping) else parsed # type: ignore[arg-type,return-value] + dict_value = getattr(value, "__dict__", None) + if dict_value is not None: + return dict(dict_value) # type: ignore[arg-type] + return value # type: ignore[return-value,no-any-return] + + +def _parse_manager_payload(raw: Any) -> ManagerDirectivePayload: + """Validate raw manager output into ``ManagerDirectivePayload``.""" + data = _coerce_directive_source(raw) + if not isinstance(data, dict): + raise RuntimeError("Unable to parse manager directive from chat client response.") + + payload: ManagerDirectivePayload = {} + + next_agent_value = data.get("next_agent") + if next_agent_value is not None and not isinstance(next_agent_value, str): + raise RuntimeError("Manager directive 'next_agent' must be a string or null.") + payload["next_agent"] = next_agent_value # type: ignore[typeddict-item] + + message_value = data.get("message") + if message_value is not None and not isinstance(message_value, str): + raise RuntimeError("Manager directive 'message' must be a string when provided.") + if message_value is not None: + payload["message"] = message_value + + finish_value = data.get("finish", False) + if not isinstance(finish_value, bool): + raise RuntimeError("Manager directive 'finish' must be a boolean.") + payload["finish"] = finish_value + + final_response_value = data.get("final_response") + if final_response_value is not None and not isinstance(final_response_value, str): + raise RuntimeError("Manager directive 'final_response' must be a string when provided.") + if final_response_value is not None: + payload["final_response"] = final_response_value + + return payload DEFAULT_MANAGER_INSTRUCTIONS = """You are coordinating a team conversation to solve the user's task. @@ -1172,8 +1286,7 @@ class _PromptBasedGroupChatManager: - Receives immutable state snapshot with full conversation history - Formats system prompt with instructions, task, and participant descriptions - Appends conversation context and structured output prompt - - Calls LLM with response_format=_ManagerDirectiveModel for type safety - - Parses LLM response and converts to GroupChatDirective + - Parses LLM response (JSON mapping) and converts to GroupChatDirective Flexibility: - Custom instructions allow domain-specific coordination strategies @@ -1191,7 +1304,7 @@ class _PromptBasedGroupChatManager: name: Display name for the manager in conversation history Raises: - RuntimeError: If LLM response cannot be parsed into _ManagerDirectiveModel + RuntimeError: If LLM response cannot be parsed into the directive payload If directive is missing next_agent when finish=False If selected agent is not in participants """ @@ -1234,25 +1347,23 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: ) ) + response = await self._chat_client.get_response(messages) + payload_source: Any + if response.value is not None: + payload_source = response.value + elif response.messages: + payload_source = response.messages[-1].text or "{}" + else: + raise RuntimeError("LLM response did not contain structured output.") + try: - response = await self._chat_client.get_response( - messages, - response_format=_ManagerDirectiveModel, - ) - directive_obj: _ManagerDirectiveModel - if response.value is not None: - directive_obj = _ManagerDirectiveModel.model_validate(response.value) - elif response.messages: - payload = response.messages[-1].text or "{}" - directive_obj = _ManagerDirectiveModel.model_validate_json(payload) - else: - raise RuntimeError("LLM response did not contain structured output.") - except (ValidationError, json.JSONDecodeError) as exc: + directive_payload = _parse_manager_payload(payload_source) + except (json.JSONDecodeError, RuntimeError) as exc: logger.error("Failed to parse manager directive: %s", exc) raise RuntimeError("Unable to parse manager directive from chat client response.") from exc - if directive_obj.finish: - final_text = directive_obj.final_response or "" + if directive_payload.get("finish", False): + final_text = directive_payload.get("final_response") or "" return GroupChatDirective( finish=True, final_message=ChatMessage( @@ -1262,7 +1373,7 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: ), ) - next_agent = directive_obj.next_agent + next_agent = directive_payload.get("next_agent") if not next_agent: raise RuntimeError("Manager directive missing next_agent while finish is False.") if next_agent not in participants: @@ -1270,7 +1381,7 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: return GroupChatDirective( agent_name=next_agent, - instruction=directive_obj.message or "", + instruction=directive_payload.get("message") or "", ) @@ -1299,9 +1410,12 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: if not result: message = await self._resolve_final_message(state) return GroupChatDirective(finish=True, final_message=message) - if len(result) != 1: + if len(result) != 1: # type: ignore[arg-type] raise ValueError("Speaker selector must return a single participant name or None.") - result = result[0] + first_item = result[0] # type: ignore[index] + if not isinstance(first_item, str): + raise TypeError("Speaker selector must return a participant name (str) or None.") + result = first_item if not isinstance(result, str): raise TypeError("Speaker selector must return a participant name (str) or None.") diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 725e50cb25c..dc4af012e89 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -35,9 +35,18 @@ from .._agents import ChatAgent from .._middleware import FunctionInvocationContext, FunctionMiddleware from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse +from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage -from ._conversation_state import decode_chat_messages, encode_chat_messages +from ._conversation_history import append_messages, clone_conversation from ._executor import Executor, handler +from ._group_chat import ( + GroupChatParticipantSpec, + GroupChatWiring, + _default_participant_factory, # type: ignore + assemble_group_chat_workflow, +) +from ._orchestrator_helpers import clean_conversation_for_handoff +from ._participant_utils import prepare_participant_metadata, sanitize_identifier from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -49,19 +58,9 @@ _HANDOFF_TOOL_PATTERN = re.compile(r"(?:handoff|transfer)[_\s-]*to[_\s-]*(?P[\w-]+)", re.IGNORECASE) -def _sanitize_alias(value: str) -> str: - """Normalise an agent alias into a lowercase identifier-safe string.""" - cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", value).strip("_") - if not cleaned: - cleaned = "agent" - if cleaned[0].isdigit(): - cleaned = f"agent_{cleaned}" - return cleaned.lower() - - def _create_handoff_tool(alias: str, description: str | None = None) -> AIFunction[Any, Any]: """Construct the synthetic handoff tool that signals routing to `alias`.""" - sanitized = _sanitize_alias(alias) + sanitized = sanitize_identifier(alias) tool_name = f"handoff_to_{sanitized}" doc = description or f"Handoff to the {alias} agent." @@ -257,7 +256,7 @@ def _target_from_tool_name(name: str | None) -> str | None: return None -class _HandoffCoordinator(Executor): +class _HandoffCoordinator(BaseGroupChatOrchestrator): """Coordinates agent-to-agent transfers and user turn requests.""" def __init__( @@ -294,7 +293,7 @@ async def handle_agent_response( elif not self._full_conversation: restored = self._restore_conversation_from_state(state) if restored: - self._full_conversation = restored + self._full_conversation = clone_conversation(restored) source = ctx.get_source_executor_id() is_starting_agent = source == self._starting_agent_id @@ -305,23 +304,23 @@ async def handle_agent_response( # First response from starting agent - initialize with authoritative conversation snapshot # Keep the FULL conversation including tool calls (OpenAI SDK default behavior) full_conv = self._conversation_from_response(response) - self._full_conversation = list(full_conv) + self._full_conversation = clone_conversation(full_conv) else: # Subsequent responses - append only new messages from this agent # Keep ALL messages including tool calls to maintain complete history - new_messages = list(response.agent_run_response.messages) - self._full_conversation.extend(new_messages) + new_messages = response.agent_run_response.messages or [] + append_messages(self._full_conversation, new_messages) self._apply_response_metadata(self._full_conversation, response.agent_run_response) - conversation = list(self._full_conversation) + conversation = clone_conversation(self._full_conversation) # Check for handoff from ANY agent (starting agent or specialist) target = self._resolve_specialist(response.agent_run_response, conversation) if target is not None: await self._persist_state(ctx) # Clean tool-related content before sending to next agent - cleaned = self._get_cleaned_conversation(conversation) + cleaned = clean_conversation_for_handoff(conversation) request = AgentExecutorRequest(messages=cleaned, should_respond=True) await ctx.send_message(request, target_id=target) return @@ -347,7 +346,7 @@ async def handle_user_input( ) -> None: """Receive full conversation with new user input from gateway, update history, trim for agent.""" # Update authoritative full conversation - self._full_conversation = list(message.full_conversation) + self._full_conversation = clone_conversation(message.full_conversation) await self._persist_state(ctx) # Check termination before sending to agent @@ -357,7 +356,7 @@ async def handle_user_input( return # Clean before sending to starting agent - cleaned = self._get_cleaned_conversation(self._full_conversation) + cleaned = clean_conversation_for_handoff(self._full_conversation) request = AgentExecutorRequest(messages=cleaned, should_respond=True) await ctx.send_message(request, target_id=self._starting_agent_id) @@ -409,8 +408,8 @@ def _append_tool_acknowledgement( author_name=function_call.name, ) # Add tool acknowledgement to both the conversation being sent and the full history - conversation.append(tool_message) - self._full_conversation.append(tool_message) + append_messages(conversation, (tool_message,)) + append_messages(self._full_conversation, (tool_message,)) def _conversation_from_response(self, response: AgentExecutorResponse) -> list[ChatMessage]: """Return the authoritative conversation snapshot from an executor response.""" @@ -421,78 +420,50 @@ def _conversation_from_response(self, response: AgentExecutorResponse) -> list[C ) return list(conversation) - def _get_cleaned_conversation(self, conversation: list[ChatMessage]) -> list[ChatMessage]: - """Create a cleaned copy of conversation with tool-related content removed. + async def _persist_state(self, ctx: WorkflowContext[Any, Any]) -> None: + """Store authoritative conversation snapshot without losing rich metadata.""" + state_payload = self.snapshot_state() + await ctx.set_executor_state(state_payload) + + def snapshot_state(self) -> dict[str, Any]: + """Capture current coordinator state for checkpointing. - This method creates a copy of the conversation and removes tool-related content - before passing it to agents. The original conversation is preserved for handoff - detection and state management. + Serializes conversation history using unified OrchestrationState structure. - During handoffs, tool calls (including handoff tools) cause OpenAI API errors. The OpenAI - API requires that: - 1. Assistant messages with tool_calls must be followed by corresponding tool responses - 2. Tool response messages must follow an assistant message with tool_calls + Returns: + Dict ready for checkpoint persistence + """ + from ._orchestration_state import OrchestrationState - To avoid these errors, we remove ALL tool-related content from the conversation: - - FunctionApprovalRequestContent and FunctionCallContent from assistant messages - - Tool response messages (Role.TOOL) + state = OrchestrationState( + conversation=list(self._full_conversation), + metadata={}, # Handoff has no additional metadata to checkpoint + ) + return state.to_dict() - This follows the pattern from OpenAI Agents SDK's `remove_all_tools` filter, which strips - all tool-related content from conversation history during handoffs. + def restore_state(self, state: dict[str, Any]) -> None: + """Restore coordinator state from checkpoint. - Removes: - - FunctionApprovalRequestContent: Approval requests for tools - - FunctionCallContent: Tool calls made by the agent - - Tool response messages (Role.TOOL with FunctionResultContent) - - Messages with only tool calls and no text content + Deserializes checkpointed state using OrchestrationState. - Preserves: - - User messages - - Assistant messages with text content (tool calls are stripped out) + Args: + state: Checkpoint data dict """ - # Create a copy to avoid modifying the original - cleaned: list[ChatMessage] = [] - for msg in conversation: - # Skip tool response messages - they must be paired with tool calls which we're removing - if msg.role == Role.TOOL: - continue - - # Check if message has tool-related content - has_tool_content = False - if msg.contents: - has_tool_content = any( - isinstance(content, (FunctionApprovalRequestContent, FunctionCallContent)) - for content in msg.contents - ) + from ._orchestration_state import OrchestrationState - # If no tool content, keep the original message - if not has_tool_content: - cleaned.append(msg) - continue - - # Message has tool content - only keep if it also has text - if msg.text and msg.text.strip(): - # Create fresh text-only message to avoid tool_calls being regenerated - msg_copy = ChatMessage( - role=msg.role, - text=msg.text, - author_name=msg.author_name, - ) - cleaned.append(msg_copy) + orch_state = OrchestrationState.from_dict(state) + self._full_conversation = list(orch_state.conversation) - return cleaned + def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[ChatMessage]: + """Rehydrate the coordinator's conversation history from checkpointed state. - async def _persist_state(self, ctx: WorkflowContext[Any, Any]) -> None: - """Store authoritative conversation snapshot without losing rich metadata.""" - state_payload = {"full_conversation": encode_chat_messages(self._full_conversation)} - await ctx.set_executor_state(state_payload) + DEPRECATED: Use restore_state() instead. Kept for backward compatibility. + """ + from ._orchestration_state import OrchestrationState - def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[ChatMessage]: - """Rehydrate the coordinator's conversation history from checkpointed state.""" - raw_conv = state.get("full_conversation") - if not isinstance(raw_conv, list): - return [] - return decode_chat_messages(raw_conv) # type: ignore[arg-type] + orch_state_dict = {"conversation": state.get("full_conversation", state.get("conversation", []))} + temp_state = OrchestrationState.from_dict(orch_state_dict) + return list(temp_state.conversation) def _apply_response_metadata(self, conversation: list[ChatMessage], agent_response: AgentRunResponse) -> None: """Merge top-level response metadata into the latest assistant message.""" @@ -814,36 +785,41 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han if not participants: raise ValueError("participants cannot be empty") - wrapped: list[Executor] = [] + named: dict[str, AgentProtocol | Executor] = {} + for participant in participants: + identifier: str + if isinstance(participant, Executor): + identifier = participant.id + elif isinstance(participant, AgentProtocol): + name_attr = getattr(participant, "name", None) + if not name_attr: + raise ValueError( + "Agents used in handoff workflows must have a stable name " + "so they can be addressed during routing." + ) + identifier = str(name_attr) + else: + raise TypeError( + f"Participants must be AgentProtocol or Executor instances. Got {type(participant).__name__}." + ) + if identifier in named: + raise ValueError(f"Duplicate participant name '{identifier}' detected") + named[identifier] = participant + + metadata = prepare_participant_metadata( + named, + description_factory=lambda name, participant: getattr(participant, "description", None) or name, + ) + + wrapped = metadata["executors"] seen_ids: set[str] = set() - alias_map: dict[str, str] = {} - - def _register_alias(alias: str | None, exec_id: str) -> None: - """Record canonical and sanitised aliases that resolve to the executor id.""" - if not alias: - return - alias_map[alias] = exec_id - sanitized = _sanitize_alias(alias) - if sanitized and sanitized not in alias_map: - alias_map[sanitized] = exec_id - - for p in participants: - executor = self._wrap_participant(p) + for executor in wrapped.values(): if executor.id in seen_ids: raise ValueError(f"Duplicate participant with id '{executor.id}' detected") seen_ids.add(executor.id) - wrapped.append(executor) - - _register_alias(executor.id, executor.id) - if isinstance(p, AgentProtocol): - name = getattr(p, "name", None) - _register_alias(name, executor.id) - display = getattr(p, "display_name", None) - if isinstance(display, str) and display: - _register_alias(display, executor.id) - - self._executors = {executor.id: executor for executor in wrapped} - self._aliases = alias_map + + self._executors = {executor.id: executor for executor in wrapped.values()} + self._aliases = metadata["aliases"] self._starting_agent_id = None return self @@ -1023,7 +999,7 @@ def _apply_auto_tools(self, agent: ChatAgent, specialists: Mapping[str, Executor new_tools: list[Any] = [] for exec_id in specialists: alias = exec_id - sanitized = _sanitize_alias(alias) + sanitized = sanitize_identifier(alias) tool = _create_handoff_tool(alias) if tool.name not in existing_names: new_tools.append(tool) @@ -1308,6 +1284,14 @@ def build(self) -> Workflow: if not specialists: logger.warning("Handoff workflow has no specialist agents; the coordinator will loop with the user.") + descriptions = { + exec_id: getattr(executor, "description", None) or exec_id for exec_id, executor in self._executors.items() + } + participant_specs = { + exec_id: GroupChatParticipantSpec(name=exec_id, participant=executor, description=descriptions[exec_id]) + for exec_id, executor in self._executors.items() + } + input_node = _InputToConversation(id="input-conversation") request_info = RequestInfoExecutor(id=f"{starting_executor.id}_handoff_requests") user_gateway = _UserInputGateway( @@ -1316,47 +1300,49 @@ def build(self) -> Workflow: prompt=self._request_prompt, id="handoff-user-input", ) - coordinator = _HandoffCoordinator( - starting_agent_id=starting_executor.id, - specialist_ids={alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists}, - input_gateway_id=user_gateway.id, - termination_condition=self._termination_condition, - id="handoff-coordinator", - handoff_tool_targets=handoff_tool_targets, - ) - builder = WorkflowBuilder(name=self._name, description=self._description) - builder.set_start_executor(input_node) - builder.add_edge(input_node, starting_executor) - builder.add_edge(starting_executor, coordinator) + specialist_aliases = {alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists} - for specialist in specialists.values(): - builder.add_edge(coordinator, specialist) - builder.add_edge(specialist, coordinator) + def _handoff_orchestrator_factory(_: GroupChatWiring) -> Executor: + return _HandoffCoordinator( + starting_agent_id=starting_executor.id, + specialist_ids=specialist_aliases, + input_gateway_id=user_gateway.id, + termination_condition=self._termination_condition, + id="handoff-coordinator", + handoff_tool_targets=handoff_tool_targets, + ) - builder.add_edge(coordinator, user_gateway) - builder.add_edge(user_gateway, request_info) - builder.add_edge(request_info, user_gateway) - builder.add_edge(user_gateway, coordinator) # Route back to coordinator, not directly to agent - builder.add_edge(coordinator, starting_executor) # Coordinator sends trimmed request to agent + wiring = GroupChatWiring( + manager=None, + manager_name=self._starting_agent_id, + participants=participant_specs, + max_rounds=None, + participant_aliases=self._aliases, + participant_executors=self._executors, + ) - if self._checkpoint_storage is not None: - builder = builder.with_checkpointing(self._checkpoint_storage) + result = assemble_group_chat_workflow( + wiring=wiring, + participant_factory=_default_participant_factory, + orchestrator_factory=_handoff_orchestrator_factory, + interceptors=(), + checkpoint_storage=self._checkpoint_storage, + builder=WorkflowBuilder(name=self._name, description=self._description), + return_builder=True, + ) + if not isinstance(result, tuple): + raise TypeError("Expected tuple from assemble_group_chat_workflow with return_builder=True") + builder, coordinator = result - return builder.build() + builder = builder.set_start_executor(input_node) + builder = builder.add_edge(input_node, starting_executor) + builder = builder.add_edge(coordinator, user_gateway) + builder = builder.add_edge(user_gateway, request_info) + builder = builder.add_edge(request_info, user_gateway) + builder = builder.add_edge(user_gateway, coordinator) - def _wrap_participant(self, participant: AgentProtocol | Executor) -> Executor: - """Ensure every participant is represented as an Executor instance.""" - if isinstance(participant, Executor): - return participant - if isinstance(participant, AgentProtocol): - name = getattr(participant, "name", None) - if not name: - raise ValueError( - "Agents used in handoff workflows must have a stable name so they can be addressed during routing." - ) - return AgentExecutor(participant, id=name) - raise TypeError(f"Participants must be AgentProtocol or Executor instances. Got {type(participant).__name__}.") + return builder.build() def _resolve_to_id(self, candidate: str | AgentProtocol | Executor) -> str: """Resolve a participant reference into a concrete executor identifier.""" diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 7917b934046..2c9ae792cc0 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -23,8 +23,8 @@ FunctionResultContent, Role, ) -from agent_framework._agents import BaseAgent +from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._events import WorkflowEvent from ._executor import Executor, handler @@ -35,9 +35,11 @@ GroupChatRequestMessage, GroupChatResponseMessage, GroupChatWiring, + group_chat_orchestrator, ) from ._message_utils import normalize_messages_input from ._model_utils import DictConvertible, encode_value +from ._participant_utils import participant_description from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse from ._workflow import Workflow, WorkflowRunResult from ._workflow_context import WorkflowContext @@ -320,11 +322,16 @@ def _new_participant_descriptions() -> dict[str, str]: return {} +def _new_chat_message_list() -> list[ChatMessage]: + """Typed default factory for ChatMessage list to satisfy type checkers.""" + return [] + + @dataclass class MagenticStartMessage(DictConvertible): """A message to start a magentic workflow.""" - messages: list[ChatMessage] = field(default_factory=list) + messages: list[ChatMessage] = field(default_factory=_new_chat_message_list) def __init__( self, @@ -337,7 +344,7 @@ def __init__( normalized += normalize_messages_input(task) if not normalized: raise ValueError("MagenticStartMessage requires at least one message input.") - self.messages = normalized + self.messages: list[ChatMessage] = normalized @property def task(self) -> ChatMessage: @@ -363,7 +370,7 @@ def from_dict(cls, data: dict[str, Any]) -> "MagenticStartMessage": raw_messages = data["messages"] if not isinstance(raw_messages, Sequence) or isinstance(raw_messages, (str, bytes)): raise TypeError("MagenticStartMessage 'messages' must be a sequence.") - messages = [ChatMessage.from_dict(raw) for raw in raw_messages] + messages: list[ChatMessage] = [ChatMessage.from_dict(raw) for raw in raw_messages] # type: ignore[arg-type] return cls(messages) if "task" in data: task = ChatMessage.from_dict(data["task"]) @@ -940,7 +947,7 @@ async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatM # region Magentic Executors -class MagenticOrchestratorExecutor(Executor): +class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator): """Magentic orchestrator executor that handles all orchestration logic. This executor manages the entire Magentic One workflow including: @@ -1027,6 +1034,14 @@ async def _emit_orchestrator_message( await ctx.add_event(event) def snapshot_state(self) -> dict[str, Any]: + """Capture current orchestrator state for checkpointing. + + Uses OrchestrationState for structure but maintains Magentic's complex metadata + at the top level for backward compatibility with existing checkpoints. + + Returns: + Dict ready for checkpoint persistence + """ state: dict[str, Any] = { "plan_review_round": self._plan_review_round, "max_plan_review_rounds": self._max_plan_review_rounds, @@ -1045,6 +1060,22 @@ def snapshot_state(self) -> dict[str, Any]: return state def restore_state(self, state: dict[str, Any]) -> None: + """Restore orchestrator state from checkpoint. + + Maintains backward compatibility with existing Magentic checkpoints + while supporting OrchestrationState structure. + + Args: + state: Checkpoint data dict + """ + # Support both old format (direct keys) and new format (wrapped in OrchestrationState) + if "metadata" in state and isinstance(state.get("metadata"), dict): + # New OrchestrationState format - extract metadata + from ._orchestration_state import OrchestrationState + + orch_state = OrchestrationState.from_dict(state) + state = orch_state.metadata + ctx_payload = state.get("magentic_context") if ctx_payload is not None: try: @@ -1568,10 +1599,13 @@ async def _send_plan_review_request( await context.send_message(req) +# region Magentic Executors + + class MagenticAgentExecutor(Executor): """Magentic agent executor that wraps an agent for participation in workflows. - This executor handles: + Leverages enhanced AgentExecutor with conversation injection hooks for: - Receiving task ledger broadcasts - Responding to specific agent requests - Resetting agent state when needed @@ -1589,22 +1623,34 @@ def __init__( self._state_restored = False def snapshot_state(self) -> dict[str, Any]: + """Capture current executor state for checkpointing. + + Returns: + Dict containing serialized chat history + """ + from ._conversation_state import encode_chat_messages + return { - "chat_history": [_message_to_payload(msg) for msg in self._chat_history], + "chat_history": encode_chat_messages(self._chat_history), } def restore_state(self, state: dict[str, Any]) -> None: + """Restore executor state from checkpoint. + + Args: + state: Checkpoint data dict + """ + from ._conversation_state import decode_chat_messages + history_payload = state.get("chat_history") - if not history_payload: - self._chat_history = [] - return - restored: list[ChatMessage] = [] - for item in history_payload: + if history_payload: try: - restored.append(_message_from_payload(item)) + self._chat_history = decode_chat_messages(history_payload) except Exception as exc: # pragma: no cover - logger.debug("Agent %s: Skipping invalid chat history item during restore: %s", self._agent_id, exc) - self._chat_history = restored + logger.warning("Agent %s: Failed to restore chat history: %s", self._agent_id, exc) + self._chat_history = [] + else: + self._chat_history = [] async def _ensure_state_restored(self, context: WorkflowContext[Any, Any]) -> None: if self._state_restored and self._chat_history: @@ -2163,11 +2209,8 @@ def build(self) -> Workflow: # Create participant descriptions participant_descriptions: dict[str, str] = {} for name, participant in self._participants.items(): - if isinstance(participant, BaseAgent): - description = getattr(participant, "description", None) or f"Agent {name}" - else: - description = f"Executor {name}" - participant_descriptions[name] = description + fallback = f"Executor {name}" if isinstance(participant, Executor) else f"Agent {name}" + participant_descriptions[name] = participant_description(participant, fallback) # Type narrowing: we already checked self._manager is not None above manager: MagenticManagerBase = self._manager # type: ignore[assignment] @@ -2195,7 +2238,7 @@ def _participant_factory( # Magentic provides its own orchestrator via custom factory, so no manager is needed group_builder = GroupChatBuilder( - _orchestrator_factory=_orchestrator_factory, + _orchestrator_factory=group_chat_orchestrator(_orchestrator_factory), _participant_factory=_participant_factory, ).participants(self._participants) @@ -2203,9 +2246,8 @@ def _participant_factory( group_builder = group_builder.with_checkpointing(self._checkpoint_storage) if self._enable_plan_review: - request_info = RequestInfoExecutor(id="magentic_plan_review") group_builder = group_builder.with_request_handler( - request_info, + lambda _wiring: RequestInfoExecutor(id="magentic_plan_review"), condition=lambda msg: isinstance(msg, MagenticPlanReviewRequest), ) @@ -2308,7 +2350,7 @@ async def run_stream(self, message: Any | None = None) -> AsyncIterable[Workflow elif isinstance(message, str): message = MagenticStartMessage.from_string(message) elif isinstance(message, (ChatMessage, list)): - message = MagenticStartMessage(message) + message = MagenticStartMessage(message) # type: ignore[arg-type] async for event in self._workflow.run_stream(message): yield event diff --git a/python/packages/core/agent_framework/_workflows/_model_utils.py b/python/packages/core/agent_framework/_workflows/_model_utils.py index 58bd614b340..72380901c63 100644 --- a/python/packages/core/agent_framework/_workflows/_model_utils.py +++ b/python/packages/core/agent_framework/_workflows/_model_utils.py @@ -2,7 +2,7 @@ import copy import sys -from typing import Any, TypeVar +from typing import Any, TypeVar, cast if sys.version_info >= (3, 11): from typing import Self # pragma: no cover @@ -37,7 +37,7 @@ def from_json(cls: type[TModel], raw: str) -> TModel: data = json.loads(raw) if not isinstance(data, dict): raise ValueError("JSON payload must decode to a mapping") - return cls.from_dict(data) + return cls.from_dict(cast(dict[str, Any], data)) def encode_value(value: Any) -> Any: diff --git a/python/packages/core/agent_framework/_workflows/_orchestration_state.py b/python/packages/core/agent_framework/_workflows/_orchestration_state.py new file mode 100644 index 00000000000..26c0068e7a3 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_orchestration_state.py @@ -0,0 +1,92 @@ +# 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 + 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, + ) diff --git a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py new file mode 100644 index 00000000000..1a2cccca1a6 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py @@ -0,0 +1,223 @@ +# 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 typing import TYPE_CHECKING, Any + +from .._types import ChatMessage, Role +from ._conversation_history import clone_conversation + +if TYPE_CHECKING: + from ._group_chat import GroupChatRequestMessage + +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: + - FunctionApprovalRequestContent and FunctionCallContent 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 + """ + from agent_framework import FunctionApprovalRequestContent, FunctionCallContent + + cleaned: list[ChatMessage] = [] + for msg in conversation: + # Skip tool response messages entirely + if msg.role == Role.TOOL: + continue + + # Check for tool-related content + has_tool_content = False + if msg.contents: + has_tool_content = any( + isinstance(content, (FunctionApprovalRequestContent, FunctionCallContent)) 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 + msg_copy = ChatMessage( + role=msg.role, + text=msg.text, + author_name=msg.author_name, + ) + cleaned.append(msg_copy) + + return cleaned + + +def check_round_limit( + current_round: int, + max_rounds: int | None, + *, + pattern_name: str = "orchestrator", +) -> bool: + """Check if round limit has been reached. + + Simple utility to avoid duplicating limit checking logic. + + Args: + current_round: Current round index + max_rounds: Maximum allowed rounds, or None for unlimited + pattern_name: Name for logging (e.g., "group_chat", "handoff") + + Returns: + True if within limits, False if limit reached + """ + if max_rounds is None: + return True + + if current_round >= max_rounds: + logger.warning( + "%s reached max_rounds=%s; stopping coordination.", + pattern_name, + max_rounds, + ) + return False + + return True + + +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=Role.ASSISTANT, + text=message_text, + author_name=author_name, + ) + + +def prepare_participant_request( + *, + participant_name: str, + conversation: list[ChatMessage], + instruction: str | None = None, + task: ChatMessage | None = None, + metadata: dict[str, Any] | None = None, +) -> "GroupChatRequestMessage": + """Create a standardized participant request message. + + Simple helper to avoid duplicating request construction. + + Args: + participant_name: Name of the target participant + conversation: Conversation history to send + instruction: Optional instruction from manager/orchestrator + task: Optional task context + metadata: Optional metadata dict + + Returns: + GroupChatRequestMessage ready to send + """ + # Import here to avoid circular dependency + from ._group_chat import GroupChatRequestMessage + + return GroupChatRequestMessage( + agent_name=participant_name, + conversation=clone_conversation(conversation), + instruction=instruction or "", + task=task, + metadata=metadata, + ) + + +class ParticipantRegistry: + """Simple registry for tracking participant executor IDs and routing info. + + Provides a clean interface for the common pattern of mapping participant names + to executor IDs and tracking which are agents vs custom executors. + """ + + def __init__(self) -> None: + self._participant_entry_ids: dict[str, str] = {} + self._agent_executor_ids: dict[str, str] = {} + self._executor_id_to_participant: dict[str, str] = {} + self._non_agent_participants: set[str] = set() + + def register( + self, + name: str, + *, + entry_id: str, + is_agent: bool, + ) -> None: + """Register a participant's routing information. + + Args: + name: Participant name + entry_id: Executor ID for this participant's entry point + is_agent: Whether this is an AgentExecutor (True) or custom Executor (False) + """ + self._participant_entry_ids[name] = entry_id + + if is_agent: + self._agent_executor_ids[name] = entry_id + self._executor_id_to_participant[entry_id] = name + else: + self._non_agent_participants.add(name) + + def get_entry_id(self, name: str) -> str | None: + """Get the entry executor ID for a participant name.""" + return self._participant_entry_ids.get(name) + + def get_participant_name(self, executor_id: str) -> str | None: + """Get the participant name for an executor ID (agents only).""" + return self._executor_id_to_participant.get(executor_id) + + def is_agent(self, name: str) -> bool: + """Check if a participant is an agent (vs custom executor).""" + return name in self._agent_executor_ids + + def is_registered(self, name: str) -> bool: + """Check if a participant is registered.""" + return name in self._participant_entry_ids + + def all_participants(self) -> set[str]: + """Get all registered participant names.""" + return set(self._participant_entry_ids.keys()) diff --git a/python/packages/core/agent_framework/_workflows/_participant_utils.py b/python/packages/core/agent_framework/_workflows/_participant_utils.py new file mode 100644 index 00000000000..55ed7dde0de --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_participant_utils.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared participant helpers for orchestration builders.""" + +import re +from collections.abc import Callable, Iterable, Mapping +from typing import Any + +from .._agents import AgentProtocol +from ._agent_executor import AgentExecutor +from ._executor import Executor + +_SANITIZE_PATTERN = re.compile(r"[^0-9a-zA-Z]+") + + +def sanitize_identifier(value: str, *, default: str = "agent") -> str: + """Return a deterministic, lowercase identifier derived from `value`.""" + cleaned = _SANITIZE_PATTERN.sub("_", value).strip("_") + if not cleaned: + cleaned = default + if cleaned[0].isdigit(): + cleaned = f"{default}_{cleaned}" + return cleaned.lower() + + +def wrap_participant(participant: AgentProtocol | Executor, *, executor_id: str | None = None) -> Executor: + """Represent `participant` as an `Executor`.""" + if isinstance(participant, Executor): + return participant + if not isinstance(participant, AgentProtocol): + raise TypeError( + f"Participants must implement AgentProtocol or be Executor instances. Got {type(participant).__name__}." + ) + name = getattr(participant, "name", None) + if executor_id is None: + if not name: + raise ValueError("Agent participants must expose a stable 'name' attribute.") + executor_id = str(name) + return AgentExecutor(participant, id=executor_id) + + +def participant_description(participant: AgentProtocol | Executor, fallback: str) -> str: + """Produce a human-readable description for manager context.""" + if isinstance(participant, Executor): + description = getattr(participant, "description", None) + if isinstance(description, str) and description.strip(): + return description.strip() + return fallback + description = getattr(participant, "description", None) + if isinstance(description, str) and description.strip(): + return description.strip() + return fallback + + +def build_alias_map(participant: AgentProtocol | Executor, executor: Executor) -> dict[str, str]: + """Collect canonical and sanitised aliases that should resolve to `executor`.""" + aliases: dict[str, str] = {} + + def _register(values: Iterable[str | None]) -> None: + for value in values: + if not value: + continue + key = str(value) + if key not in aliases: + aliases[key] = executor.id + sanitized = sanitize_identifier(key) + if sanitized not in aliases: + aliases[sanitized] = executor.id + + _register([executor.id]) + + if isinstance(participant, AgentProtocol): + name = getattr(participant, "name", None) + display = getattr(participant, "display_name", None) + _register([name, display]) + else: + display = getattr(participant, "display_name", None) + _register([display]) + + return aliases + + +def merge_alias_maps(maps: Iterable[Mapping[str, str]]) -> dict[str, str]: + """Merge alias mappings, preserving the first occurrence of each alias.""" + merged: dict[str, str] = {} + for mapping in maps: + for key, value in mapping.items(): + merged.setdefault(key, value) + return merged + + +def prepare_participant_metadata( + participants: Mapping[str, AgentProtocol | Executor], + *, + executor_id_factory: Callable[[str, AgentProtocol | Executor], str | None] | None = None, + description_factory: Callable[[str, AgentProtocol | Executor], str] | None = None, +) -> dict[str, dict[str, Any]]: + """Return metadata dicts for participants keyed by participant name.""" + executors: dict[str, Executor] = {} + descriptions: dict[str, str] = {} + alias_maps: list[Mapping[str, str]] = [] + + for name, participant in participants.items(): + desired_id = executor_id_factory(name, participant) if executor_id_factory else None + executor = wrap_participant(participant, executor_id=desired_id) + fallback_description = description_factory(name, participant) if description_factory else executor.id + descriptions[name] = participant_description(participant, fallback_description) + executors[name] = executor + alias_maps.append(build_alias_map(participant, executor)) + + aliases = merge_alias_maps(alias_maps) + return { + "executors": executors, + "descriptions": descriptions, + "aliases": aliases, + } diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index f085fee5b17..de5d328ea9d 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -4,56 +4,72 @@ from collections.abc import Mapping from dataclasses import fields, is_dataclass from types import UnionType -from typing import Any, Union, get_args, get_origin +from typing import Any, TypeVar, Union, cast, get_args, get_origin logger = logging.getLogger(__name__) +T = TypeVar("T") -def _coerce_to_type(value: Any, target_type: type) -> Any | None: - """Best-effort conversion of value into target_type.""" + +def _coerce_to_type(value: Any, target_type: type[T]) -> T | None: + """Best-effort conversion of value into target_type. + + Args: + value: The value to convert (can be dict, dataclass, or object with __dict__) + target_type: The target type to convert to + + Returns: + Instance of target_type if conversion succeeds, None otherwise + """ if isinstance(value, target_type): - return value + return value # type: ignore[return-value] # Convert dataclass instances or objects with __dict__ into dict first + value_as_dict: dict[str, Any] if not isinstance(value, dict): if is_dataclass(value): - value = {f.name: getattr(value, f.name) for f in fields(value)} + value_as_dict = {f.name: getattr(value, f.name) for f in fields(value)} else: value_dict = getattr(value, "__dict__", None) if isinstance(value_dict, dict): - value = dict(value_dict) - - if isinstance(value, dict): - ctor_kwargs: dict[str, Any] = dict(value) + value_as_dict = cast(dict[str, Any], value_dict) + else: + return None + else: + value_as_dict = cast(dict[str, Any], value) + + # Try to construct the target type from the dict + ctor_kwargs: dict[str, Any] = dict(value_as_dict) + + if is_dataclass(target_type): + field_names = {f.name for f in fields(target_type)} + ctor_kwargs = {k: v for k, v in value_as_dict.items() if k in field_names} + + try: + return target_type(**ctor_kwargs) # type: ignore[call-arg,return-value] + except TypeError as exc: + logger.debug(f"_coerce_to_type could not call {target_type.__name__}(**..): {exc}") + except Exception as exc: # pragma: no cover - unexpected constructor failure + logger.warning( + f"_coerce_to_type encountered unexpected error calling {target_type.__name__} constructor: {exc}" + ) - if is_dataclass(target_type): - field_names = {f.name for f in fields(target_type)} - ctor_kwargs = {k: v for k, v in value.items() if k in field_names} + # Fallback: try to create instance without __init__ and set attributes + try: + instance = object.__new__(target_type) + except Exception as exc: # pragma: no cover - pathological type + logger.debug(f"_coerce_to_type could not allocate {target_type.__name__} without __init__: {exc}") + return None + for key, val in value_as_dict.items(): try: - return target_type(**ctor_kwargs) # type: ignore[arg-type] - except TypeError as exc: - logger.debug(f"_coerce_to_type could not call {target_type.__name__}(**..): {exc}") - except Exception as exc: # pragma: no cover - unexpected constructor failure - logger.warning( - f"_coerce_to_type encountered unexpected error calling {target_type.__name__} constructor: {exc}" + setattr(instance, key, val) + except Exception as exc: + logger.debug( + f"_coerce_to_type could not set {target_type.__name__}.{key} during fallback assignment: {exc}" ) - try: - instance: Any = object.__new__(target_type) - except Exception as exc: # pragma: no cover - pathological type - logger.debug(f"_coerce_to_type could not allocate {target_type.__name__} without __init__: {exc}") - return None - for key, val in value.items(): - try: - setattr(instance, key, val) - except Exception as exc: - logger.debug( - f"_coerce_to_type could not set {target_type.__name__}.{key} during fallback assignment: {exc}" - ) - continue - return instance - - return None + continue + return instance # type: ignore[return-value] def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool: @@ -89,14 +105,14 @@ def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool: # Case 3: target_type is a generic type if origin in [list, set]: return isinstance(data, origin) and ( - not args or all(any(is_instance_of(item, arg) for arg in args) for item in data) + not args or all(any(is_instance_of(item, arg) for arg in args) for item in data) # type: ignore[misc] ) # type: ignore # Case 4: target_type is a tuple if origin is tuple: if len(args) == 2 and args[1] is Ellipsis: # Tuple[T, ...] case element_type = args[0] - return isinstance(data, tuple) and all(is_instance_of(item, element_type) for item in data) + return isinstance(data, tuple) and all(is_instance_of(item, element_type) for item in data) # type: ignore[misc] if len(args) == 1 and args[0] is Ellipsis: # Tuple[...] case return isinstance(data, tuple) if len(args) == 0: @@ -135,7 +151,7 @@ def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool: # and validators still receive a fully typed RequestResponse instance. original_request = data.original_request if isinstance(original_request, Mapping): - coerced = _coerce_to_type(dict(original_request), request_type) + coerced = _coerce_to_type(dict(original_request), request_type) # type: ignore[arg-type] if coerced is None or not isinstance(coerced, request_type): return False data.original_request = coerced diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index a91d5af14e3..71141bdc83b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -20,7 +20,7 @@ WorkflowStartedEvent, WorkflowStatusEvent, WorkflowWarningEvent, - _framework_event_origin, + _framework_event_origin, # type: ignore ) from ._runner_context import Message, RunnerContext from ._shared_state import SharedState diff --git a/python/samples/getting_started/workflows/orchestration/group_chat.py b/python/samples/getting_started/workflows/orchestration/group_chat.py index fdb139b5dbd..6a6d3a5e229 100644 --- a/python/samples/getting_started/workflows/orchestration/group_chat.py +++ b/python/samples/getting_started/workflows/orchestration/group_chat.py @@ -3,7 +3,7 @@ import asyncio import logging -from agent_framework import ChatAgent, GroupChatBuilder, WorkflowOutputEvent +from agent_framework import AgentRunUpdateEvent, ChatAgent, GroupChatBuilder, WorkflowOutputEvent from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient logging.basicConfig(level=logging.INFO) @@ -49,8 +49,18 @@ async def main() -> None: print(f"TASK: {task}\n") final_response = None + last_executor_id: str | None = None async for event in workflow.run_stream(task): - if isinstance(event, WorkflowOutputEvent): + if isinstance(event, AgentRunUpdateEvent): + # Handle the streaming agent update as it's produced + eid = event.executor_id + if eid != last_executor_id: + if last_executor_id is not None: + print() + print(f"{eid}:", end=" ", flush=True) + last_executor_id = eid + print(event.data, end="", flush=True) + elif isinstance(event, WorkflowOutputEvent): final_response = getattr(event.data, "text", str(event.data)) if final_response: From b324bceaa78add835052dbe07b2f6780e522a7cc Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 19:36:53 +0900 Subject: [PATCH 10/15] Further clean up --- .../agent_framework/_workflows/__init__.py | 28 --- .../agent_framework/_workflows/__init__.pyi | 28 --- .../_workflows/_base_orchestrator.py | 143 ++++++++++-- .../agent_framework/_workflows/_group_chat.py | 200 ++++++++--------- .../agent_framework/_workflows/_handoff.py | 75 +++---- .../agent_framework/_workflows/_magentic.py | 210 ++++++++++-------- .../_workflows/_orchestrator_helpers.py | 8 +- 7 files changed, 368 insertions(+), 324 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 2c7188f7f82..0a4fb7c758e 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -6,7 +6,6 @@ AgentExecutorRequest, AgentExecutorResponse, ) -from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import ( CheckpointStorage, FileCheckpointStorage, @@ -57,33 +56,20 @@ DEFAULT_MANAGER_INSTRUCTIONS, GroupChatBuilder, GroupChatDirective, - GroupChatOrchestratorExecutor, - GroupChatParticipantSpec, - GroupChatRequestMessage, - GroupChatResponseMessage, GroupChatStateSnapshot, - GroupChatTurn, - GroupChatWiring, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest from ._magentic import ( MagenticAgentDeltaEvent, - MagenticAgentExecutor, MagenticAgentMessageEvent, MagenticBuilder, MagenticContext, MagenticFinalResultEvent, MagenticManagerBase, - MagenticOrchestratorExecutor, MagenticOrchestratorMessageEvent, MagenticPlanReviewDecision, MagenticPlanReviewReply, MagenticPlanReviewRequest, - MagenticProgressLedger, - MagenticProgressLedgerItem, - MagenticRequestMessage, - MagenticResponseMessage, - MagenticStartMessage, StandardMagenticManager, ) from ._orchestration_state import OrchestrationState @@ -124,7 +110,6 @@ "AgentExecutorResponse", "AgentRunEvent", "AgentRunUpdateEvent", - "BaseGroupChatOrchestrator", "Case", "CheckpointStorage", "ConcurrentBuilder", @@ -144,34 +129,21 @@ "GraphConnectivityError", "GroupChatBuilder", "GroupChatDirective", - "GroupChatOrchestratorExecutor", - "GroupChatParticipantSpec", - "GroupChatRequestMessage", - "GroupChatResponseMessage", "GroupChatStateSnapshot", - "GroupChatTurn", - "GroupChatWiring", "HandoffBuilder", "HandoffUserInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", - "MagenticAgentExecutor", "MagenticAgentMessageEvent", "MagenticBuilder", "MagenticContext", "MagenticFinalResultEvent", "MagenticManagerBase", - "MagenticOrchestratorExecutor", "MagenticOrchestratorMessageEvent", "MagenticPlanReviewDecision", "MagenticPlanReviewReply", "MagenticPlanReviewRequest", - "MagenticProgressLedger", - "MagenticProgressLedgerItem", - "MagenticRequestMessage", - "MagenticResponseMessage", - "MagenticStartMessage", "Message", "OrchestrationState", "PendingRequestDetails", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index 780bca069a4..38a304cf3e0 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -6,7 +6,6 @@ from ._agent_executor import ( AgentExecutorRequest, AgentExecutorResponse, ) -from ._base_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import ( CheckpointStorage, FileCheckpointStorage, @@ -55,33 +54,20 @@ from ._group_chat import ( DEFAULT_MANAGER_INSTRUCTIONS, GroupChatBuilder, GroupChatDirective, - GroupChatOrchestratorExecutor, - GroupChatParticipantSpec, - GroupChatRequestMessage, - GroupChatResponseMessage, GroupChatStateSnapshot, - GroupChatTurn, - GroupChatWiring, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest from ._magentic import ( MagenticAgentDeltaEvent, - MagenticAgentExecutor, MagenticAgentMessageEvent, MagenticBuilder, MagenticContext, MagenticFinalResultEvent, MagenticManagerBase, - MagenticOrchestratorExecutor, MagenticOrchestratorMessageEvent, MagenticPlanReviewDecision, MagenticPlanReviewReply, MagenticPlanReviewRequest, - MagenticProgressLedger, - MagenticProgressLedgerItem, - MagenticRequestMessage, - MagenticResponseMessage, - MagenticStartMessage, StandardMagenticManager, ) from ._orchestration_state import OrchestrationState @@ -122,7 +108,6 @@ __all__ = [ "AgentExecutorResponse", "AgentRunEvent", "AgentRunUpdateEvent", - "BaseGroupChatOrchestrator", "Case", "CheckpointStorage", "ConcurrentBuilder", @@ -142,34 +127,21 @@ __all__ = [ "GraphConnectivityError", "GroupChatBuilder", "GroupChatDirective", - "GroupChatOrchestratorExecutor", - "GroupChatParticipantSpec", - "GroupChatRequestMessage", - "GroupChatResponseMessage", "GroupChatStateSnapshot", - "GroupChatTurn", - "GroupChatWiring", "HandoffBuilder", "HandoffUserInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", - "MagenticAgentExecutor", "MagenticAgentMessageEvent", "MagenticBuilder", "MagenticContext", "MagenticFinalResultEvent", "MagenticManagerBase", - "MagenticOrchestratorExecutor", "MagenticOrchestratorMessageEvent", "MagenticPlanReviewDecision", "MagenticPlanReviewReply", "MagenticPlanReviewRequest", - "MagenticProgressLedger", - "MagenticProgressLedgerItem", - "MagenticRequestMessage", - "MagenticResponseMessage", - "MagenticStartMessage", "Message", "OrchestrationState", "PendingRequestDetails", diff --git a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py b/python/packages/core/agent_framework/_workflows/_base_orchestrator.py index 31e0cccc31d..656b79681e3 100644 --- a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py +++ b/python/packages/core/agent_framework/_workflows/_base_orchestrator.py @@ -8,11 +8,13 @@ import logging from abc import ABC +from collections.abc import Sequence from typing import Any from .._types import ChatMessage +from ._conversation_history import append_messages, clone_conversation from ._executor import Executor -from ._orchestrator_helpers import ParticipantRegistry +from ._orchestrator_helpers import ParticipantRegistry, create_completion_message from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -36,6 +38,11 @@ def __init__(self, executor_id: str) -> None: """ super().__init__(executor_id) self._registry = ParticipantRegistry() + # Shared conversation state management + self._conversation: list[ChatMessage] = [] + self._round_index: int = 0 + self._max_rounds: int | None = None + self._termination_condition: Any = None # Callable[[list[ChatMessage]], bool] | None def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: """Record routing details for a participant's entry executor. @@ -50,6 +57,67 @@ def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool """ self._registry.register(name, entry_id=entry_id, is_agent=is_agent) + # 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 + """ + append_messages(self._conversation, messages) + + def _get_conversation(self) -> list[ChatMessage]: + """Get a copy of the current conversation. + + Returns: + Cloned conversation list + """ + return clone_conversation(self._conversation) + + def _clear_conversation(self) -> None: + """Clear the conversation history.""" + self._conversation.clear() + + def _increment_round(self) -> None: + """Increment the round counter.""" + self._round_index += 1 + + def _check_termination(self) -> bool: + """Check if conversation should terminate based on termination condition. + + Returns: + True if termination condition met, False otherwise + """ + if self._termination_condition is None: + return False + result = self._termination_condition(self._get_conversation()) + return bool(result) + + def _create_completion_message( + self, + text: str | None = None, + reason: str = "completed", + ) -> ChatMessage: + """Create a standardized completion message. + + Args: + text: Optional message text (auto-generated if None) + reason: Completion reason for default text + + Returns: + ChatMessage with completion content + """ + # Try to get manager/orchestrator name from subclass + author_name = getattr(self, "_manager_name", self.id) + return create_completion_message( + text=text, + author_name=author_name, + reason=reason, + ) + + # Participant routing (shared across all patterns) + async def _route_to_participant( self, participant_name: str, @@ -101,48 +169,83 @@ async def _route_to_participant( ) await ctx.send_message(request, target_id=entry_id) - def _check_round_limit( - self, - current_round: int, - max_rounds: int | None, - *, - pattern_name: str = "orchestrator", - ) -> bool: + # Round limit enforcement (shared across all patterns) + + def _check_round_limit(self) -> bool: """Check if round limit has been reached. - Args: - current_round: Current round index - max_rounds: Maximum allowed rounds (None = no limit) - pattern_name: Name for logging (e.g., "GroupChat", "Handoff") + Uses instance variables _round_index and _max_rounds. Returns: True if limit reached, False otherwise """ - if max_rounds is None: + if self._max_rounds is None: return False - if current_round >= max_rounds: + if self._round_index >= self._max_rounds: logger.warning( "%s reached max_rounds=%s; forcing completion.", - pattern_name, - max_rounds, + self.__class__.__name__, + self._max_rounds, ) return True return False + # State persistence (shared across all patterns) + + # State persistence (shared across all patterns) + def snapshot_state(self) -> dict[str, Any]: """Capture current orchestrator state for checkpointing. - Subclasses should override this to serialize pattern-specific state. - Default implementation returns empty dict. + Default implementation uses OrchestrationState to serialize common state. + Subclasses should override _snapshot_pattern_metadata() to add pattern-specific data. + + Returns: + Serialized state dict + """ + from ._orchestration_state import OrchestrationState + + state = OrchestrationState( + conversation=list(self._conversation), + round_index=self._round_index, + 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 {} def restore_state(self, state: dict[str, Any]) -> None: """Restore orchestrator state from checkpoint. - Subclasses should override this to deserialize pattern-specific state. - Default implementation does nothing. + Default implementation uses OrchestrationState to deserialize common state. + Subclasses should override _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._conversation = list(orch_state.conversation) + self._round_index = orch_state.round_index + 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 diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 642e3031736..9a3d80e4a26 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -36,7 +36,6 @@ from ._checkpoint import CheckpointStorage from ._conversation_history import append_messages, clone_conversation, ensure_author, latest_user_message from ._executor import Executor, handler -from ._orchestrator_helpers import ParticipantRegistry, create_completion_message from ._participant_utils import prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -49,8 +48,8 @@ @dataclass -class GroupChatRequestMessage: - """Request envelope sent from the orchestrator to a participant.""" +class _GroupChatRequestMessage: + """Internal: Request envelope sent from the orchestrator to a participant.""" agent_name: str conversation: list[ChatMessage] = field(default_factory=list) # type: ignore @@ -60,8 +59,8 @@ class GroupChatRequestMessage: @dataclass -class GroupChatResponseMessage: - """Response envelope emitted by participants back to the orchestrator.""" +class _GroupChatResponseMessage: + """Internal: Response envelope emitted by participants back to the orchestrator.""" agent_name: str message: ChatMessage @@ -71,8 +70,8 @@ class GroupChatResponseMessage: @dataclass -class GroupChatTurn: - """Represents a single turn in the manager-participant conversation.""" +class _GroupChatTurn: + """Internal: Represents a single turn in the manager-participant conversation.""" speaker: str role: str @@ -108,8 +107,8 @@ async def _maybe_await(value: Any) -> Any: @dataclass -class GroupChatParticipantSpec: - """Metadata describing a single participant in the orchestration. +class _GroupChatParticipantSpec: + """Internal: Metadata describing a single participant in the orchestration. Attributes: name: Unique identifier for the participant used by the manager for selection @@ -122,12 +121,12 @@ class GroupChatParticipantSpec: description: str -GroupChatParticipantPipeline: TypeAlias = Sequence[Executor] +_GroupChatParticipantPipeline: TypeAlias = Sequence[Executor] @dataclass -class GroupChatWiring: - """Configuration passed to factories during workflow assembly. +class _GroupChatWiring: + """Internal: Configuration passed to factories during workflow assembly. Attributes: manager: Manager instance responsible for orchestration decisions (None when custom factory handles it) @@ -139,7 +138,7 @@ class GroupChatWiring: manager: _GroupChatManagerFn | None manager_name: str - participants: Mapping[str, GroupChatParticipantSpec] + participants: Mapping[str, _GroupChatParticipantSpec] max_rounds: int | None = None orchestrator: Executor | None = None participant_aliases: dict[str, str] = field(default_factory=dict) # type: ignore[type-arg] @@ -151,14 +150,14 @@ class GroupChatWiring: # region Default participant factory -GroupChatOrchestratorFactory: TypeAlias = Callable[[GroupChatWiring], Executor] -InterceptorSpec: TypeAlias = tuple[Callable[[GroupChatWiring], Executor], Callable[[Any], bool]] +_GroupChatOrchestratorFactory: TypeAlias = Callable[[_GroupChatWiring], Executor] +_InterceptorSpec: TypeAlias = tuple[Callable[[_GroupChatWiring], Executor], Callable[[Any], bool]] def _default_participant_factory( - spec: GroupChatParticipantSpec, - wiring: GroupChatWiring, -) -> GroupChatParticipantPipeline: + spec: _GroupChatParticipantSpec, + wiring: _GroupChatWiring, +) -> _GroupChatParticipantPipeline: """Default factory for constructing participant pipeline nodes in the workflow graph. Creates a single AgentExecutor node for AgentProtocol participants or a passthrough executor @@ -254,16 +253,12 @@ def __init__( self._manager = manager self._participants = dict(participants) self._manager_name = manager_name - self._conversation: list[ChatMessage] = [] - self._history: list[GroupChatTurn] = [] + self._max_rounds = max_rounds + self._history: list[_GroupChatTurn] = [] self._task_message: ChatMessage | None = None self._pending_agent: str | None = None - self._round_index = 0 - self._max_rounds = max_rounds # Stashes the initial conversation list until _handle_task_message normalizes it into _conversation. self._pending_initial_conversation: list[ChatMessage] | None = None - # Use the simple registry helper instead of tracking separately - self._registry = ParticipantRegistry() @staticmethod def _role_value(message: ChatMessage) -> str: @@ -310,70 +305,52 @@ def _build_state(self) -> GroupChatStateSnapshot: } return MappingProxyType(snapshot) - def snapshot_state(self) -> dict[str, Any]: - """Capture current orchestrator state for checkpointing. - - Serializes conversation history, task, round index, and pattern-specific - metadata into a dict using the unified OrchestrationState structure. + def _snapshot_pattern_metadata(self) -> dict[str, Any]: + """Serialize GroupChat-specific state for checkpointing. Returns: - Dict ready for checkpoint persistence + Dict with participants, manager name, history, and pending agent """ - from ._orchestration_state import OrchestrationState - - state = OrchestrationState( - conversation=list(self._conversation), - round_index=self._round_index, - task=self._task_message, - metadata={ - "participants": dict(self._participants), - "manager_name": self._manager_name, - "pending_agent": self._pending_agent, - "history": [ - {"speaker": turn.speaker, "role": turn.role, "message": turn.message.to_dict()} - for turn in self._history - ], - }, - ) - return state.to_dict() - - def restore_state(self, state: dict[str, Any]) -> None: - """Restore orchestrator state from checkpoint. + return { + "participants": dict(self._participants), + "manager_name": self._manager_name, + "pending_agent": self._pending_agent, + "task_message": self._task_message.to_dict() if self._task_message else None, + "history": [ + {"speaker": turn.speaker, "role": turn.role, "message": turn.message.to_dict()} + for turn in self._history + ], + } - Deserializes checkpointed state using OrchestrationState and restores - internal conversation history, task, and round tracking. + def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None: + """Restore GroupChat-specific state from checkpoint. Args: - state: Checkpoint data dict + metadata: Pattern-specific state dict """ - from ._orchestration_state import OrchestrationState - - orch_state = OrchestrationState.from_dict(state) - self._conversation = list(orch_state.conversation) - self._round_index = orch_state.round_index - self._task_message = orch_state.task - - # Restore pattern-specific metadata - if "participants" in orch_state.metadata: - self._participants = dict(orch_state.metadata["participants"]) - if "manager_name" in orch_state.metadata: - self._manager_name = orch_state.metadata["manager_name"] - if "pending_agent" in orch_state.metadata: - self._pending_agent = orch_state.metadata["pending_agent"] - if "history" in orch_state.metadata: + if "participants" in metadata: + self._participants = dict(metadata["participants"]) + if "manager_name" in metadata: + self._manager_name = metadata["manager_name"] + if "pending_agent" in metadata: + self._pending_agent = metadata["pending_agent"] + task_msg = metadata.get("task_message") + if task_msg: + self._task_message = ChatMessage.from_dict(task_msg) + if "history" in metadata: self._history = [ - GroupChatTurn( + _GroupChatTurn( speaker=turn["speaker"], role=turn["role"], message=ChatMessage.from_dict(turn["message"]), ) - for turn in orch_state.metadata["history"] + for turn in metadata["history"] ] async def _apply_directive( self, directive: GroupChatDirective, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Execute a manager directive by either finishing the workflow or routing to a participant. @@ -410,15 +387,14 @@ async def _apply_directive( if directive.finish: final_message = directive.final_message if final_message is None: - final_message = create_completion_message( + final_message = self._create_completion_message( text="Completed without final summary.", - author_name=self._manager_name, reason="no summary provided", ) final_message = ensure_author(final_message, self._manager_name) append_messages(self._conversation, (final_message,)) - self._history.append(GroupChatTurn(self._manager_name, "manager", final_message)) + self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message)) self._pending_agent = None await ctx.yield_output(final_message) return @@ -437,15 +413,15 @@ async def _apply_directive( conversation = clone_conversation(self._conversation) if instruction: manager_message = ensure_author( - create_completion_message(text=instruction, author_name=self._manager_name), + self._create_completion_message(text=instruction, reason="instruction"), self._manager_name, ) append_messages(conversation, (manager_message,)) append_messages(self._conversation, (manager_message,)) - self._history.append(GroupChatTurn(self._manager_name, "manager", manager_message)) + self._history.append(_GroupChatTurn(self._manager_name, "manager", manager_message)) self._pending_agent = agent_name - self._round_index += 1 + self._increment_round() # Use inherited routing method from BaseGroupChatOrchestrator await self._route_to_participant( @@ -457,13 +433,12 @@ async def _apply_directive( metadata=directive.metadata, ) - if self._check_round_limit(self._round_index, self._max_rounds, pattern_name="GroupChat"): + if self._check_round_limit(): await self._apply_directive( GroupChatDirective( finish=True, - final_message=create_completion_message( + final_message=self._create_completion_message( text="Conversation halted after reaching manager round limit.", - author_name=self._manager_name, reason="max_rounds reached", ), ), @@ -474,7 +449,7 @@ async def _ingest_participant_message( self, participant_name: str, message: ChatMessage, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Common response ingestion logic shared by agent and custom participants.""" if participant_name not in self._participants: @@ -483,7 +458,7 @@ async def _ingest_participant_message( message = ensure_author(message, participant_name) append_messages(self._conversation, (message,)) - self._history.append(GroupChatTurn(participant_name, "agent", message)) + self._history.append(_GroupChatTurn(participant_name, "agent", message)) self._pending_agent = None if self._max_rounds is not None and self._round_index >= self._max_rounds: @@ -492,9 +467,8 @@ async def _ingest_participant_message( self._max_rounds, ) await ctx.yield_output( - create_completion_message( + self._create_completion_message( text="Conversation halted after reaching manager round limit.", - author_name=self._manager_name, reason="max_rounds reached after response", ) ) @@ -506,6 +480,8 @@ async def _ingest_participant_message( @staticmethod def _extract_agent_message(response: AgentExecutorResponse, participant_name: str) -> ChatMessage: """Select the final assistant message from an AgentExecutor response.""" + from ._orchestrator_helpers import create_completion_message + final_message: ChatMessage | None = None candidate_sequences: tuple[Sequence[ChatMessage] | None, ...] = ( response.agent_run_response.messages, @@ -522,13 +498,17 @@ def _extract_agent_message(response: AgentExecutorResponse, participant_name: st break if final_message is None: - final_message = create_completion_message(text="", author_name=participant_name) + final_message = create_completion_message( + text="", + author_name=participant_name, + reason="empty response", + ) return ensure_author(final_message, participant_name) async def _handle_task_message( self, task_message: ChatMessage, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Initialize orchestrator state and start the manager-directed conversation loop. @@ -566,7 +546,7 @@ async def _handle_task_message( self._pending_initial_conversation = None self._conversation = initial_conversation self._history = [ - GroupChatTurn( + _GroupChatTurn( msg.author_name or self._role_value(msg), self._role_value(msg), msg, @@ -575,7 +555,7 @@ async def _handle_task_message( ] else: self._conversation = [task_message] - self._history = [GroupChatTurn("user", "user", task_message)] + self._history = [_GroupChatTurn("user", "user", task_message)] self._pending_agent = None self._round_index = 0 directive = await self._manager(self._build_state()) @@ -585,7 +565,7 @@ async def _handle_task_message( async def handle_str( self, task: str, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Handler for string input as workflow entry point. @@ -604,7 +584,7 @@ async def handle_str( async def handle_chat_message( self, task_message: ChatMessage, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Handler for ChatMessage input as workflow entry point. @@ -623,7 +603,7 @@ async def handle_chat_message( async def handle_conversation( self, conversation: list[ChatMessage], - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Handler for conversation history as workflow entry point. @@ -660,8 +640,8 @@ async def handle_conversation( @handler async def handle_agent_response( self, - response: GroupChatResponseMessage, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + response: _GroupChatResponseMessage, + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Handle responses from custom participant executors.""" await self._ingest_participant_message(response.agent_name, response.message, ctx) @@ -670,7 +650,7 @@ async def handle_agent_response( async def handle_agent_executor_response( self, response: AgentExecutorResponse, - ctx: WorkflowContext[AgentExecutorRequest | GroupChatRequestMessage, ChatMessage], + ctx: WorkflowContext[AgentExecutorRequest | _GroupChatRequestMessage, ChatMessage], ) -> None: """Handle direct AgentExecutor responses.""" participant_name = self._registry.get_participant_name(response.executor_id) @@ -684,7 +664,7 @@ async def handle_agent_executor_response( await self._ingest_participant_message(participant_name, message, ctx) -def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: +def _default_orchestrator_factory(wiring: _GroupChatWiring) -> Executor: """Default factory for creating the GroupChatOrchestratorExecutor instance. This is the internal implementation used by GroupChatBuilder to instantiate the @@ -721,17 +701,17 @@ def _default_orchestrator_factory(wiring: GroupChatWiring) -> Executor: ) -def group_chat_orchestrator(factory: GroupChatOrchestratorFactory | None = None) -> GroupChatOrchestratorFactory: +def group_chat_orchestrator(factory: _GroupChatOrchestratorFactory | None = None) -> _GroupChatOrchestratorFactory: """Return a callable orchestrator factory, defaulting to the built-in implementation.""" return factory or _default_orchestrator_factory def assemble_group_chat_workflow( *, - wiring: GroupChatWiring, - participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantPipeline], - orchestrator_factory: GroupChatOrchestratorFactory = _default_orchestrator_factory, - interceptors: Sequence[InterceptorSpec] | None = None, + wiring: _GroupChatWiring, + participant_factory: Callable[[_GroupChatParticipantSpec, _GroupChatWiring], _GroupChatParticipantPipeline], + orchestrator_factory: _GroupChatOrchestratorFactory = _default_orchestrator_factory, + interceptors: Sequence[_InterceptorSpec] | None = None, checkpoint_storage: CheckpointStorage | None = None, builder: WorkflowBuilder | None = None, return_builder: bool = False, @@ -826,8 +806,8 @@ class GroupChatBuilder: def __init__( self, *, - _orchestrator_factory: GroupChatOrchestratorFactory | None = None, - _participant_factory: Callable[[GroupChatParticipantSpec, GroupChatWiring], GroupChatParticipantPipeline] + _orchestrator_factory: _GroupChatOrchestratorFactory | None = None, + _participant_factory: Callable[[_GroupChatParticipantSpec, _GroupChatWiring], _GroupChatParticipantPipeline] | None = None, ) -> None: """Initialize the GroupChatBuilder. @@ -844,7 +824,7 @@ def __init__( self._manager_name: str = "manager" self._checkpoint_storage: CheckpointStorage | None = None self._max_rounds: int | None = None - self._interceptors: list[InterceptorSpec] = [] + self._interceptors: list[_InterceptorSpec] = [] self._orchestrator_factory = group_chat_orchestrator(_orchestrator_factory) self._participant_factory = _participant_factory or _default_participant_factory @@ -1036,7 +1016,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupCha def with_request_handler( self, - handler: Callable[[GroupChatWiring], Executor] | Executor, + handler: Callable[[_GroupChatWiring], Executor] | Executor, *, condition: Callable[[Any], bool], ) -> "GroupChatBuilder": @@ -1049,11 +1029,11 @@ def with_request_handler( Returns: Self for fluent chaining """ - factory: Callable[[GroupChatWiring], Executor] + factory: Callable[[_GroupChatWiring], Executor] if isinstance(handler, Executor): executor = handler - def _factory(_: GroupChatWiring) -> Executor: + def _factory(_: _GroupChatWiring) -> Executor: return executor factory = _factory @@ -1115,12 +1095,12 @@ def _get_participant_metadata(self) -> dict[str, Any]: ) return self._participant_metadata - def _build_participant_specs(self) -> dict[str, GroupChatParticipantSpec]: + def _build_participant_specs(self) -> dict[str, _GroupChatParticipantSpec]: metadata = self._get_participant_metadata() descriptions: Mapping[str, str] = metadata["descriptions"] - specs: dict[str, GroupChatParticipantSpec] = {} + specs: dict[str, _GroupChatParticipantSpec] = {} for name, participant in self._participants.items(): - specs[name] = GroupChatParticipantSpec( + specs[name] = _GroupChatParticipantSpec( name=name, participant=participant, description=descriptions[name], @@ -1175,7 +1155,7 @@ def build(self) -> Workflow: metadata = self._get_participant_metadata() participant_specs = self._build_participant_specs() - wiring = GroupChatWiring( + wiring = _GroupChatWiring( manager=self._manager, manager_name=self._manager_name, participants=participant_specs, diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index dc4af012e89..4bebff67d4b 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -40,9 +40,9 @@ from ._conversation_history import append_messages, clone_conversation from ._executor import Executor, handler from ._group_chat import ( - GroupChatParticipantSpec, - GroupChatWiring, - _default_participant_factory, # type: ignore + _default_participant_factory, # type: ignore[reportPrivateUsage] + _GroupChatParticipantSpec, # type: ignore[reportPrivateUsage] + _GroupChatWiring, # type: ignore[reportPrivateUsage] assemble_group_chat_workflow, ) from ._orchestrator_helpers import clean_conversation_for_handoff @@ -276,7 +276,6 @@ def __init__( self._specialist_ids = set(specialist_ids.values()) self._input_gateway_id = input_gateway_id self._termination_condition = termination_condition - self._full_conversation: list[ChatMessage] = [] self._handoff_tool_targets = {k.lower(): v for k, v in (handoff_tool_targets or {}).items()} @handler @@ -289,31 +288,32 @@ async def handle_agent_response( # Hydrate coordinator state (and detect new run) using checkpointable executor state state = await ctx.get_executor_state() if not state: - self._full_conversation = [] - elif not self._full_conversation: + self._clear_conversation() + elif not self._get_conversation(): restored = self._restore_conversation_from_state(state) if restored: - self._full_conversation = clone_conversation(restored) + self._conversation = clone_conversation(restored) source = ctx.get_source_executor_id() is_starting_agent = source == self._starting_agent_id - # On first turn of a run, full_conversation is empty + # On first turn of a run, conversation is empty # Track new messages only, build authoritative history incrementally - if not self._full_conversation: + conversation_msgs = self._get_conversation() + if not conversation_msgs: # First response from starting agent - initialize with authoritative conversation snapshot # Keep the FULL conversation including tool calls (OpenAI SDK default behavior) full_conv = self._conversation_from_response(response) - self._full_conversation = clone_conversation(full_conv) + self._conversation = clone_conversation(full_conv) else: # Subsequent responses - append only new messages from this agent # Keep ALL messages including tool calls to maintain complete history new_messages = response.agent_run_response.messages or [] - append_messages(self._full_conversation, new_messages) + append_messages(self._conversation, new_messages) - self._apply_response_metadata(self._full_conversation, response.agent_run_response) + self._apply_response_metadata(self._conversation, response.agent_run_response) - conversation = clone_conversation(self._full_conversation) + conversation = clone_conversation(self._conversation) # Check for handoff from ANY agent (starting agent or specialist) target = self._resolve_specialist(response.agent_run_response, conversation) @@ -331,7 +331,7 @@ async def handle_agent_response( await self._persist_state(ctx) - if self._termination_condition(conversation): + if self._check_termination(): logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(conversation)) return @@ -345,18 +345,18 @@ async def handle_user_input( ctx: WorkflowContext[AgentExecutorRequest, list[ChatMessage]], ) -> None: """Receive full conversation with new user input from gateway, update history, trim for agent.""" - # Update authoritative full conversation - self._full_conversation = clone_conversation(message.full_conversation) + # Update authoritative conversation + self._conversation = clone_conversation(message.full_conversation) await self._persist_state(ctx) # Check termination before sending to agent - if self._termination_condition(self._full_conversation): + if self._check_termination(): logger.info("Handoff workflow termination condition met. Ending conversation.") - await ctx.yield_output(list(self._full_conversation)) + await ctx.yield_output(list(self._conversation)) return # Clean before sending to starting agent - cleaned = clean_conversation_for_handoff(self._full_conversation) + cleaned = clean_conversation_for_handoff(self._conversation) request = AgentExecutorRequest(messages=cleaned, should_respond=True) await ctx.send_message(request, target_id=self._starting_agent_id) @@ -409,7 +409,7 @@ def _append_tool_acknowledgement( ) # Add tool acknowledgement to both the conversation being sent and the full history append_messages(conversation, (tool_message,)) - append_messages(self._full_conversation, (tool_message,)) + self._append_messages((tool_message,)) def _conversation_from_response(self, response: AgentExecutorResponse) -> list[ChatMessage]: """Return the authoritative conversation snapshot from an executor response.""" @@ -425,34 +425,25 @@ async def _persist_state(self, ctx: WorkflowContext[Any, Any]) -> None: state_payload = self.snapshot_state() await ctx.set_executor_state(state_payload) - def snapshot_state(self) -> dict[str, Any]: - """Capture current coordinator state for checkpointing. + def _snapshot_pattern_metadata(self) -> dict[str, Any]: + """Serialize pattern-specific state. - Serializes conversation history using unified OrchestrationState structure. + Handoff has no additional metadata beyond base conversation state. Returns: - Dict ready for checkpoint persistence + Empty dict (no pattern-specific state) """ - from ._orchestration_state import OrchestrationState - - state = OrchestrationState( - conversation=list(self._full_conversation), - metadata={}, # Handoff has no additional metadata to checkpoint - ) - return state.to_dict() + return {} - def restore_state(self, state: dict[str, Any]) -> None: - """Restore coordinator state from checkpoint. + def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None: + """Restore pattern-specific state. - Deserializes checkpointed state using OrchestrationState. + Handoff has no additional metadata beyond base conversation state. Args: - state: Checkpoint data dict + metadata: Pattern-specific state dict (ignored) """ - from ._orchestration_state import OrchestrationState - - orch_state = OrchestrationState.from_dict(state) - self._full_conversation = list(orch_state.conversation) + pass def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[ChatMessage]: """Rehydrate the coordinator's conversation history from checkpointed state. @@ -1288,7 +1279,7 @@ def build(self) -> Workflow: exec_id: getattr(executor, "description", None) or exec_id for exec_id, executor in self._executors.items() } participant_specs = { - exec_id: GroupChatParticipantSpec(name=exec_id, participant=executor, description=descriptions[exec_id]) + exec_id: _GroupChatParticipantSpec(name=exec_id, participant=executor, description=descriptions[exec_id]) for exec_id, executor in self._executors.items() } @@ -1303,7 +1294,7 @@ def build(self) -> Workflow: specialist_aliases = {alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists} - def _handoff_orchestrator_factory(_: GroupChatWiring) -> Executor: + def _handoff_orchestrator_factory(_: _GroupChatWiring) -> Executor: return _HandoffCoordinator( starting_agent_id=starting_executor.id, specialist_ids=specialist_aliases, @@ -1313,7 +1304,7 @@ def _handoff_orchestrator_factory(_: GroupChatWiring) -> Executor: handoff_tool_targets=handoff_tool_targets, ) - wiring = GroupChatWiring( + wiring = _GroupChatWiring( manager=None, manager_name=self._starting_agent_id, participants=participant_specs, diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 2c9ae792cc0..a8230b5af02 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -30,11 +30,11 @@ from ._executor import Executor, handler from ._group_chat import ( GroupChatBuilder, - GroupChatParticipantPipeline, - GroupChatParticipantSpec, - GroupChatRequestMessage, - GroupChatResponseMessage, - GroupChatWiring, + _GroupChatParticipantPipeline, # type: ignore[reportPrivateUsage] + _GroupChatParticipantSpec, # type: ignore[reportPrivateUsage] + _GroupChatRequestMessage, # type: ignore[reportPrivateUsage] + _GroupChatResponseMessage, # type: ignore[reportPrivateUsage] + _GroupChatWiring, # type: ignore[reportPrivateUsage] group_chat_orchestrator, ) from ._message_utils import normalize_messages_input @@ -328,8 +328,8 @@ def _new_chat_message_list() -> list[ChatMessage]: @dataclass -class MagenticStartMessage(DictConvertible): - """A message to start a magentic workflow.""" +class _MagenticStartMessage(DictConvertible): + """Internal: A message to start a magentic workflow.""" messages: list[ChatMessage] = field(default_factory=_new_chat_message_list) @@ -352,7 +352,7 @@ def task(self) -> ChatMessage: return self.messages[-1] @classmethod - def from_string(cls, task_text: str) -> "MagenticStartMessage": + def from_string(cls, task_text: str) -> "_MagenticStartMessage": """Create a MagenticStartMessage from a simple string.""" return cls(task_text) @@ -364,7 +364,7 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MagenticStartMessage": + def from_dict(cls, data: dict[str, Any]) -> "_MagenticStartMessage": """Create from a dict.""" if "messages" in data: raw_messages = data["messages"] @@ -379,14 +379,14 @@ def from_dict(cls, data: dict[str, Any]) -> "MagenticStartMessage": @dataclass -class MagenticRequestMessage(GroupChatRequestMessage): - """A request message type for agents in a magentic workflow.""" +class _MagenticRequestMessage(_GroupChatRequestMessage): + """Internal: A request message type for agents in a magentic workflow.""" task_context: str = "" -class MagenticResponseMessage(GroupChatResponseMessage): - """A response message type. +class _MagenticResponseMessage(_GroupChatResponseMessage): + """Internal: A response message type. When emitted by the orchestrator you can mark it as a broadcast to all agents, or target a specific agent by name. @@ -412,7 +412,7 @@ def to_dict(self) -> dict[str, Any]: return {"body": self.body.to_dict(), "target_agent": self.target_agent, "broadcast": self.broadcast} @classmethod - def from_dict(cls, value: dict[str, Any]) -> "MagenticResponseMessage": + def from_dict(cls, value: dict[str, Any]) -> "_MagenticResponseMessage": """Create from a dict.""" body = ChatMessage.from_dict(value["body"]) target_agent = value.get("target_agent") @@ -421,8 +421,8 @@ def from_dict(cls, value: dict[str, Any]) -> "MagenticResponseMessage": @dataclass -class MagenticPlanReviewRequest(RequestInfoMessage): - """Human-in-the-loop request to review and optionally edit the plan before execution.""" +class _MagenticPlanReviewRequest(RequestInfoMessage): + """Internal: Human-in-the-loop request to review and optionally edit the plan before execution.""" # Because RequestInfoMessage defines a default field (request_id), # subclass fields must also have defaults to satisfy dataclass rules. @@ -438,8 +438,8 @@ class MagenticPlanReviewDecision(str, Enum): @dataclass -class MagenticPlanReviewReply: - """Human reply to a plan review request.""" +class _MagenticPlanReviewReply: + """Internal: Human reply to a plan review request.""" decision: MagenticPlanReviewDecision edited_plan_text: str | None = None # if supplied, becomes the new plan text verbatim @@ -447,8 +447,8 @@ class MagenticPlanReviewReply: @dataclass -class MagenticTaskLedger(DictConvertible): - """Task ledger for the Standard Magentic manager.""" +class _MagenticTaskLedger(DictConvertible): + """Internal: Task ledger for the Standard Magentic manager.""" facts: ChatMessage plan: ChatMessage @@ -457,7 +457,7 @@ def to_dict(self) -> dict[str, Any]: return {"facts": _message_to_payload(self.facts), "plan": _message_to_payload(self.plan)} @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MagenticTaskLedger": + def from_dict(cls, data: dict[str, Any]) -> "_MagenticTaskLedger": return cls( facts=_message_from_payload(data.get("facts")), plan=_message_from_payload(data.get("plan")), @@ -465,8 +465,8 @@ def from_dict(cls, data: dict[str, Any]) -> "MagenticTaskLedger": @dataclass -class MagenticProgressLedgerItem(DictConvertible): - """A progress ledger item.""" +class _MagenticProgressLedgerItem(DictConvertible): + """Internal: A progress ledger item.""" reason: str answer: str | bool @@ -475,7 +475,7 @@ def to_dict(self) -> dict[str, Any]: return {"reason": self.reason, "answer": self.answer} @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MagenticProgressLedgerItem": + def from_dict(cls, data: dict[str, Any]) -> "_MagenticProgressLedgerItem": answer_value = data.get("answer") if not isinstance(answer_value, (str, bool)): answer_value = "" # Default to empty string if not str or bool @@ -483,14 +483,14 @@ def from_dict(cls, data: dict[str, Any]) -> "MagenticProgressLedgerItem": @dataclass -class MagenticProgressLedger(DictConvertible): - """A progress ledger for tracking workflow progress.""" +class _MagenticProgressLedger(DictConvertible): + """Internal: A progress ledger for tracking workflow progress.""" - is_request_satisfied: MagenticProgressLedgerItem - is_in_loop: MagenticProgressLedgerItem - is_progress_being_made: MagenticProgressLedgerItem - next_speaker: MagenticProgressLedgerItem - instruction_or_question: MagenticProgressLedgerItem + is_request_satisfied: _MagenticProgressLedgerItem + is_in_loop: _MagenticProgressLedgerItem + is_progress_being_made: _MagenticProgressLedgerItem + next_speaker: _MagenticProgressLedgerItem + instruction_or_question: _MagenticProgressLedgerItem def to_dict(self) -> dict[str, Any]: return { @@ -502,13 +502,13 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MagenticProgressLedger": + def from_dict(cls, data: dict[str, Any]) -> "_MagenticProgressLedger": return cls( - is_request_satisfied=MagenticProgressLedgerItem.from_dict(data.get("is_request_satisfied", {})), - is_in_loop=MagenticProgressLedgerItem.from_dict(data.get("is_in_loop", {})), - is_progress_being_made=MagenticProgressLedgerItem.from_dict(data.get("is_progress_being_made", {})), - next_speaker=MagenticProgressLedgerItem.from_dict(data.get("next_speaker", {})), - instruction_or_question=MagenticProgressLedgerItem.from_dict(data.get("instruction_or_question", {})), + is_request_satisfied=_MagenticProgressLedgerItem.from_dict(data.get("is_request_satisfied", {})), + is_in_loop=_MagenticProgressLedgerItem.from_dict(data.get("is_in_loop", {})), + is_progress_being_made=_MagenticProgressLedgerItem.from_dict(data.get("is_progress_being_made", {})), + next_speaker=_MagenticProgressLedgerItem.from_dict(data.get("next_speaker", {})), + instruction_or_question=_MagenticProgressLedgerItem.from_dict(data.get("instruction_or_question", {})), ) @@ -665,7 +665,7 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: ... @abstractmethod - async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger: """Create a progress ledger.""" ... @@ -694,7 +694,7 @@ class StandardMagenticManager(MagenticManagerBase): - Final answer synthesis """ - task_ledger: MagenticTaskLedger | None + task_ledger: _MagenticTaskLedger | None def snapshot_state(self) -> dict[str, Any]: state = super().snapshot_state() @@ -708,14 +708,14 @@ def restore_state(self, state: dict[str, Any]) -> None: ledger = state.get("task_ledger") if ledger is not None: try: - self.task_ledger = MagenticTaskLedger.from_dict(ledger) + self.task_ledger = _MagenticTaskLedger.from_dict(ledger) except Exception: # pragma: no cover - defensive logger.warning("Failed to restore manager task ledger from checkpoint state") def __init__( self, chat_client: ChatClientProtocol, - task_ledger: MagenticTaskLedger | None = None, + task_ledger: _MagenticTaskLedger | None = None, *, instructions: str | None = None, task_ledger_facts_prompt: str | None = None, @@ -758,7 +758,7 @@ def __init__( self.chat_client: ChatClientProtocol = chat_client self.instructions: str | None = instructions - self.task_ledger: MagenticTaskLedger | None = task_ledger + self.task_ledger: _MagenticTaskLedger | None = task_ledger # Prompts may be overridden if needed self.task_ledger_facts_prompt: str = task_ledger_facts_prompt or ORCHESTRATOR_TASK_LEDGER_FACTS_PROMPT @@ -831,7 +831,7 @@ async def plan(self, magentic_context: MagenticContext) -> ChatMessage: plan_msg = await self._complete([*magentic_context.chat_history, facts_user, facts_msg, plan_user]) # Store ledger and render full combined view - self.task_ledger = MagenticTaskLedger(facts=facts_msg, plan=plan_msg) + self.task_ledger = _MagenticTaskLedger(facts=facts_msg, plan=plan_msg) # Also store individual messages in chat_history for better grounding # This gives the progress ledger model access to the detailed reasoning @@ -873,7 +873,7 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: ]) # Store and render - self.task_ledger = MagenticTaskLedger(facts=updated_facts, plan=updated_plan) + self.task_ledger = _MagenticTaskLedger(facts=updated_facts, plan=updated_plan) # Also store individual messages in chat_history for better grounding # This gives the progress ledger model access to the detailed reasoning @@ -887,7 +887,7 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: ) return ChatMessage(role=Role.ASSISTANT, text=combined, author_name=MAGENTIC_MANAGER_NAME) - async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger: """Use the model to produce a JSON progress ledger based on the conversation so far. Adds lightweight retries with backoff for transient parse issues and avoids selecting a @@ -914,7 +914,7 @@ async def create_progress_ledger(self, magentic_context: MagenticContext) -> Mag raw = await self._complete([*magentic_context.chat_history, user_message]) try: ledger_dict = _extract_json(raw.text) - return _coerce_model(MagenticProgressLedger, ledger_dict) + return _coerce_model(_MagenticProgressLedger, ledger_dict) except Exception as ex: last_error = ex attempts += 1 @@ -1139,6 +1139,28 @@ def _reconcile_restored_participants(self) -> None: for name, description in expected.items(): restored[name] = description + def _snapshot_pattern_metadata(self) -> dict[str, Any]: + """Serialize pattern-specific state. + + Magentic uses custom snapshot_state() instead of base class hooks. + This method exists to satisfy the base class contract. + + Returns: + Empty dict (Magentic manages its own state) + """ + return {} + + def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None: + """Restore pattern-specific state. + + Magentic uses custom restore_state() instead of base class hooks. + This method exists to satisfy the base class contract. + + Args: + metadata: Pattern-specific state dict (ignored) + """ + pass + async def _ensure_state_restored( self, context: WorkflowContext[Any, Any], @@ -1163,9 +1185,9 @@ async def _ensure_state_restored( @handler async def handle_start_message( self, - message: MagenticStartMessage, + message: _MagenticStartMessage, context: WorkflowContext[ - MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage ], ) -> None: """Handle the initial start message to begin orchestration.""" @@ -1200,7 +1222,7 @@ async def handle_start_message( # Start the inner loop ctx2 = cast( - WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], context, ) await self._run_inner_loop(ctx2) @@ -1210,36 +1232,36 @@ async def handle_task_text( self, task_text: str, context: WorkflowContext[ - MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage ], ) -> None: - await self.handle_start_message(MagenticStartMessage.from_string(task_text), context) + await self.handle_start_message(_MagenticStartMessage.from_string(task_text), context) @handler async def handle_task_message( self, task_message: ChatMessage, context: WorkflowContext[ - MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage ], ) -> None: - await self.handle_start_message(MagenticStartMessage(task_message), context) + await self.handle_start_message(_MagenticStartMessage(task_message), context) @handler async def handle_task_messages( self, conversation: list[ChatMessage], context: WorkflowContext[ - MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage ], ) -> None: - await self.handle_start_message(MagenticStartMessage(conversation), context) + await self.handle_start_message(_MagenticStartMessage(conversation), context) @handler async def handle_response_message( self, - message: MagenticResponseMessage, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + message: _MagenticResponseMessage, + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> None: """Handle responses from agents.""" if getattr(self, "_terminated", False): @@ -1267,10 +1289,10 @@ async def handle_response_message( @handler async def handle_plan_review_response( self, - response: RequestResponse[MagenticPlanReviewRequest, MagenticPlanReviewReply], + response: RequestResponse[_MagenticPlanReviewRequest, _MagenticPlanReviewReply], context: WorkflowContext[ # may broadcast ledger next, or ask for another round of review - MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage ], ) -> None: if getattr(self, "_terminated", False): @@ -1282,7 +1304,7 @@ async def handle_plan_review_response( human = response.data if human is None: # type: ignore[unreachable] # Defensive fallback: treat as revise with empty comments - human = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.REVISE, comments="") + human = _MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.REVISE, comments="") if human.decision == MagenticPlanReviewDecision.APPROVE: # Close the review loop on approval (no further plan review requests this run) @@ -1321,7 +1343,7 @@ async def handle_plan_review_response( # Enter the normal coordination loop ctx2 = cast( - WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], context, ) await self._run_inner_loop(ctx2) @@ -1348,7 +1370,7 @@ async def handle_plan_review_response( self._context.chat_history.append(self._task_ledger) # No further review requests; proceed directly into coordination ctx2 = cast( - WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], context, ) await self._run_inner_loop(ctx2) @@ -1383,7 +1405,7 @@ async def handle_plan_review_response( async def _run_outer_loop( self, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> None: """Run the outer orchestration loop - planning phase.""" if self._context is None: @@ -1406,7 +1428,7 @@ async def _run_outer_loop( async def _run_inner_loop( self, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> None: """Run the inner orchestration loop. Coordination phase. Serialized with a lock.""" if self._context is None or self._task_ledger is None: @@ -1416,7 +1438,7 @@ async def _run_inner_loop( async def _run_inner_loop_helper( self, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> None: """Run inner loop with exclusive access.""" # Narrow optional context for the remainder of this method @@ -1491,7 +1513,7 @@ async def _run_inner_loop_helper( # Request specific agent to respond logger.debug("Magentic Orchestrator: Requesting %s to respond", next_speaker_value) await context.send_message( - MagenticRequestMessage( + _MagenticRequestMessage( agent_name=next_speaker_value, instruction=str(instruction), task_context=ctx.task.text, @@ -1501,7 +1523,7 @@ async def _run_inner_loop_helper( async def _reset_and_replan( self, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> None: """Reset context and replan.""" if self._context is None: @@ -1527,7 +1549,7 @@ async def _reset_and_replan( async def _prepare_final_answer( self, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> None: """Prepare the final answer using the manager.""" if self._context is None: @@ -1542,7 +1564,7 @@ async def _prepare_final_answer( async def _check_within_limits_or_complete( self, - context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], + context: WorkflowContext[_MagenticResponseMessage | _MagenticRequestMessage, ChatMessage], ) -> bool: """Check if orchestrator is within operational limits.""" if self._context is None: @@ -1578,7 +1600,7 @@ async def _check_within_limits_or_complete( async def _send_plan_review_request( self, context: WorkflowContext[ - MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage + _MagenticResponseMessage | _MagenticRequestMessage | _MagenticPlanReviewRequest, ChatMessage ], ) -> None: """Emit a PlanReviewRequest via RequestInfoExecutor.""" @@ -1590,7 +1612,7 @@ async def _send_plan_review_request( plan_text = ledger.plan.text if ledger else "" task_text = self._context.task.text if self._context else "" - req = MagenticPlanReviewRequest( + req = _MagenticPlanReviewRequest( task_text=task_text, facts_text=facts_text, plan_text=plan_text, @@ -1672,7 +1694,7 @@ async def _ensure_state_restored(self, context: WorkflowContext[Any, Any]) -> No @handler async def handle_response_message( - self, message: MagenticResponseMessage, context: WorkflowContext[MagenticResponseMessage] + self, message: _MagenticResponseMessage, context: WorkflowContext[_MagenticResponseMessage] ) -> None: """Handle response message (task ledger broadcast).""" logger.debug("Agent %s: Received response message", self._agent_id) @@ -1711,7 +1733,7 @@ def _get_persona_adoption_role(self) -> Role: @handler async def handle_request_message( - self, message: MagenticRequestMessage, context: WorkflowContext[MagenticResponseMessage, AgentRunResponse] + self, message: _MagenticRequestMessage, context: WorkflowContext[_MagenticResponseMessage, AgentRunResponse] ) -> None: """Handle request to respond.""" if message.agent_name != self._agent_id: @@ -1750,7 +1772,7 @@ async def handle_request_message( self._chat_history.append(response) # Send response back to orchestrator - await context.send_message(MagenticResponseMessage(body=response)) + await context.send_message(_MagenticResponseMessage(body=response)) except Exception as e: logger.warning("Agent %s invoke failed: %s", self._agent_id, e) @@ -1761,7 +1783,7 @@ async def handle_request_message( ) self._chat_history.append(response) await self._emit_agent_message_event(context, response) - await context.send_message(MagenticResponseMessage(body=response)) + await context.send_message(_MagenticResponseMessage(body=response)) def reset(self) -> None: """Reset the internal chat history of the agent (internal operation).""" @@ -1816,7 +1838,7 @@ async def _emit_agent_message_event( async def _invoke_agent( self, - ctx: WorkflowContext[MagenticResponseMessage, AgentRunResponse], + ctx: WorkflowContext[_MagenticResponseMessage, AgentRunResponse], ) -> ChatMessage: """Invoke the wrapped agent and return a response.""" logger.debug(f"Agent {self._agent_id}: Running with {len(self._chat_history)} messages") @@ -1959,7 +1981,7 @@ def with_plan_review(self, enable: bool = True) -> "MagenticBuilder": """Enable or disable human-in-the-loop plan review before task execution. When enabled, the workflow will pause after the manager generates the initial - plan and emit a MagenticPlanReviewRequest event. A human reviewer can then + plan and emit a _MagenticPlanReviewRequest event. A human reviewer can then approve, request revisions, or reject the plan. The workflow continues only after approval. @@ -1989,14 +2011,14 @@ def with_plan_review(self, enable: bool = True) -> "MagenticBuilder": # During execution, handle plan review async for event in workflow.run_stream("task"): - if isinstance(event, MagenticPlanReviewRequest): + if isinstance(event, _MagenticPlanReviewRequest): # Review plan and respond - reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) + reply = _MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE) await workflow.send(reply) See Also: - - :class:`MagenticPlanReviewRequest`: Event emitted for review - - :class:`MagenticPlanReviewReply`: Response to send back + - :class:`_MagenticPlanReviewRequest`: Event emitted for review + - :class:`_MagenticPlanReviewReply`: Response to send back - :class:`MagenticPlanReviewDecision`: Approve/Revise/Reject options """ self._enable_plan_review = enable @@ -2054,7 +2076,7 @@ def with_standard_manager( *, # Constructor args for StandardMagenticManager when manager is not provided chat_client: ChatClientProtocol | None = None, - task_ledger: MagenticTaskLedger | None = None, + task_ledger: _MagenticTaskLedger | None = None, instructions: str | None = None, # Prompt overrides task_ledger_facts_prompt: str | None = None, @@ -2215,7 +2237,7 @@ def build(self) -> Workflow: # Type narrowing: we already checked self._manager is not None above manager: MagenticManagerBase = self._manager # type: ignore[assignment] - def _orchestrator_factory(wiring: GroupChatWiring) -> Executor: + def _orchestrator_factory(wiring: _GroupChatWiring) -> Executor: return MagenticOrchestratorExecutor( manager=manager, participants=participant_descriptions, @@ -2224,9 +2246,9 @@ def _orchestrator_factory(wiring: GroupChatWiring) -> Executor: ) def _participant_factory( - spec: GroupChatParticipantSpec, - wiring: GroupChatWiring, - ) -> GroupChatParticipantPipeline: + spec: _GroupChatParticipantSpec, + wiring: _GroupChatWiring, + ) -> _GroupChatParticipantPipeline: agent_executor = MagenticAgentExecutor( spec.participant, spec.name, @@ -2248,7 +2270,7 @@ def _participant_factory( if self._enable_plan_review: group_builder = group_builder.with_request_handler( lambda _wiring: RequestInfoExecutor(id="magentic_plan_review"), - condition=lambda msg: isinstance(msg, MagenticPlanReviewRequest), + condition=lambda msg: isinstance(msg, _MagenticPlanReviewRequest), ) return group_builder.build() @@ -2316,7 +2338,7 @@ async def run_streaming_with_string(self, task_text: str) -> AsyncIterable[Workf Yields: WorkflowEvent: The events generated during the workflow execution. """ - start_message = MagenticStartMessage.from_string(task_text) + start_message = _MagenticStartMessage.from_string(task_text) async for event in self._workflow.run_stream(start_message): yield event @@ -2329,7 +2351,7 @@ async def run_streaming_with_message(self, task_message: ChatMessage) -> AsyncIt Yields: WorkflowEvent: The events generated during the workflow execution. """ - start_message = MagenticStartMessage(task_message) + start_message = _MagenticStartMessage(task_message) async for event in self._workflow.run_stream(start_message): yield event @@ -2346,11 +2368,11 @@ async def run_stream(self, message: Any | None = None) -> AsyncIterable[Workflow if message is None: if self._task_text is None: raise ValueError("No message provided and no preset task text available") - message = MagenticStartMessage.from_string(self._task_text) + message = _MagenticStartMessage.from_string(self._task_text) elif isinstance(message, str): - message = MagenticStartMessage.from_string(message) + message = _MagenticStartMessage.from_string(message) elif isinstance(message, (ChatMessage, list)): - message = MagenticStartMessage(message) # type: ignore[arg-type] + message = _MagenticStartMessage(message) # type: ignore[arg-type] async for event in self._workflow.run_stream(message): yield event @@ -2519,3 +2541,7 @@ def __getattr__(self, name: str) -> Any: # endregion Magentic Workflow + +# Public aliases for types needed by users implementing custom plan review handlers +MagenticPlanReviewRequest = _MagenticPlanReviewRequest +MagenticPlanReviewReply = _MagenticPlanReviewReply diff --git a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py index 1a2cccca1a6..9fdd366075a 100644 --- a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py +++ b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py @@ -13,7 +13,7 @@ from ._conversation_history import clone_conversation if TYPE_CHECKING: - from ._group_chat import GroupChatRequestMessage + from ._group_chat import _GroupChatRequestMessage # type: ignore[reportPrivateUsage] logger = logging.getLogger(__name__) @@ -140,7 +140,7 @@ def prepare_participant_request( instruction: str | None = None, task: ChatMessage | None = None, metadata: dict[str, Any] | None = None, -) -> "GroupChatRequestMessage": +) -> "_GroupChatRequestMessage": """Create a standardized participant request message. Simple helper to avoid duplicating request construction. @@ -156,9 +156,9 @@ def prepare_participant_request( GroupChatRequestMessage ready to send """ # Import here to avoid circular dependency - from ._group_chat import GroupChatRequestMessage + from ._group_chat import _GroupChatRequestMessage # type: ignore[reportPrivateUsage] - return GroupChatRequestMessage( + return _GroupChatRequestMessage( agent_name=participant_name, conversation=clone_conversation(conversation), instruction=instruction or "", From 7f3a16b5fe7f7fe92b425a03cd340141773ca63d Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 19:50:22 +0900 Subject: [PATCH 11/15] Add group chat sample --- .../agent_framework/_workflows/_group_chat.py | 129 ++++++++++++++---- .../getting_started/workflows/README.md | 3 +- ....py => group_chat_prompt_based_manager.py} | 0 .../group_chat_simple_selector.py | 110 +++++++++++++++ 4 files changed, 212 insertions(+), 30 deletions(-) rename python/samples/getting_started/workflows/orchestration/{group_chat.py => group_chat_prompt_based_manager.py} (100%) create mode 100644 python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 9a3d80e4a26..a32e3ba4eb1 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -771,36 +771,74 @@ def assemble_group_chat_workflow( class GroupChatBuilder: r"""High-level builder for manager-directed group chat workflows with dynamic orchestration. - - Call exactly one of `set_prompt_based_manager(...)` or `set_speaker_selector(...)` to configure coordination - - `participants({...})` accepts a mapping (or list) of AgentProtocol/Executor instances - - The workflow delegates speaker selection to the manager and requests completion when finished - - Agents are automatically wrapped as AgentExecutor for consistent observability + GroupChat coordinates multi-agent conversations using a manager that selects which participant + speaks next. The manager can be a simple Python function (select_speakers) or an LLM-based + selector (set_prompt_based_manager). These two approaches are mutually exclusive. - Usage: + **Core Workflow:** + 1. Define participants: list of agents (uses their .name) or dict mapping names to agents + 2. Configure speaker selection: select_speakers() OR set_prompt_based_manager() (not both) + 3. Optional: set round limits, checkpointing, termination conditions + 4. Build and run the workflow + + **Speaker Selection Patterns:** + + *Pattern 1: Simple function-based selection (recommended)* .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient, AzureOpenAIResponsesClient - from agent_framework import ChatAgent, GroupChatBuilder + def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: + # state contains: task, participants, conversation, history, round_index + if state["round_index"] >= 5: + return None # Finish + last_speaker = state["history"][-1].speaker if state["history"] else None + if last_speaker == "researcher": + return "writer" + return "researcher" - researcher = ChatAgent( - name="Researcher", - chat_client=AzureOpenAIChatClient(), - instructions="Collect useful notes.", - ) - writer = ChatAgent( - name="Writer", - chat_client=AzureOpenAIResponsesClient(), - instructions="Draft a polished answer.", + + workflow = ( + GroupChatBuilder() + .select_speakers(select_next_speaker) + .participants([researcher_agent, writer_agent]) # Uses agent.name + .build() ) + *Pattern 2: LLM-based selection* + + .. code-block:: python + + from agent_framework.azure import AzureOpenAIChatClient + workflow = ( GroupChatBuilder() .set_prompt_based_manager(chat_client=AzureOpenAIChatClient(), display_name="Coordinator") - .participants(researcher=researcher, writer=writer) + .participants([researcher, writer]) # Or use dict: researcher=r, writer=w .with_max_rounds(10) .build() ) + + **Participant Specification:** + + Two ways to specify participants: + - List form: ``[agent1, agent2]`` - uses ``agent.name`` attribute for participant names + - Dict form: ``{name1: agent1, name2: agent2}`` - explicit name control + - Keyword form: ``participants(name1=agent1, name2=agent2)`` - explicit name control + + **State Snapshot Structure:** + + The GroupChatStateSnapshot passed to select_speakers contains: + - ``task``: ChatMessage - Original user task + - ``participants``: dict[str, str] - Mapping of participant names to descriptions + - ``conversation``: tuple[ChatMessage, ...] - Full conversation history + - ``history``: tuple[GroupChatTurn, ...] - Turn-by-turn record with speaker attribution + - ``round_index``: int - Number of manager selection rounds so far + - ``pending_agent``: str | None - Name of agent currently processing (if any) + + **Important Constraints:** + - Cannot combine select_speakers() and set_prompt_based_manager() - choose one + - Participant names must be unique + - When using list form, agents must have a non-empty ``name`` attribute """ def __init__( @@ -836,7 +874,7 @@ def _set_manager_function( if self._manager is not None: raise ValueError( "GroupChatBuilder already has a manager configured. " - "Call set_prompt_based_manager(...) or set_speaker_selector(...) at most once." + "Call select_speakers(...) or set_prompt_based_manager(...) at most once." ) resolved_name = display_name or getattr(manager, "name", None) or "manager" self._manager = manager @@ -881,27 +919,60 @@ def set_prompt_based_manager( ) return self._set_manager_function(manager, display_name) - def set_speaker_selector( + def select_speakers( self, - selector: Callable[[GroupChatStateSnapshot], Awaitable[Any]] | Callable[[GroupChatStateSnapshot], Any], + selector: ( + Callable[[GroupChatStateSnapshot], Awaitable[str | None]] | Callable[[GroupChatStateSnapshot], str | None] + ), *, display_name: str | None = None, final_message: ChatMessage | str | Callable[[GroupChatStateSnapshot], Any] | None = None, ) -> "GroupChatBuilder": - """Configure a lightweight selector function that picks the next speaker. + """Configure speaker selection using a pure function that examines group chat state. + + This is the primary way to control orchestration flow in a GroupChat. Your selector + function receives an immutable snapshot of the current conversation state and returns + the name of the next participant to speak, or None to finish the conversation. + + The selector function signature: + def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: + # state contains: task, participants, conversation, history, round_index + # Return participant name to continue, or None to finish + ... Args: - selector: Callable receiving the conversation snapshot. Return a participant name to - continue the conversation or None to finish. The callable may be sync or async. - display_name: Optional name shown in conversation history for manager messages. - final_message: Optional final message (or factory) emitted when the selector returns None - (defaults to ``"Conversation completed."`` authored by the manager). + selector: Function that takes GroupChatStateSnapshot and returns the next speaker's + name (str) to continue the conversation, or None to finish. May be sync or async. + display_name: Optional name shown in conversation history for orchestrator messages + (defaults to "manager"). + final_message: Optional final message (or factory) emitted when selector returns None + (defaults to "Conversation completed." authored by the manager). Returns: Self for fluent chaining. + Example: + + .. code-block:: python + + def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: + if state["round_index"] >= 3: + return None # Finish after 3 rounds + last_speaker = state["history"][-1].speaker if state["history"] else None + if last_speaker == "researcher": + return "writer" + return "researcher" + + + workflow = ( + GroupChatBuilder() + .select_speakers(select_next_speaker) + .participants(researcher=researcher_agent, writer=writer_agent) + .build() + ) + Note: - Calling this method and :meth:`set_prompt_based_manager` together is not allowed; choose one. + Cannot be combined with set_prompt_based_manager(). Choose one orchestration strategy. """ manager_name = display_name or "manager" adapter = _SpeakerSelectorAdapter( @@ -921,7 +992,7 @@ def participants( Accepts AgentProtocol instances (auto-wrapped as AgentExecutor) or Executor instances. Provide a mapping of name → participant for explicit control, or pass a sequence and - names will be inferred from the agent's ``name`` attribute (or executor ``id``). + names will be inferred from the agent's name attribute (or executor id). Args: participants: Optional mapping or sequence of participant definitions @@ -1213,7 +1284,7 @@ def _coerce_directive_source(value: Any) -> dict[str, Any]: def _parse_manager_payload(raw: Any) -> ManagerDirectivePayload: - """Validate raw manager output into ``ManagerDirectivePayload``.""" + """Validate raw manager output into ManagerDirectivePayload.""" data = _coerce_directive_source(raw) if not isinstance(data, dict): raise RuntimeError("Unable to parse manager directive from chat client response.") diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 6f997b06ef6..efbf5bdff27 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -92,7 +92,8 @@ Once comfortable with these, explore the rest of the samples below. | Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages | | Concurrent Orchestration (Custom Aggregator) | [orchestration/concurrent_custom_aggregator.py](./orchestration/concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM | | Concurrent Orchestration (Custom Agent Executors) | [orchestration/concurrent_custom_agent_executors.py](./orchestration/concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder | -| Group Chat Orchestration | [orchestration/group_chat.py](./orchestration/group_chat.py) | Manager-directed conversation using GroupChatBuilder and LLMGroupChatManager | +| Group Chat Orchestration with Prompt Based Manager | [orchestration/group_chat_prompt_based_manager.py](./orchestration/group_chat_prompt_based_manager.py) | LLM Manager-directed conversation using GroupChatBuilder | +| Group Chat with Simple Function Selector | [orchestration/group_chat_simple_selector.py](./orchestration/group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker | | Handoff (Simple) | [orchestration/handoff_simple.py](./orchestration/handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response | | Handoff (Specialist-to-Specialist) | [orchestration/handoff_specialist_to_specialist.py](./orchestration/handoff_specialist_to_specialist.py) | Multi-tier routing: specialists can hand off to other specialists using `.add_handoff()` fluent API | | Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming | diff --git a/python/samples/getting_started/workflows/orchestration/group_chat.py b/python/samples/getting_started/workflows/orchestration/group_chat_prompt_based_manager.py similarity index 100% rename from python/samples/getting_started/workflows/orchestration/group_chat.py rename to python/samples/getting_started/workflows/orchestration/group_chat_prompt_based_manager.py diff --git a/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py b/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py new file mode 100644 index 00000000000..ba4d16accb9 --- /dev/null +++ b/python/samples/getting_started/workflows/orchestration/group_chat_simple_selector.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import logging + +from agent_framework import ChatAgent, GroupChatBuilder, GroupChatStateSnapshot, WorkflowOutputEvent +from agent_framework.openai import OpenAIChatClient + +logging.basicConfig(level=logging.INFO) + +""" +Sample: Group Chat with Simple Speaker Selector Function + +What it does: +- Demonstrates the select_speakers() API for GroupChat orchestration +- Uses a pure Python function to control speaker selection based on conversation state +- Alternates between researcher and writer agents in a simple round-robin pattern +- Shows how to access conversation history, round index, and participant metadata + +Key pattern: + def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: + # state contains: task, participants, conversation, history, round_index + # Return participant name to continue, or None to finish + ... + +Prerequisites: +- OpenAI environment variables configured for OpenAIChatClient +""" + + +def select_next_speaker(state: GroupChatStateSnapshot) -> str | None: + """Simple speaker selector that alternates between researcher and writer. + + This function demonstrates the core pattern: + 1. Examine the current state of the group chat + 2. Decide who should speak next + 3. Return participant name or None to finish + + Args: + state: Immutable snapshot containing: + - task: ChatMessage - original user task + - participants: dict[str, str] - participant names → descriptions + - conversation: tuple[ChatMessage, ...] - full conversation history + - history: tuple[GroupChatTurn, ...] - turn-by-turn with speaker attribution + - round_index: int - number of selection rounds so far + - pending_agent: str | None - currently active agent (if any) + + Returns: + Name of next speaker, or None to finish the conversation + """ + round_idx = state["round_index"] + history = state["history"] + + # Finish after 4 turns (researcher → writer → researcher → writer) + if round_idx >= 4: + return None + + # Get the last speaker from history + last_speaker = history[-1].speaker if history else None + + # Simple alternation: researcher → writer → researcher → writer + if last_speaker == "Researcher": + return "Writer" + return "Researcher" + + +async def main() -> None: + researcher = ChatAgent( + name="Researcher", + description="Collects relevant background information.", + instructions="Gather concise facts that help answer the question. Be brief.", + chat_client=OpenAIChatClient(model_id="gpt-4o-mini"), + ) + + writer = ChatAgent( + name="Writer", + description="Synthesizes a polished answer using the gathered notes.", + instructions="Compose a clear, structured answer using any notes provided.", + chat_client=OpenAIChatClient(model_id="gpt-4o-mini"), + ) + + # Two ways to specify participants: + # 1. List form - uses agent.name attribute: .participants([researcher, writer]) + # 2. Dict form - explicit names: .participants(researcher=researcher, writer=writer) + workflow = ( + GroupChatBuilder() + .select_speakers(select_next_speaker, display_name="Orchestrator") + .participants([researcher, writer]) # Uses agent.name for participant names + .build() + ) + + task = "What are the key benefits of using async/await in Python?" + + print("\nStarting Group Chat with Simple Speaker Selector...\n") + print(f"TASK: {task}\n") + print("=" * 80) + + async for event in workflow.run_stream(task): + if isinstance(event, WorkflowOutputEvent): + final_message = event.data + author = getattr(final_message, "author_name", "Unknown") + text = getattr(final_message, "text", str(final_message)) + print(f"\n[{author}]\n{text}\n") + print("-" * 80) + + print("\nWorkflow completed.") + + +if __name__ == "__main__": + asyncio.run(main()) From e03047450b34381e31f7e346215bfc0dd2421aaa Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 19:56:15 +0900 Subject: [PATCH 12/15] Improve typing --- .../core/agent_framework/_workflows/_base_orchestrator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py b/python/packages/core/agent_framework/_workflows/_base_orchestrator.py index 656b79681e3..5a8d5a0887c 100644 --- a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py +++ b/python/packages/core/agent_framework/_workflows/_base_orchestrator.py @@ -8,7 +8,7 @@ import logging from abc import ABC -from collections.abc import Sequence +from collections.abc import Callable, Sequence from typing import Any from .._types import ChatMessage @@ -42,7 +42,7 @@ def __init__(self, executor_id: str) -> None: self._conversation: list[ChatMessage] = [] self._round_index: int = 0 self._max_rounds: int | None = None - self._termination_condition: Any = None # Callable[[list[ChatMessage]], bool] | None + self._termination_condition: Callable[[list[ChatMessage]], bool] | None = None def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: """Record routing details for a participant's entry executor. From 9660e59cc04303b65884a451dceae1fb93a76e97 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 23 Oct 2025 08:02:19 +0900 Subject: [PATCH 13/15] Fix test imports --- .../core/tests/workflow/test_group_chat.py | 40 +++++----- .../core/tests/workflow/test_magentic.py | 77 ++++++++++--------- 2 files changed, 60 insertions(+), 57 deletions(-) diff --git a/python/packages/core/tests/workflow/test_group_chat.py b/python/packages/core/tests/workflow/test_group_chat.py index f8f8f10b603..45993a9b8a7 100644 --- a/python/packages/core/tests/workflow/test_group_chat.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -16,14 +16,16 @@ MagenticContext, MagenticManagerBase, MagenticOrchestratorMessageEvent, - MagenticProgressLedger, - MagenticProgressLedgerItem, - MagenticStartMessage, Role, TextContent, Workflow, WorkflowOutputEvent, ) +from agent_framework._workflows._magentic import ( + _MagenticProgressLedger, # type: ignore + _MagenticProgressLedgerItem, # type: ignore + _MagenticStartMessage, # type: ignore +) class StubAgent(BaseAgent): @@ -85,24 +87,24 @@ async def plan(self, magentic_context: MagenticContext) -> ChatMessage: async def replan(self, magentic_context: MagenticContext) -> ChatMessage: return await self.plan(magentic_context) - async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger: participants = list(magentic_context.participant_descriptions.keys()) target = participants[0] if participants else "agent" if self._round == 0: self._round += 1 - return MagenticProgressLedger( - is_request_satisfied=MagenticProgressLedgerItem(reason="", answer=False), - is_in_loop=MagenticProgressLedgerItem(reason="", answer=False), - is_progress_being_made=MagenticProgressLedgerItem(reason="", answer=True), - next_speaker=MagenticProgressLedgerItem(reason="", answer=target), - instruction_or_question=MagenticProgressLedgerItem(reason="", answer="respond"), + return _MagenticProgressLedger( + is_request_satisfied=_MagenticProgressLedgerItem(reason="", answer=False), + is_in_loop=_MagenticProgressLedgerItem(reason="", answer=False), + is_progress_being_made=_MagenticProgressLedgerItem(reason="", answer=True), + next_speaker=_MagenticProgressLedgerItem(reason="", answer=target), + instruction_or_question=_MagenticProgressLedgerItem(reason="", answer="respond"), ) - return MagenticProgressLedger( - is_request_satisfied=MagenticProgressLedgerItem(reason="", answer=True), - is_in_loop=MagenticProgressLedgerItem(reason="", answer=False), - is_progress_being_made=MagenticProgressLedgerItem(reason="", answer=True), - next_speaker=MagenticProgressLedgerItem(reason="", answer=target), - instruction_or_question=MagenticProgressLedgerItem(reason="", answer=""), + return _MagenticProgressLedger( + is_request_satisfied=_MagenticProgressLedgerItem(reason="", answer=True), + is_in_loop=_MagenticProgressLedgerItem(reason="", answer=False), + is_progress_being_made=_MagenticProgressLedgerItem(reason="", answer=True), + next_speaker=_MagenticProgressLedgerItem(reason="", answer=target), + instruction_or_question=_MagenticProgressLedgerItem(reason="", answer=""), ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: @@ -116,7 +118,7 @@ async def test_group_chat_builder_basic_flow() -> None: workflow = ( GroupChatBuilder() - .set_speaker_selector(selector, display_name="manager", final_message="done") + .select_speakers(selector, display_name="manager", final_message="done") .participants(alpha=alpha, beta=beta) .build() ) @@ -144,7 +146,7 @@ async def test_magentic_builder_returns_workflow_and_runs() -> None: outputs: list[ChatMessage] = [] orchestrator_events: list[MagenticOrchestratorMessageEvent] = [] agent_events: list[MagenticAgentMessageEvent] = [] - start_message = MagenticStartMessage.from_string("compose summary") + start_message = _MagenticStartMessage.from_string("compose summary") async for event in workflow.run_stream(start_message): if isinstance(event, MagenticOrchestratorMessageEvent): orchestrator_events.append(event) @@ -170,7 +172,7 @@ async def test_group_chat_as_agent_accepts_conversation() -> None: workflow = ( GroupChatBuilder() - .set_speaker_selector(selector, display_name="manager", final_message="done") + .select_speakers(selector, display_name="manager", final_message="done") .participants(alpha=alpha, beta=beta) .build() ) diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index da9e0969ff2..fe83fa0ea4e 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterable from dataclasses import dataclass -from typing import Any +from typing import Any, cast import pytest @@ -21,8 +21,6 @@ MagenticPlanReviewDecision, MagenticPlanReviewReply, MagenticPlanReviewRequest, - MagenticProgressLedger, - MagenticProgressLedgerItem, RequestInfoEvent, Role, TextContent, @@ -35,17 +33,19 @@ handler, ) from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage -from agent_framework._workflows._magentic import ( +from agent_framework._workflows._magentic import ( # type: ignore[reportPrivateUsage] MagenticAgentExecutor, MagenticContext, MagenticOrchestratorExecutor, - MagenticStartMessage, + _MagenticProgressLedger, # type: ignore + _MagenticProgressLedgerItem, # type: ignore + _MagenticStartMessage, # type: ignore ) def test_magentic_start_message_from_string(): - msg = MagenticStartMessage.from_string("Do the thing") - assert isinstance(msg, MagenticStartMessage) + msg = _MagenticStartMessage.from_string("Do the thing") + assert isinstance(msg, _MagenticStartMessage) assert isinstance(msg.task, ChatMessage) assert msg.task.role == Role.USER assert msg.task.text == "Do the thing" @@ -115,8 +115,9 @@ def restore_state(self, state: dict[str, Any]) -> None: super().restore_state(state) ledger_state = state.get("task_ledger") if isinstance(ledger_state, dict): - facts_payload = ledger_state.get("facts") # type: ignore[reportUnknownMemberType] - plan_payload = ledger_state.get("plan") # type: ignore[reportUnknownMemberType] + ledger_dict = cast(dict[str, Any], ledger_state) + facts_payload = cast(dict[str, Any] | None, ledger_dict.get("facts")) + plan_payload = cast(dict[str, Any] | None, ledger_dict.get("plan")) if facts_payload is not None and plan_payload is not None: try: facts = ChatMessage.from_dict(facts_payload) @@ -139,14 +140,14 @@ async def replan(self, magentic_context: MagenticContext) -> ChatMessage: combined = f"Task: {magentic_context.task.text}\n\nFacts:\n{facts.text}\n\nPlan:\n{plan.text}" return ChatMessage(role=Role.ASSISTANT, text=combined, author_name="magentic_manager") - async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger: is_satisfied = self.satisfied_after_signoff and len(magentic_context.chat_history) > 0 - return MagenticProgressLedger( - is_request_satisfied=MagenticProgressLedgerItem(reason="test", answer=is_satisfied), - is_in_loop=MagenticProgressLedgerItem(reason="test", answer=False), - is_progress_being_made=MagenticProgressLedgerItem(reason="test", answer=True), - next_speaker=MagenticProgressLedgerItem(reason="test", answer=self.next_speaker_name), - instruction_or_question=MagenticProgressLedgerItem(reason="test", answer=self.instruction_text), + return _MagenticProgressLedger( + is_request_satisfied=_MagenticProgressLedgerItem(reason="test", answer=is_satisfied), + is_in_loop=_MagenticProgressLedgerItem(reason="test", answer=False), + is_progress_being_made=_MagenticProgressLedgerItem(reason="test", answer=True), + next_speaker=_MagenticProgressLedgerItem(reason="test", answer=self.next_speaker_name), + instruction_or_question=_MagenticProgressLedgerItem(reason="test", answer=self.instruction_text), ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: @@ -176,7 +177,7 @@ async def test_standard_manager_progress_ledger_and_fallback(): ) ledger = await manager.create_progress_ledger(ctx.clone()) - assert isinstance(ledger, MagenticProgressLedger) + assert isinstance(ledger, _MagenticProgressLedger) assert ledger.next_speaker.answer == "agentA" manager.satisfied_after_signoff = False @@ -471,24 +472,24 @@ async def plan(self, magentic_context: MagenticContext) -> ChatMessage: async def replan(self, magentic_context: MagenticContext) -> ChatMessage: return ChatMessage(role=Role.ASSISTANT, text="re-ledger") - async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger: if not self._invoked: # First round: ask agentA to respond self._invoked = True - return MagenticProgressLedger( - is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=False), - is_in_loop=MagenticProgressLedgerItem(reason="r", answer=False), - is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=True), - next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"), - instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="say hi"), + return _MagenticProgressLedger( + is_request_satisfied=_MagenticProgressLedgerItem(reason="r", answer=False), + is_in_loop=_MagenticProgressLedgerItem(reason="r", answer=False), + is_progress_being_made=_MagenticProgressLedgerItem(reason="r", answer=True), + next_speaker=_MagenticProgressLedgerItem(reason="r", answer="agentA"), + instruction_or_question=_MagenticProgressLedgerItem(reason="r", answer="say hi"), ) # Next round: mark satisfied so run can conclude - return MagenticProgressLedger( - is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=True), - is_in_loop=MagenticProgressLedgerItem(reason="r", answer=False), - is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=True), - next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"), - instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"), + return _MagenticProgressLedger( + is_request_satisfied=_MagenticProgressLedgerItem(reason="r", answer=True), + is_in_loop=_MagenticProgressLedgerItem(reason="r", answer=False), + is_progress_being_made=_MagenticProgressLedgerItem(reason="r", answer=True), + next_speaker=_MagenticProgressLedgerItem(reason="r", answer="agentA"), + instruction_or_question=_MagenticProgressLedgerItem(reason="r", answer="done"), ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: @@ -555,7 +556,7 @@ async def _collect_agent_responses_setup(participant_obj: object): async def test_agent_executor_invoke_with_thread_chat_client(): captured = await _collect_agent_responses_setup(StubThreadAgent()) - # Should have at least one response from agentA via MagenticAgentExecutor path + # Should have at least one response from agentA via _MagenticAgentExecutor path assert any((m.author_name == "agentA" and "ok" in (m.text or "")) for m in captured) @@ -700,13 +701,13 @@ async def plan(self, magentic_context: MagenticContext) -> ChatMessage: async def replan(self, magentic_context: MagenticContext) -> ChatMessage: return ChatMessage(role=Role.ASSISTANT, text="re-ledger") - async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: - return MagenticProgressLedger( - is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=False), - is_in_loop=MagenticProgressLedgerItem(reason="r", answer=True), - is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=False), - next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"), - instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"), + async def create_progress_ledger(self, magentic_context: MagenticContext) -> _MagenticProgressLedger: + return _MagenticProgressLedger( + is_request_satisfied=_MagenticProgressLedgerItem(reason="r", answer=False), + is_in_loop=_MagenticProgressLedgerItem(reason="r", answer=True), + is_progress_being_made=_MagenticProgressLedgerItem(reason="r", answer=False), + next_speaker=_MagenticProgressLedgerItem(reason="r", answer="agentA"), + instruction_or_question=_MagenticProgressLedgerItem(reason="r", answer="done"), ) async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: From e54786a29c43dd37c77a22dc03f402232a92b0c1 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 23 Oct 2025 08:17:38 +0900 Subject: [PATCH 14/15] Fix readme links --- python/samples/README.md | 11 +++++++---- .../orchestrations/group_chat.py | 2 -- .../orchestrations/handoff.py | 14 +++++--------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/python/samples/README.md b/python/samples/README.md index 5504cdc3a00..4178ec752cf 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -288,10 +288,13 @@ This directory contains samples demonstrating the capabilities of Microsoft Agen | [`getting_started/workflows/orchestration/concurrent_agents.py`](./getting_started/workflows/orchestration/concurrent_agents.py) | Sample: Concurrent fan-out/fan-in (agent-only API) with default aggregator | | [`getting_started/workflows/orchestration/concurrent_custom_agent_executors.py`](./getting_started/workflows/orchestration/concurrent_custom_agent_executors.py) | Sample: Concurrent Orchestration with Custom Agent Executors | | [`getting_started/workflows/orchestration/concurrent_custom_aggregator.py`](./getting_started/workflows/orchestration/concurrent_custom_aggregator.py) | Sample: Concurrent Orchestration with Custom Aggregator | -| [`getting_started/workflows/orchestration/group_chat.py`](./getting_started/workflows/orchestration/group_chat.py) | Sample: Group Chat Orchestration with LLM manager | -| [`getting_started/workflows/orchestration/magentic.py`](./getting_started/workflows/orchestration/magentic.py) | Sample: Magentic Orchestration (multi-agent) | -| [`getting_started/workflows/orchestration/magentic_checkpoint.py`](./getting_started/workflows/orchestration/magentic_checkpoint.py) | Sample: Magentic Orchestration + Checkpointing | -| [`getting_started/workflows/orchestration/magentic_human_plan_update.py`](./getting_started/workflows/orchestration/magentic_human_plan_update.py) | Sample: Magentic Orchestration + Human Plan Review | +| [`getting_started/workflows/orchestration/group_chat_prompt_based_manager.py`](./getting_started/workflows/orchestration/group_chat_prompt_based_manager.py) | Sample: Group Chat Orchestration with LLM-based manager | +| [`getting_started/workflows/orchestration/group_chat_simple_selector.py`](./getting_started/workflows/orchestration/group_chat_simple_selector.py) | Sample: Group Chat Orchestration with function-based speaker selector | +| [`getting_started/workflows/orchestration/handoff_simple.py`](./getting_started/workflows/orchestration/handoff_simple.py) | Sample: Handoff Orchestration with simple agent handoff pattern | +| [`getting_started/workflows/orchestration/handoff_specialist_to_specialist.py`](./getting_started/workflows/orchestration/handoff_specialist_to_specialist.py) | Sample: Handoff Orchestration with specialist-to-specialist routing | +| [`getting_started/workflows/orchestration/magentic.py`](./getting_started/workflows/orchestration/magentic.py) | Sample: Magentic Orchestration (agentic task planning with multi-agent execution) | +| [`getting_started/workflows/orchestration/magentic_checkpoint.py`](./getting_started/workflows/orchestration/magentic_checkpoint.py) | Sample: Magentic Orchestration with Checkpointing | +| [`getting_started/workflows/orchestration/magentic_human_plan_update.py`](./getting_started/workflows/orchestration/magentic_human_plan_update.py) | Sample: Magentic Orchestration with Human Plan Review | | [`getting_started/workflows/orchestration/sequential_agents.py`](./getting_started/workflows/orchestration/sequential_agents.py) | Sample: Sequential workflow (agent-focused API) with shared conversation context | | [`getting_started/workflows/orchestration/sequential_custom_executors.py`](./getting_started/workflows/orchestration/sequential_custom_executors.py) | Sample: Sequential workflow mixing agents and a custom summarizer executor | diff --git a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py index 72bd24c1e8e..42142b5363d 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/group_chat.py +++ b/python/samples/semantic-kernel-migration/orchestrations/group_chat.py @@ -2,8 +2,6 @@ """Side-by-side group chat orchestrations for Agent Framework and Semantic Kernel.""" -from __future__ import annotations - import asyncio import sys from collections.abc import Sequence diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py index ccb30d4f6c2..2bf1f73665c 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/handoff.py +++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py @@ -1,13 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. """Side-by-side handoff orchestrations for Semantic Kernel and Agent Framework.""" -from __future__ import annotations - import asyncio import sys -from collections.abc import AsyncIterable, Sequence -from typing import Any, cast -from collections.abc import Iterator +from collections.abc import AsyncIterable, Iterator, Sequence +from typing import cast from agent_framework import ( ChatMessage, @@ -29,13 +26,12 @@ FunctionResultContent, StreamingChatMessageContent, ) -from semantic_kernel.functions import KernelArguments, kernel_function -from semantic_kernel.prompt_template import KernelPromptTemplate, PromptTemplateConfig +from semantic_kernel.functions import kernel_function if sys.version_info >= (3, 12): - from typing import override # pragma: no cover + pass # pragma: no cover else: - from typing_extensions import override # pragma: no cover + pass # pragma: no cover CUSTOMER_PROMPT = "I need help with order 12345. I want a replacement and need to know when it will arrive." From 21c15667bb46709c50cd12de128fff7a8df4554c Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 24 Oct 2025 12:18:35 +0900 Subject: [PATCH 15/15] Cleanup per PR Feedback --- .../agent_framework/_workflows/__init__.py | 4 + .../agent_framework/_workflows/__init__.pyi | 2 + .../_workflows/_agent_executor.py | 36 +- ...or.py => _base_group_chat_orchestrator.py} | 52 +- .../_workflows/_conversation_history.py | 33 +- .../agent_framework/_workflows/_group_chat.py | 285 ++++----- .../agent_framework/_workflows/_handoff.py | 59 +- .../agent_framework/_workflows/_magentic.py | 21 +- .../_workflows/_orchestrator_helpers.py | 35 +- .../_workflows/_participant_utils.py | 20 + .../core/tests/workflow/test_group_chat.py | 541 ++++++++++++++++++ .../core/tests/workflow/test_handoff.py | 40 +- 12 files changed, 808 insertions(+), 320 deletions(-) rename python/packages/core/agent_framework/_workflows/{_base_orchestrator.py => _base_group_chat_orchestrator.py} (86%) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 0a4fb7c758e..e0f9a1cbc71 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -54,9 +54,11 @@ from ._function_executor import FunctionExecutor, executor from ._group_chat import ( DEFAULT_MANAGER_INSTRUCTIONS, + DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT, GroupChatBuilder, GroupChatDirective, GroupChatStateSnapshot, + ManagerDirectiveModel, ) from ._handoff import HandoffBuilder, HandoffUserInputRequest from ._magentic import ( @@ -104,6 +106,7 @@ __all__ = [ "DEFAULT_MANAGER_INSTRUCTIONS", + "DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT", "DEFAULT_MAX_ITERATIONS", "AgentExecutor", "AgentExecutorRequest", @@ -144,6 +147,7 @@ "MagenticPlanReviewDecision", "MagenticPlanReviewReply", "MagenticPlanReviewRequest", + "ManagerDirectiveModel", "Message", "OrchestrationState", "PendingRequestDetails", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index 38a304cf3e0..fbce568ec7b 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -52,6 +52,7 @@ from ._executor import ( from ._function_executor import FunctionExecutor, executor from ._group_chat import ( DEFAULT_MANAGER_INSTRUCTIONS, + DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT, GroupChatBuilder, GroupChatDirective, GroupChatStateSnapshot, @@ -102,6 +103,7 @@ from ._workflow_executor import WorkflowExecutor __all__ = [ "DEFAULT_MANAGER_INSTRUCTIONS", + "DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT", "DEFAULT_MAX_ITERATIONS", "AgentExecutor", "AgentExecutorRequest", diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 149fed93713..07f737db779 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import logging -from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -58,11 +57,6 @@ class AgentExecutor(Executor): - run(): Emits a single AgentRunEvent containing the complete response The executor automatically detects the mode via WorkflowContext.is_streaming(). - - Supports conversation injection hooks for advanced orchestration patterns: - - inject_conversation: Optional callback to inject/modify conversation before agent invocation - - on_message_event: Optional callback to emit custom events for each response message - - on_delta_event: Optional callback to emit custom events for streaming updates """ def __init__( @@ -72,9 +66,6 @@ def __init__( agent_thread: AgentThread | None = None, output_response: bool = False, id: str | None = None, - inject_conversation: Callable[[list[ChatMessage]], list[ChatMessage]] | None = None, - on_message_event: Callable[[WorkflowContext[Any, Any], ChatMessage], Any] | None = None, - on_delta_event: Callable[[WorkflowContext[Any, Any], AgentRunResponseUpdate], Any] | None = None, ): """Initialize the executor with a unique identifier. @@ -83,12 +74,6 @@ def __init__( agent_thread: The thread to use for running the agent. If None, a new thread will be created. output_response: Whether to yield an AgentRunResponse as a workflow output when the agent completes. id: A unique identifier for the executor. If None, the agent's name will be used if available. - inject_conversation: Optional callback to inject or modify conversation before agent invocation. - Takes the current cache and returns the conversation to pass to the agent. - on_message_event: Optional async callback to emit custom events for each response message. - Called with (context, message) for each message in the agent's response. - on_delta_event: Optional async callback to emit custom events for streaming updates. - Called with (context, update) for each streaming update. """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or agent.name @@ -99,9 +84,6 @@ def __init__( self._agent_thread = agent_thread or self._agent.get_new_thread() self._output_response = output_response self._cache: list[ChatMessage] = [] - self._inject_conversation = inject_conversation - self._on_message_event = on_message_event - self._on_delta_event = on_delta_event @property def workflow_output_types(self) -> list[type[Any]]: @@ -115,24 +97,15 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, Checks ctx.is_streaming() to determine whether to emit incremental AgentRunUpdateEvent events (streaming mode) or a single AgentRunEvent (non-streaming mode). - - Supports conversation injection and custom event callbacks for advanced orchestration. """ - # Apply conversation injection if provided - conversation = self._inject_conversation(self._cache) if self._inject_conversation else self._cache - if ctx.is_streaming(): # Streaming mode: emit incremental updates updates: list[AgentRunResponseUpdate] = [] async for update in self._agent.run_stream( - conversation, + self._cache, thread=self._agent_thread, ): updates.append(update) - # Emit custom delta event if callback provided - if self._on_delta_event: - await self._on_delta_event(ctx, update) - # Always emit standard update event await ctx.add_event(AgentRunUpdateEvent(self.id, update)) if isinstance(self._agent, ChatAgent): @@ -146,16 +119,11 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, else: # Non-streaming mode: use run() and emit single event response = await self._agent.run( - conversation, + self._cache, thread=self._agent_thread, ) await ctx.add_event(AgentRunEvent(self.id, response)) - # Emit custom message events if callback provided - if self._on_message_event: - for message in response.messages: - await self._on_message_event(ctx, message) - if self._output_response: await ctx.yield_output(response) diff --git a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py b/python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py similarity index 86% rename from python/packages/core/agent_framework/_workflows/_base_orchestrator.py rename to python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py index 5a8d5a0887c..5752febab5e 100644 --- a/python/packages/core/agent_framework/_workflows/_base_orchestrator.py +++ b/python/packages/core/agent_framework/_workflows/_base_group_chat_orchestrator.py @@ -1,20 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. -"""Base orchestrator class for group chat patterns. - -This module provides BaseGroupChatOrchestrator, an abstract base class that -consolidates shared orchestration logic across GroupChat, Handoff, and Magentic patterns. -""" +"""Base class for group chat orchestrators that manages conversation flow and participant selection.""" +import inspect import logging -from abc import ABC -from collections.abc import Callable, Sequence +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable, Sequence from typing import Any from .._types import ChatMessage -from ._conversation_history import append_messages, clone_conversation from ._executor import Executor -from ._orchestrator_helpers import ParticipantRegistry, create_completion_message +from ._orchestrator_helpers import ParticipantRegistry from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) @@ -42,7 +38,7 @@ def __init__(self, executor_id: str) -> None: self._conversation: list[ChatMessage] = [] self._round_index: int = 0 self._max_rounds: int | None = None - self._termination_condition: Callable[[list[ChatMessage]], bool] | None = None + self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] | None = None def register_participant_entry(self, name: str, *, entry_id: str, is_agent: bool) -> None: """Record routing details for a participant's entry executor. @@ -65,7 +61,7 @@ def _append_messages(self, messages: Sequence[ChatMessage]) -> None: Args: messages: Messages to append """ - append_messages(self._conversation, messages) + self._conversation.extend(messages) def _get_conversation(self) -> list[ChatMessage]: """Get a copy of the current conversation. @@ -73,7 +69,7 @@ def _get_conversation(self) -> list[ChatMessage]: Returns: Cloned conversation list """ - return clone_conversation(self._conversation) + return list(self._conversation) def _clear_conversation(self) -> None: """Clear the conversation history.""" @@ -83,17 +79,34 @@ def _increment_round(self) -> None: """Increment the round counter.""" self._round_index += 1 - def _check_termination(self) -> bool: + 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.iscoroutine(result) or inspect.isawaitable(result): + result = await result return bool(result) + @abstractmethod + def _get_author_name(self) -> str: + """Get the author name for orchestrator-generated messages. + + Subclasses must implement this to provide a stable author name + for completion messages and other orchestrator-generated content. + + Returns: + Author name to use for messages generated by this orchestrator + """ + ... + def _create_completion_message( self, text: str | None = None, @@ -108,12 +121,13 @@ def _create_completion_message( Returns: ChatMessage with completion content """ - # Try to get manager/orchestrator name from subclass - author_name = getattr(self, "_manager_name", self.id) - return create_completion_message( - text=text, - author_name=author_name, - reason=reason, + from .._types import Role + + message_text = text or f"Conversation {reason}." + return ChatMessage( + role=Role.ASSISTANT, + text=message_text, + author_name=self._get_author_name(), ) # Participant routing (shared across all patterns) diff --git a/python/packages/core/agent_framework/_workflows/_conversation_history.py b/python/packages/core/agent_framework/_workflows/_conversation_history.py index 7f44986ec94..7e19671b27e 100644 --- a/python/packages/core/agent_framework/_workflows/_conversation_history.py +++ b/python/packages/core/agent_framework/_workflows/_conversation_history.py @@ -7,20 +7,10 @@ """ import json -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Mapping, Sequence from typing import Any -from .._types import ChatMessage, Role - - -def clone_conversation(messages: Iterable[ChatMessage]) -> list[ChatMessage]: - """Return a shallow copy of `messages` as a list.""" - return list(messages) - - -def append_messages(conversation: list[ChatMessage], messages: Iterable[ChatMessage]) -> None: - """Extend `conversation` with `messages` in order.""" - conversation.extend(messages) +from .._types import ChatMessage def latest_user_message(conversation: Sequence[ChatMessage]) -> ChatMessage: @@ -29,26 +19,13 @@ def latest_user_message(conversation: Sequence[ChatMessage]) -> ChatMessage: role_value = getattr(message.role, "value", message.role) if str(role_value).lower() == "user": return message - if not conversation: - raise ValueError("Conversation is empty; cannot determine user message.") - return conversation[-1] + raise ValueError("No user message in conversation") def ensure_author(message: ChatMessage, fallback: str) -> ChatMessage: """Attach `fallback` author if message is missing `author_name`.""" - author = getattr(message, "author_name", None) - if author: - return message - if hasattr(message, "to_dict") and callable(message.to_dict): # type: ignore[attr-defined] - payload = message.to_dict() # type: ignore[attr-defined] - else: - payload = getattr(message, "__dict__", {}).copy() - payload["author_name"] = fallback - if hasattr(ChatMessage, "from_dict") and callable(getattr(ChatMessage, "from_dict", None)): - return ChatMessage.from_dict(payload) # type: ignore[attr-defined,return-value] - return ChatMessage( - role=getattr(message, "role", Role.ASSISTANT), text=payload.get("text", ""), author_name=fallback - ) + message.author_name = message.author_name or fallback + return message def snapshot_state(conversation: Sequence[ChatMessage]) -> dict[str, Any]: diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index a32e3ba4eb1..84859a4f0c1 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -20,23 +20,24 @@ import inspect import itertools -import json import logging from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from types import MappingProxyType -from typing import Any, TypeAlias, TypedDict +from typing import Any, TypeAlias from uuid import uuid4 +from pydantic import BaseModel, Field + from .._agents import AgentProtocol from .._clients import ChatClientProtocol from .._types import ChatMessage, Role from ._agent_executor import AgentExecutorRequest, AgentExecutorResponse -from ._base_orchestrator import BaseGroupChatOrchestrator +from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage -from ._conversation_history import append_messages, clone_conversation, ensure_author, latest_user_message +from ._conversation_history import ensure_author, latest_user_message from ._executor import Executor, handler -from ._participant_utils import prepare_participant_metadata, wrap_participant +from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, wrap_participant from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder from ._workflow_context import WorkflowContext @@ -64,9 +65,6 @@ class _GroupChatResponseMessage: agent_name: str message: ChatMessage - target_agent: str | None = None - broadcast: bool = False - metadata: dict[str, Any] | None = None @dataclass @@ -106,26 +104,11 @@ async def _maybe_await(value: Any) -> Any: return value -@dataclass -class _GroupChatParticipantSpec: - """Internal: Metadata describing a single participant in the orchestration. - - Attributes: - name: Unique identifier for the participant used by the manager for selection - participant: AgentProtocol or Executor instance representing the participant - description: Human-readable description provided to the manager for selection context - """ - - name: str - participant: AgentProtocol | Executor - description: str - - _GroupChatParticipantPipeline: TypeAlias = Sequence[Executor] @dataclass -class _GroupChatWiring: +class _GroupChatConfig: """Internal: Configuration passed to factories during workflow assembly. Attributes: @@ -138,7 +121,7 @@ class _GroupChatWiring: manager: _GroupChatManagerFn | None manager_name: str - participants: Mapping[str, _GroupChatParticipantSpec] + participants: Mapping[str, GroupChatParticipantSpec] max_rounds: int | None = None orchestrator: Executor | None = None participant_aliases: dict[str, str] = field(default_factory=dict) # type: ignore[type-arg] @@ -150,13 +133,13 @@ class _GroupChatWiring: # region Default participant factory -_GroupChatOrchestratorFactory: TypeAlias = Callable[[_GroupChatWiring], Executor] -_InterceptorSpec: TypeAlias = tuple[Callable[[_GroupChatWiring], Executor], Callable[[Any], bool]] +_GroupChatOrchestratorFactory: TypeAlias = Callable[[_GroupChatConfig], Executor] +_InterceptorSpec: TypeAlias = tuple[Callable[[_GroupChatConfig], Executor], Callable[[Any], bool]] def _default_participant_factory( - spec: _GroupChatParticipantSpec, - wiring: _GroupChatWiring, + spec: GroupChatParticipantSpec, + wiring: _GroupChatConfig, ) -> _GroupChatParticipantPipeline: """Default factory for constructing participant pipeline nodes in the workflow graph. @@ -260,22 +243,9 @@ def __init__( # Stashes the initial conversation list until _handle_task_message normalizes it into _conversation. self._pending_initial_conversation: list[ChatMessage] | None = None - @staticmethod - def _role_value(message: ChatMessage) -> str: - """Extract string role value from a ChatMessage, handling enum and string cases. - - Args: - message: Chat message with role attribute (may be enum or string) - - Returns: - String representation of the role (e.g., "user", "assistant", "system") - - Why this exists: - Different ChatMessage implementations may use Role enum or plain strings. - This normalizes access for consistent turn tracking. - """ - role = getattr(message.role, "value", None) or str(message.role) - return str(role) + def _get_author_name(self) -> str: + """Get the manager name for orchestrator-generated messages.""" + return self._manager_name def _build_state(self) -> GroupChatStateSnapshot: """Build a snapshot of current orchestration state for the manager. @@ -393,7 +363,7 @@ async def _apply_directive( ) final_message = ensure_author(final_message, self._manager_name) - append_messages(self._conversation, (final_message,)) + self._conversation.extend((final_message,)) self._history.append(_GroupChatTurn(self._manager_name, "manager", final_message)) self._pending_agent = None await ctx.yield_output(final_message) @@ -405,19 +375,15 @@ async def _apply_directive( if agent_name not in self._participants: raise ValueError(f"Manager selected unknown participant '{agent_name}'.") - entry_id = self._registry.get_entry_id(agent_name) - if entry_id is None: - raise ValueError(f"No registered entry executor for participant '{agent_name}'.") - instruction = directive.instruction or "" - conversation = clone_conversation(self._conversation) + conversation = list(self._conversation) if instruction: manager_message = ensure_author( self._create_completion_message(text=instruction, reason="instruction"), self._manager_name, ) - append_messages(conversation, (manager_message,)) - append_messages(self._conversation, (manager_message,)) + conversation.extend((manager_message,)) + self._conversation.extend((manager_message,)) self._history.append(_GroupChatTurn(self._manager_name, "manager", manager_message)) self._pending_agent = agent_name @@ -453,19 +419,14 @@ async def _ingest_participant_message( ) -> None: """Common response ingestion logic shared by agent and custom participants.""" if participant_name not in self._participants: - logger.debug("Ignoring response from unknown participant '%s'.", participant_name) - return + raise ValueError(f"Received response from unknown participant '{participant_name}'.") message = ensure_author(message, participant_name) - append_messages(self._conversation, (message,)) + self._conversation.extend((message,)) self._history.append(_GroupChatTurn(participant_name, "agent", message)) self._pending_agent = None - if self._max_rounds is not None and self._round_index >= self._max_rounds: - logger.warning( - "GroupChatOrchestratorExecutor reached max_rounds=%s after receiving agent response.", - self._max_rounds, - ) + if self._check_round_limit(): await ctx.yield_output( self._create_completion_message( text="Conversation halted after reaching manager round limit.", @@ -491,7 +452,7 @@ def _extract_agent_message(response: AgentExecutorResponse, participant_name: st if not sequence: continue for candidate in reversed(sequence): - if getattr(candidate, "role", None) == Role.ASSISTANT: + if candidate.role == Role.ASSISTANT: final_message = candidate break if final_message is not None: @@ -542,13 +503,13 @@ async def _handle_task_message( """ self._task_message = task_message if self._pending_initial_conversation: - initial_conversation = clone_conversation(self._pending_initial_conversation) + initial_conversation = list(self._pending_initial_conversation) self._pending_initial_conversation = None self._conversation = initial_conversation self._history = [ _GroupChatTurn( - msg.author_name or self._role_value(msg), - self._role_value(msg), + msg.author_name or msg.role.value, + msg.role.value, msg, ) for msg in initial_conversation @@ -633,7 +594,7 @@ async def handle_conversation( """ if not conversation: raise ValueError("GroupChat workflow requires at least one chat message.") - self._pending_initial_conversation = clone_conversation(conversation) + self._pending_initial_conversation = list(conversation) task_message = latest_user_message(conversation) await self._handle_task_message(task_message, ctx) @@ -664,7 +625,7 @@ async def handle_agent_executor_response( await self._ingest_participant_message(participant_name, message, ctx) -def _default_orchestrator_factory(wiring: _GroupChatWiring) -> Executor: +def _default_orchestrator_factory(wiring: _GroupChatConfig) -> Executor: """Default factory for creating the GroupChatOrchestratorExecutor instance. This is the internal implementation used by GroupChatBuilder to instantiate the @@ -708,8 +669,8 @@ def group_chat_orchestrator(factory: _GroupChatOrchestratorFactory | None = None def assemble_group_chat_workflow( *, - wiring: _GroupChatWiring, - participant_factory: Callable[[_GroupChatParticipantSpec, _GroupChatWiring], _GroupChatParticipantPipeline], + wiring: _GroupChatConfig, + participant_factory: Callable[[GroupChatParticipantSpec, _GroupChatConfig], _GroupChatParticipantPipeline], orchestrator_factory: _GroupChatOrchestratorFactory = _default_orchestrator_factory, interceptors: Sequence[_InterceptorSpec] | None = None, checkpoint_storage: CheckpointStorage | None = None, @@ -845,7 +806,7 @@ def __init__( self, *, _orchestrator_factory: _GroupChatOrchestratorFactory | None = None, - _participant_factory: Callable[[_GroupChatParticipantSpec, _GroupChatWiring], _GroupChatParticipantPipeline] + _participant_factory: Callable[[GroupChatParticipantSpec, _GroupChatConfig], _GroupChatParticipantPipeline] | None = None, ) -> None: """Initialize the GroupChatBuilder. @@ -888,11 +849,18 @@ def set_prompt_based_manager( instructions: str | None = None, display_name: str | None = None, ) -> "GroupChatBuilder": - """Configure the default prompt-based manager driven by an LLM chat client. + r"""Configure the default prompt-based manager driven by an LLM chat client. + + The manager coordinates participants by making selection decisions based on the conversation + state, task, and participant descriptions. It uses structured output (ManagerDirectiveModel) + to ensure reliable parsing of decisions. Args: chat_client: Chat completion client used to run the coordinator LLM. - instructions: Optional system instructions to steer the coordinator prompt. + instructions: System instructions to steer the coordinator's decision-making. + If not provided, uses DEFAULT_MANAGER_INSTRUCTIONS. These instructions are combined + with the task description, participant list, and structured output format to guide + the LLM in selecting the next speaker or completing the conversation. display_name: Optional conversational display name for manager messages. Returns: @@ -905,9 +873,15 @@ def set_prompt_based_manager( .. code-block:: python + from agent_framework import GroupChatBuilder, DEFAULT_MANAGER_INSTRUCTIONS + + custom_instructions = ( + DEFAULT_MANAGER_INSTRUCTIONS + "\\n\\nPrioritize the researcher for data analysis tasks." + ) + workflow = ( GroupChatBuilder() - .set_prompt_based_manager(chat_client, display_name="Coordinator") + .set_prompt_based_manager(chat_client, instructions=custom_instructions, display_name="Coordinator") .participants(researcher=researcher, writer=writer) .build() ) @@ -1087,7 +1061,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "GroupCha def with_request_handler( self, - handler: Callable[[_GroupChatWiring], Executor] | Executor, + handler: Callable[[_GroupChatConfig], Executor] | Executor, *, condition: Callable[[Any], bool], ) -> "GroupChatBuilder": @@ -1100,11 +1074,11 @@ def with_request_handler( Returns: Self for fluent chaining """ - factory: Callable[[_GroupChatWiring], Executor] + factory: Callable[[_GroupChatConfig], Executor] if isinstance(handler, Executor): executor = handler - def _factory(_: _GroupChatWiring) -> Executor: + def _factory(_: _GroupChatConfig) -> Executor: return executor factory = _factory @@ -1166,12 +1140,12 @@ def _get_participant_metadata(self) -> dict[str, Any]: ) return self._participant_metadata - def _build_participant_specs(self) -> dict[str, _GroupChatParticipantSpec]: + def _build_participant_specs(self) -> dict[str, GroupChatParticipantSpec]: metadata = self._get_participant_metadata() descriptions: Mapping[str, str] = metadata["descriptions"] - specs: dict[str, _GroupChatParticipantSpec] = {} + specs: dict[str, GroupChatParticipantSpec] = {} for name, participant in self._participants.items(): - specs[name] = _GroupChatParticipantSpec( + specs[name] = GroupChatParticipantSpec( name=name, participant=participant, description=descriptions[name], @@ -1226,7 +1200,7 @@ def build(self) -> Workflow: metadata = self._get_participant_metadata() participant_specs = self._build_participant_specs() - wiring = _GroupChatWiring( + wiring = _GroupChatConfig( manager=self._manager, manager_name=self._manager_name, participants=participant_specs, @@ -1253,77 +1227,39 @@ def build(self) -> Workflow: # region Default manager implementation -class ManagerDirectivePayload(TypedDict, total=False): - """Typed mapping describing a manager directive.""" - - next_agent: str | None - message: str | None - finish: bool - final_response: str | None - - -def _coerce_directive_source(value: Any) -> dict[str, Any]: - """Attempt to convert structured output into a plain mapping.""" - if isinstance(value, dict): - return value # type: ignore[return-value,no-any-return] - if isinstance(value, Mapping): - return dict(value) # type: ignore[arg-type] - if hasattr(value, "model_dump") and callable(value.model_dump): # type: ignore[attr-defined] - result = value.model_dump() # type: ignore[attr-defined] - return dict(result) if isinstance(result, Mapping) else result # type: ignore[arg-type,return-value] - if hasattr(value, "to_dict") and callable(value.to_dict): # type: ignore[attr-defined] - result = value.to_dict() # type: ignore[attr-defined] - return dict(result) if isinstance(result, Mapping) else result # type: ignore[arg-type,return-value] - if isinstance(value, str): - parsed = json.loads(value) - return dict(parsed) if isinstance(parsed, Mapping) else parsed # type: ignore[arg-type,return-value] - dict_value = getattr(value, "__dict__", None) - if dict_value is not None: - return dict(dict_value) # type: ignore[arg-type] - return value # type: ignore[return-value,no-any-return] - - -def _parse_manager_payload(raw: Any) -> ManagerDirectivePayload: - """Validate raw manager output into ManagerDirectivePayload.""" - data = _coerce_directive_source(raw) - if not isinstance(data, dict): - raise RuntimeError("Unable to parse manager directive from chat client response.") - - payload: ManagerDirectivePayload = {} - - next_agent_value = data.get("next_agent") - if next_agent_value is not None and not isinstance(next_agent_value, str): - raise RuntimeError("Manager directive 'next_agent' must be a string or null.") - payload["next_agent"] = next_agent_value # type: ignore[typeddict-item] - - message_value = data.get("message") - if message_value is not None and not isinstance(message_value, str): - raise RuntimeError("Manager directive 'message' must be a string when provided.") - if message_value is not None: - payload["message"] = message_value - - finish_value = data.get("finish", False) - if not isinstance(finish_value, bool): - raise RuntimeError("Manager directive 'finish' must be a boolean.") - payload["finish"] = finish_value - - final_response_value = data.get("final_response") - if final_response_value is not None and not isinstance(final_response_value, str): - raise RuntimeError("Manager directive 'final_response' must be a string when provided.") - if final_response_value is not None: - payload["final_response"] = final_response_value - - return payload - - DEFAULT_MANAGER_INSTRUCTIONS = """You are coordinating a team conversation to solve the user's task. -Select the next participant to respond or finish the task. When selecting an agent you MUST return -the JSON fields: +Your role is to orchestrate collaboration between multiple participants by selecting who speaks next. +Leverage each participant's unique expertise as described in their descriptions. +Have participants build on each other's contributions - earlier participants gather information, +later ones refine and synthesize. +Only finish the task after multiple relevant participants have contributed their expertise.""" + +DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT = """Return your decision using the following structure: - next_agent: name of the participant who should act next (use null when finish is true) - message: instruction for that participant (empty string if not needed) - finish: boolean indicating if the task is complete -- final_response: when finish is true, provide the final answer to the user -""" +- final_response: when finish is true, provide the final answer to the user""" + + +class ManagerDirectiveModel(BaseModel): + """Pydantic model for structured manager directive output.""" + + next_agent: str | None = Field( + default=None, + description="Name of the participant who should act next (null when finish is true)", + ) + message: str = Field( + default="", + description="Instruction for the selected participant", + ) + finish: bool = Field( + default=False, + description="Whether the task is complete", + ) + final_response: str | None = Field( + default=None, + description="Final answer to the user when finish is true", + ) class _PromptBasedGroupChatManager: @@ -1336,8 +1272,8 @@ class _PromptBasedGroupChatManager: Coordination strategy: - Receives immutable state snapshot with full conversation history - Formats system prompt with instructions, task, and participant descriptions - - Appends conversation context and structured output prompt - - Parses LLM response (JSON mapping) and converts to GroupChatDirective + - Appends conversation context and uses structured output (Pydantic model) for reliable parsing + - Converts LLM response to GroupChatDirective Flexibility: - Custom instructions allow domain-specific coordination strategies @@ -1351,7 +1287,9 @@ class _PromptBasedGroupChatManager: Args: chat_client: ChatClientProtocol implementation for LLM inference - instructions: Custom system instructions (defaults to DEFAULT_MANAGER_INSTRUCTIONS) + instructions: Custom system instructions (defaults to DEFAULT_MANAGER_INSTRUCTIONS). + These instructions are combined with the task, participant list, and + structured output format (ManagerDirectiveModel) to coordinate the conversation. name: Display name for the manager in conversation history Raises: @@ -1384,37 +1322,36 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: system_message = ChatMessage( role=Role.SYSTEM, - text=(f"{self._instructions}\n\nTask:\n{task_message.text}\n\nParticipants:\n{participants_section}"), + text=( + f"{self._instructions}\n\n" + f"Task:\n{task_message.text}\n\n" + f"Participants:\n{participants_section}\n\n" + f"{DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT}" + ), ) messages: list[ChatMessage] = [system_message, *conversation] - messages.append( - ChatMessage( - role=Role.USER, - text=( - "Return a JSON object with keys (next_agent, message, finish, final_response). " - "If you decide to finish, next_agent must be null." - ), - ) - ) - response = await self._chat_client.get_response(messages) - payload_source: Any + response = await self._chat_client.get_response(messages, response_format=ManagerDirectiveModel) + + directive_model: ManagerDirectiveModel if response.value is not None: - payload_source = response.value + if isinstance(response.value, ManagerDirectiveModel): + directive_model = response.value + elif isinstance(response.value, str): + directive_model = ManagerDirectiveModel.model_validate_json(response.value) + elif isinstance(response.value, dict): + directive_model = ManagerDirectiveModel.model_validate(response.value) # type: ignore[arg-type] + else: + raise RuntimeError(f"Unexpected response.value type: {type(response.value)}") elif response.messages: - payload_source = response.messages[-1].text or "{}" + text = response.messages[-1].text or "{}" + directive_model = ManagerDirectiveModel.model_validate_json(text) else: raise RuntimeError("LLM response did not contain structured output.") - try: - directive_payload = _parse_manager_payload(payload_source) - except (json.JSONDecodeError, RuntimeError) as exc: - logger.error("Failed to parse manager directive: %s", exc) - raise RuntimeError("Unable to parse manager directive from chat client response.") from exc - - if directive_payload.get("finish", False): - final_text = directive_payload.get("final_response") or "" + if directive_model.finish: + final_text = directive_model.final_response or "" return GroupChatDirective( finish=True, final_message=ChatMessage( @@ -1424,7 +1361,7 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: ), ) - next_agent = directive_payload.get("next_agent") + next_agent = directive_model.next_agent if not next_agent: raise RuntimeError("Manager directive missing next_agent while finish is False.") if next_agent not in participants: @@ -1432,7 +1369,7 @@ async def __call__(self, state: GroupChatStateSnapshot) -> GroupChatDirective: return GroupChatDirective( agent_name=next_agent, - instruction=directive_payload.get("message") or "", + instruction=directive_model.message or "", ) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 4bebff67d4b..11076e3d149 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -35,18 +35,16 @@ from .._agents import ChatAgent from .._middleware import FunctionInvocationContext, FunctionMiddleware from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse -from ._base_orchestrator import BaseGroupChatOrchestrator +from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage -from ._conversation_history import append_messages, clone_conversation from ._executor import Executor, handler from ._group_chat import ( _default_participant_factory, # type: ignore[reportPrivateUsage] - _GroupChatParticipantSpec, # type: ignore[reportPrivateUsage] - _GroupChatWiring, # type: ignore[reportPrivateUsage] + _GroupChatConfig, # type: ignore[reportPrivateUsage] assemble_group_chat_workflow, ) from ._orchestrator_helpers import clean_conversation_for_handoff -from ._participant_utils import prepare_participant_metadata, sanitize_identifier +from ._participant_utils import GroupChatParticipantSpec, prepare_participant_metadata, sanitize_identifier from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse from ._workflow import Workflow from ._workflow_builder import WorkflowBuilder @@ -265,7 +263,7 @@ def __init__( starting_agent_id: str, specialist_ids: Mapping[str, str], input_gateway_id: str, - termination_condition: Callable[[list[ChatMessage]], bool], + termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]], id: str, handoff_tool_targets: Mapping[str, str] | None = None, ) -> None: @@ -278,6 +276,10 @@ def __init__( self._termination_condition = termination_condition self._handoff_tool_targets = {k.lower(): v for k, v in (handoff_tool_targets or {}).items()} + def _get_author_name(self) -> str: + """Get the coordinator name for orchestrator-generated messages.""" + return "handoff_coordinator" + @handler async def handle_agent_response( self, @@ -292,7 +294,7 @@ async def handle_agent_response( elif not self._get_conversation(): restored = self._restore_conversation_from_state(state) if restored: - self._conversation = clone_conversation(restored) + self._conversation = list(restored) source = ctx.get_source_executor_id() is_starting_agent = source == self._starting_agent_id @@ -304,16 +306,16 @@ async def handle_agent_response( # First response from starting agent - initialize with authoritative conversation snapshot # Keep the FULL conversation including tool calls (OpenAI SDK default behavior) full_conv = self._conversation_from_response(response) - self._conversation = clone_conversation(full_conv) + self._conversation = list(full_conv) else: # Subsequent responses - append only new messages from this agent # Keep ALL messages including tool calls to maintain complete history new_messages = response.agent_run_response.messages or [] - append_messages(self._conversation, new_messages) + self._conversation.extend(new_messages) self._apply_response_metadata(self._conversation, response.agent_run_response) - conversation = clone_conversation(self._conversation) + conversation = list(self._conversation) # Check for handoff from ANY agent (starting agent or specialist) target = self._resolve_specialist(response.agent_run_response, conversation) @@ -331,7 +333,7 @@ async def handle_agent_response( await self._persist_state(ctx) - if self._check_termination(): + if await self._check_termination(): logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(conversation)) return @@ -346,11 +348,11 @@ async def handle_user_input( ) -> None: """Receive full conversation with new user input from gateway, update history, trim for agent.""" # Update authoritative conversation - self._conversation = clone_conversation(message.full_conversation) + self._conversation = list(message.full_conversation) await self._persist_state(ctx) # Check termination before sending to agent - if self._check_termination(): + if await self._check_termination(): logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(self._conversation)) return @@ -408,7 +410,7 @@ def _append_tool_acknowledgement( author_name=function_call.name, ) # Add tool acknowledgement to both the conversation being sent and the full history - append_messages(conversation, (tool_message,)) + conversation.extend((tool_message,)) self._append_messages((tool_message,)) def _conversation_from_response(self, response: AgentExecutorResponse) -> list[ChatMessage]: @@ -728,7 +730,10 @@ def __init__( self._starting_agent_id: str | None = None self._checkpoint_storage: CheckpointStorage | None = None self._request_prompt: str | None = None - self._termination_condition: Callable[[list[ChatMessage]], bool] = _default_termination_condition + # Termination condition + self._termination_condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] = ( + _default_termination_condition + ) self._auto_register_handoff_tools: bool = True self._handoff_config: dict[str, list[str]] = {} # Maps agent_id -> [target_agent_ids] @@ -1151,12 +1156,16 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "HandoffB self._checkpoint_storage = checkpoint_storage return self - def with_termination_condition(self, condition: Callable[[list[ChatMessage]], bool]) -> "HandoffBuilder": + def with_termination_condition( + self, condition: Callable[[list[ChatMessage]], bool | Awaitable[bool]] + ) -> "HandoffBuilder": """Set a custom termination condition for the handoff workflow. + The condition can be either synchronous or asynchronous. + Args: condition: Function that receives the full conversation and returns True - if the workflow should terminate (not request further user input). + (or awaitable True) if the workflow should terminate (not request further user input). Returns: Self for chaining. @@ -1165,9 +1174,19 @@ def with_termination_condition(self, condition: Callable[[list[ChatMessage]], bo .. code-block:: python + # Synchronous condition builder.with_termination_condition( lambda conv: len(conv) > 20 or any("goodbye" in msg.text.lower() for msg in conv[-2:]) ) + + + # Asynchronous condition + async def check_termination(conv: list[ChatMessage]) -> bool: + # Can perform async operations + return len(conv) > 20 + + + builder.with_termination_condition(check_termination) """ self._termination_condition = condition return self @@ -1279,7 +1298,7 @@ def build(self) -> Workflow: exec_id: getattr(executor, "description", None) or exec_id for exec_id, executor in self._executors.items() } participant_specs = { - exec_id: _GroupChatParticipantSpec(name=exec_id, participant=executor, description=descriptions[exec_id]) + exec_id: GroupChatParticipantSpec(name=exec_id, participant=executor, description=descriptions[exec_id]) for exec_id, executor in self._executors.items() } @@ -1294,7 +1313,7 @@ def build(self) -> Workflow: specialist_aliases = {alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists} - def _handoff_orchestrator_factory(_: _GroupChatWiring) -> Executor: + def _handoff_orchestrator_factory(_: _GroupChatConfig) -> Executor: return _HandoffCoordinator( starting_agent_id=starting_executor.id, specialist_ids=specialist_aliases, @@ -1304,7 +1323,7 @@ def _handoff_orchestrator_factory(_: _GroupChatWiring) -> Executor: handoff_tool_targets=handoff_tool_targets, ) - wiring = _GroupChatWiring( + wiring = _GroupChatConfig( manager=None, manager_name=self._starting_agent_id, participants=participant_specs, diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index a8230b5af02..121cd9dd3de 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -24,22 +24,21 @@ Role, ) -from ._base_orchestrator import BaseGroupChatOrchestrator +from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._events import WorkflowEvent from ._executor import Executor, handler from ._group_chat import ( GroupChatBuilder, + _GroupChatConfig, # type: ignore[reportPrivateUsage] _GroupChatParticipantPipeline, # type: ignore[reportPrivateUsage] - _GroupChatParticipantSpec, # type: ignore[reportPrivateUsage] _GroupChatRequestMessage, # type: ignore[reportPrivateUsage] _GroupChatResponseMessage, # type: ignore[reportPrivateUsage] - _GroupChatWiring, # type: ignore[reportPrivateUsage] group_chat_orchestrator, ) from ._message_utils import normalize_messages_input from ._model_utils import DictConvertible, encode_value -from ._participant_utils import participant_description +from ._participant_utils import GroupChatParticipantSpec, participant_description from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse from ._workflow import Workflow, WorkflowRunResult from ._workflow_context import WorkflowContext @@ -402,10 +401,10 @@ def __init__( super().__init__( agent_name=agent_name, message=body, - target_agent=target_agent, - broadcast=broadcast, ) self.body = body + self.target_agent = target_agent + self.broadcast = broadcast def to_dict(self) -> dict[str, Any]: """Create a dict representation of the message.""" @@ -1000,6 +999,10 @@ def __init__( # Tracks whether checkpoint state has been applied for this run self._state_restored = False + def _get_author_name(self) -> str: + """Get the magentic manager name for orchestrator-generated messages.""" + return MAGENTIC_MANAGER_NAME + def register_agent_executor(self, name: str, executor: "MagenticAgentExecutor") -> None: """Register an agent executor for internal control (no messages).""" self._agent_executors[name] = executor @@ -2237,7 +2240,7 @@ def build(self) -> Workflow: # Type narrowing: we already checked self._manager is not None above manager: MagenticManagerBase = self._manager # type: ignore[assignment] - def _orchestrator_factory(wiring: _GroupChatWiring) -> Executor: + def _orchestrator_factory(wiring: _GroupChatConfig) -> Executor: return MagenticOrchestratorExecutor( manager=manager, participants=participant_descriptions, @@ -2246,8 +2249,8 @@ def _orchestrator_factory(wiring: _GroupChatWiring) -> Executor: ) def _participant_factory( - spec: _GroupChatParticipantSpec, - wiring: _GroupChatWiring, + spec: GroupChatParticipantSpec, + wiring: _GroupChatConfig, ) -> _GroupChatParticipantPipeline: agent_executor = MagenticAgentExecutor( spec.participant, diff --git a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py index 9fdd366075a..85cde6abbbe 100644 --- a/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py +++ b/python/packages/core/agent_framework/_workflows/_orchestrator_helpers.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any from .._types import ChatMessage, Role -from ._conversation_history import clone_conversation if TYPE_CHECKING: from ._group_chat import _GroupChatRequestMessage # type: ignore[reportPrivateUsage] @@ -75,38 +74,6 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat return cleaned -def check_round_limit( - current_round: int, - max_rounds: int | None, - *, - pattern_name: str = "orchestrator", -) -> bool: - """Check if round limit has been reached. - - Simple utility to avoid duplicating limit checking logic. - - Args: - current_round: Current round index - max_rounds: Maximum allowed rounds, or None for unlimited - pattern_name: Name for logging (e.g., "group_chat", "handoff") - - Returns: - True if within limits, False if limit reached - """ - if max_rounds is None: - return True - - if current_round >= max_rounds: - logger.warning( - "%s reached max_rounds=%s; stopping coordination.", - pattern_name, - max_rounds, - ) - return False - - return True - - def create_completion_message( *, text: str | None = None, @@ -160,7 +127,7 @@ def prepare_participant_request( return _GroupChatRequestMessage( agent_name=participant_name, - conversation=clone_conversation(conversation), + conversation=list(conversation), instruction=instruction or "", task=task, metadata=metadata, diff --git a/python/packages/core/agent_framework/_workflows/_participant_utils.py b/python/packages/core/agent_framework/_workflows/_participant_utils.py index 55ed7dde0de..ac632a917d3 100644 --- a/python/packages/core/agent_framework/_workflows/_participant_utils.py +++ b/python/packages/core/agent_framework/_workflows/_participant_utils.py @@ -4,12 +4,32 @@ import re from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass from typing import Any from .._agents import AgentProtocol from ._agent_executor import AgentExecutor from ._executor import Executor + +@dataclass +class GroupChatParticipantSpec: + """Metadata describing a single participant in group chat orchestrations. + + Used by multiple orchestration patterns (GroupChat, Handoff, Magentic) to describe + participants with consistent structure across different workflow types. + + Attributes: + name: Unique identifier for the participant used by managers for selection + participant: AgentProtocol or Executor instance representing the participant + description: Human-readable description provided to managers for selection context + """ + + name: str + participant: AgentProtocol | Executor + description: str + + _SANITIZE_PATTERN = re.compile(r"[^0-9a-zA-Z]+") diff --git a/python/packages/core/tests/workflow/test_group_chat.py b/python/packages/core/tests/workflow/test_group_chat.py index 45993a9b8a7..01942a8703d 100644 --- a/python/packages/core/tests/workflow/test_group_chat.py +++ b/python/packages/core/tests/workflow/test_group_chat.py @@ -3,6 +3,8 @@ from collections.abc import AsyncIterable, Callable from typing import Any +import pytest + from agent_framework import ( AgentRunResponse, AgentRunResponseUpdate, @@ -10,6 +12,7 @@ BaseAgent, ChatMessage, GroupChatBuilder, + GroupChatDirective, GroupChatStateSnapshot, MagenticAgentMessageEvent, MagenticBuilder, @@ -21,6 +24,14 @@ Workflow, WorkflowOutputEvent, ) +from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage +from agent_framework._workflows._group_chat import ( + GroupChatOrchestratorExecutor, + _default_orchestrator_factory, # type: ignore + _GroupChatConfig, # type: ignore + _PromptBasedGroupChatManager, # type: ignore + _SpeakerSelectorAdapter, # type: ignore +) from agent_framework._workflows._magentic import ( _MagenticProgressLedger, # type: ignore _MagenticProgressLedgerItem, # type: ignore @@ -201,3 +212,533 @@ async def test_magentic_as_agent_accepts_conversation() -> None: response = await agent.run(conversation) assert isinstance(response, AgentRunResponse) + + +# Comprehensive tests for group chat functionality + + +class TestGroupChatBuilder: + """Tests for GroupChatBuilder validation and configuration.""" + + def test_build_without_manager_raises_error(self) -> None: + """Test that building without a manager raises ValueError.""" + agent = StubAgent("test", "response") + + builder = GroupChatBuilder().participants([agent]) + + with pytest.raises(ValueError, match="manager must be configured before build"): + builder.build() + + def test_build_without_participants_raises_error(self) -> None: + """Test that building without participants raises ValueError.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + builder = GroupChatBuilder().select_speakers(selector) + + with pytest.raises(ValueError, match="participants must be configured before build"): + builder.build() + + def test_duplicate_manager_configuration_raises_error(self) -> None: + """Test that configuring multiple managers raises ValueError.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + builder = GroupChatBuilder().select_speakers(selector) + + with pytest.raises(ValueError, match="already has a manager configured"): + builder.select_speakers(selector) + + def test_empty_participants_raises_error(self) -> None: + """Test that empty participants list raises ValueError.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + builder = GroupChatBuilder().select_speakers(selector) + + with pytest.raises(ValueError, match="participants cannot be empty"): + builder.participants([]) + + def test_duplicate_participant_names_raises_error(self) -> None: + """Test that duplicate participant names raise ValueError.""" + agent1 = StubAgent("test", "response1") + agent2 = StubAgent("test", "response2") + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + builder = GroupChatBuilder().select_speakers(selector) + + with pytest.raises(ValueError, match="Duplicate participant name 'test'"): + builder.participants([agent1, agent2]) + + def test_agent_without_name_raises_error(self) -> None: + """Test that agent without name attribute raises ValueError.""" + + class AgentWithoutName(BaseAgent): + def __init__(self) -> None: + super().__init__(name="", description="test") + + async def run(self, messages: Any = None, *, thread: Any = None, **kwargs: Any) -> AgentRunResponse: + return AgentRunResponse(messages=[]) + + def run_stream( + self, messages: Any = None, *, thread: Any = None, **kwargs: Any + ) -> AsyncIterable[AgentRunResponseUpdate]: + async def _stream() -> AsyncIterable[AgentRunResponseUpdate]: + yield AgentRunResponseUpdate(contents=[]) + + return _stream() + + agent = AgentWithoutName() + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + builder = GroupChatBuilder().select_speakers(selector) + + with pytest.raises(ValueError, match="must define a non-empty 'name' attribute"): + builder.participants([agent]) + + def test_empty_participant_name_raises_error(self) -> None: + """Test that empty participant name raises ValueError.""" + agent = StubAgent("test", "response") + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + builder = GroupChatBuilder().select_speakers(selector) + + with pytest.raises(ValueError, match="participant names must be non-empty strings"): + builder.participants({"": agent}) + + +class TestGroupChatOrchestrator: + """Tests for GroupChatOrchestratorExecutor core functionality.""" + + async def test_max_rounds_enforcement(self) -> None: + """Test that max_rounds properly limits conversation rounds.""" + call_count = {"value": 0} + + def selector(state: GroupChatStateSnapshot) -> str | None: + call_count["value"] += 1 + # Always return the agent name to try to continue indefinitely + return "agent" + + agent = StubAgent("agent", "response") + + workflow = ( + GroupChatBuilder() + .select_speakers(selector) + .participants([agent]) + .with_max_rounds(2) # Limit to 2 rounds + .build() + ) + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + # Should have terminated due to max_rounds, expect at least one output + assert len(outputs) >= 1 + # The final message should be about round limit + final_output = outputs[-1] + assert "round limit" in final_output.text.lower() + + async def test_unknown_participant_error(self) -> None: + """Test that _apply_directive raises error for unknown participants.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return "unknown_agent" # Return non-existent participant + + agent = StubAgent("agent", "response") + + workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + + with pytest.raises(ValueError, match="Manager selected unknown participant 'unknown_agent'"): + async for _ in workflow.run_stream("test task"): + pass + + async def test_directive_without_agent_name_raises_error(self) -> None: + """Test that directive without agent_name raises error when finish=False.""" + + def bad_selector(state: GroupChatStateSnapshot) -> GroupChatDirective: + # Return a GroupChatDirective object instead of string to trigger error + return GroupChatDirective(finish=False, agent_name=None) # type: ignore + + agent = StubAgent("agent", "response") + + # The _SpeakerSelectorAdapter will catch this and raise TypeError + workflow = GroupChatBuilder().select_speakers(bad_selector).participants([agent]).build() # type: ignore + + # This should raise a TypeError because selector doesn't return str or None + with pytest.raises(TypeError, match="must return a participant name \\(str\\) or None"): + async for _ in workflow.run_stream("test"): + pass + + async def test_handle_empty_conversation_raises_error(self) -> None: + """Test that empty conversation list raises ValueError.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + agent = StubAgent("agent", "response") + + workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + + with pytest.raises(ValueError, match="requires at least one chat message"): + async for _ in workflow.run_stream([]): + pass + + async def test_unknown_participant_response_raises_error(self) -> None: + """Test that responses from unknown participants raise errors.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return "agent" + + # Create orchestrator to test _ingest_participant_message directly + orchestrator = GroupChatOrchestratorExecutor( + manager=selector, # type: ignore + participants={"agent": "test agent"}, + manager_name="test_manager", # type: ignore + ) + + # Mock the workflow context + class MockContext: + async def yield_output(self, message: ChatMessage) -> None: + pass + + ctx = MockContext() + + # Initialize orchestrator state + orchestrator._task_message = ChatMessage(role=Role.USER, text="test") # type: ignore + orchestrator._conversation = [orchestrator._task_message] # type: ignore + orchestrator._history = [] # type: ignore + orchestrator._pending_agent = None # type: ignore + orchestrator._round_index = 0 # type: ignore + + # Test with unknown participant + message = ChatMessage(role=Role.ASSISTANT, text="response") + + with pytest.raises(ValueError, match="Received response from unknown participant 'unknown'"): + await orchestrator._ingest_participant_message("unknown", message, ctx) # type: ignore + + async def test_state_build_before_initialization_raises_error(self) -> None: + """Test that _build_state raises error before task message initialization.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + return None + + orchestrator = GroupChatOrchestratorExecutor( + manager=selector, # type: ignore + participants={"agent": "test agent"}, + manager_name="test_manager", # type: ignore + ) + + with pytest.raises(RuntimeError, match="state not initialized with task message"): + orchestrator._build_state() # type: ignore + + +class TestSpeakerSelectorAdapter: + """Tests for _SpeakerSelectorAdapter functionality.""" + + async def test_selector_returning_list_with_multiple_items_raises_error(self) -> None: + """Test that selector returning list with multiple items raises error.""" + + def bad_selector(state: GroupChatStateSnapshot) -> list[str]: + return ["agent1", "agent2"] # Multiple items + + adapter = _SpeakerSelectorAdapter(bad_selector, manager_name="manager") + + state = { + "participants": {"agent1": "desc1", "agent2": "desc2"}, + "task": ChatMessage(role=Role.USER, text="test"), + "conversation": (), + "history": (), + "round_index": 0, + "pending_agent": None, + } + + with pytest.raises(ValueError, match="must return a single participant name"): + await adapter(state) + + async def test_selector_returning_non_string_raises_error(self) -> None: + """Test that selector returning non-string raises TypeError.""" + + def bad_selector(state: GroupChatStateSnapshot) -> int: + return 42 # Not a string + + adapter = _SpeakerSelectorAdapter(bad_selector, manager_name="manager") + + state = { + "participants": {"agent": "desc"}, + "task": ChatMessage(role=Role.USER, text="test"), + "conversation": (), + "history": (), + "round_index": 0, + "pending_agent": None, + } + + with pytest.raises(TypeError, match="must return a participant name \\(str\\) or None"): + await adapter(state) + + async def test_selector_returning_empty_list_finishes(self) -> None: + """Test that selector returning empty list finishes conversation.""" + + def empty_selector(state: GroupChatStateSnapshot) -> list[str]: + return [] # Empty list should finish + + adapter = _SpeakerSelectorAdapter(empty_selector, manager_name="manager") + + state = { + "participants": {"agent": "desc"}, + "task": ChatMessage(role=Role.USER, text="test"), + "conversation": (), + "history": (), + "round_index": 0, + "pending_agent": None, + } + + directive = await adapter(state) + assert directive.finish is True + assert directive.final_message is not None + + +class TestCheckpointing: + """Tests for checkpointing functionality.""" + + async def test_workflow_with_checkpointing(self) -> None: + """Test that workflow works with checkpointing enabled.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + if state["round_index"] >= 1: + return None + return "agent" + + agent = StubAgent("agent", "response") + storage = InMemoryCheckpointStorage() + + workflow = ( + GroupChatBuilder().select_speakers(selector).participants([agent]).with_checkpointing(storage).build() + ) + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream("test task"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + assert len(outputs) == 1 # Should complete normally + + +class TestPromptBasedManager: + """Tests for _PromptBasedGroupChatManager.""" + + async def test_manager_with_missing_next_agent_raises_error(self) -> None: + """Test that manager directive without next_agent raises RuntimeError.""" + + class MockChatClient: + async def get_response(self, messages: Any, response_format: Any = None) -> Any: + # Return response that has finish=False but no next_agent + class MockResponse: + def __init__(self) -> None: + self.value = {"finish": False, "next_agent": None} + self.messages: list[Any] = [] + + return MockResponse() + + manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore + + state = { + "participants": {"agent": "desc"}, + "task": ChatMessage(role=Role.USER, text="test"), + "conversation": (), + } + + with pytest.raises(RuntimeError, match="missing next_agent while finish is False"): + await manager(state) + + async def test_manager_with_unknown_participant_raises_error(self) -> None: + """Test that manager selecting unknown participant raises RuntimeError.""" + + class MockChatClient: + async def get_response(self, messages: Any, response_format: Any = None) -> Any: + # Return response selecting unknown participant + class MockResponse: + def __init__(self) -> None: + self.value = {"finish": False, "next_agent": "unknown"} + self.messages: list[Any] = [] + + return MockResponse() + + manager = _PromptBasedGroupChatManager(MockChatClient()) # type: ignore + + state = { + "participants": {"agent": "desc"}, + "task": ChatMessage(role=Role.USER, text="test"), + "conversation": (), + } + + with pytest.raises(RuntimeError, match="Manager selected unknown participant 'unknown'"): + await manager(state) + + +class TestFactoryFunctions: + """Tests for factory functions.""" + + def test_default_orchestrator_factory_without_manager_raises_error(self) -> None: + """Test that default factory requires manager to be set.""" + config = _GroupChatConfig(manager=None, manager_name="test", participants={}) + + with pytest.raises(RuntimeError, match="requires a manager to be set"): + _default_orchestrator_factory(config) + + +class TestConversationHandling: + """Tests for different conversation input types.""" + + async def test_handle_string_input(self) -> None: + """Test handling string input creates proper ChatMessage.""" + + def selector(state: GroupChatStateSnapshot) -> str | None: + # Verify the task was properly converted + assert state["task"].role == Role.USER + assert state["task"].text == "test string" + return None + + agent = StubAgent("agent", "response") + + workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream("test string"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + assert len(outputs) == 1 + + async def test_handle_chat_message_input(self) -> None: + """Test handling ChatMessage input directly.""" + task_message = ChatMessage(role=Role.USER, text="test message") + + def selector(state: GroupChatStateSnapshot) -> str | None: + # Verify the task message was preserved + assert state["task"] == task_message + return None + + agent = StubAgent("agent", "response") + + workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream(task_message): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + assert len(outputs) == 1 + + async def test_handle_conversation_list_input(self) -> None: + """Test handling conversation list preserves context.""" + conversation = [ + ChatMessage(role=Role.SYSTEM, text="system message"), + ChatMessage(role=Role.USER, text="user message"), + ] + + def selector(state: GroupChatStateSnapshot) -> str | None: + # Verify conversation context is preserved + assert len(state["conversation"]) == 2 + assert state["task"].text == "user message" + return None + + agent = StubAgent("agent", "response") + + workflow = GroupChatBuilder().select_speakers(selector).participants([agent]).build() + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream(conversation): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + assert len(outputs) == 1 + + +class TestRoundLimitEnforcement: + """Tests for round limit checking functionality.""" + + async def test_round_limit_in_apply_directive(self) -> None: + """Test round limit enforcement in _apply_directive.""" + rounds_called = {"count": 0} + + def selector(state: GroupChatStateSnapshot) -> str | None: + rounds_called["count"] += 1 + # Keep trying to select agent to test limit enforcement + return "agent" + + agent = StubAgent("agent", "response") + + workflow = ( + GroupChatBuilder() + .select_speakers(selector) + .participants([agent]) + .with_max_rounds(1) # Very low limit + .build() + ) + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream("test"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + # Should have at least one output (the round limit message) + assert len(outputs) >= 1 + # The last message should be about round limit + final_output = outputs[-1] + assert "round limit" in final_output.text.lower() + + async def test_round_limit_in_ingest_participant_message(self) -> None: + """Test round limit enforcement after participant response.""" + responses_received = {"count": 0} + + def selector(state: GroupChatStateSnapshot) -> str | None: + responses_received["count"] += 1 + if responses_received["count"] == 1: + return "agent" # First call selects agent + return "agent" # Try to continue, but should hit limit + + agent = StubAgent("agent", "response from agent") + + workflow = ( + GroupChatBuilder() + .select_speakers(selector) + .participants([agent]) + .with_max_rounds(1) # Hit limit after first response + .build() + ) + + outputs: list[ChatMessage] = [] + async for event in workflow.run_stream("test"): + if isinstance(event, WorkflowOutputEvent): + data = event.data + if isinstance(data, ChatMessage): + outputs.append(data) + + # Should have at least one output (the round limit message) + assert len(outputs) >= 1 + # The last message should be about round limit + final_output = outputs[-1] + assert "round limit" in final_output.text.lower() diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index ea8d7faead1..12d115ad40f 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -54,6 +54,7 @@ def __init__( extra_properties: dict[str, object] | None = None, ) -> None: super().__init__(id=name, name=name, display_name=name) + self._agent_name = name self.handoff_to = handoff_to self.calls: list[list[ChatMessage]] = [] self._text_handoff = text_handoff @@ -72,7 +73,7 @@ async def run( # type: ignore[override] additional_properties = _merge_additional_properties( self.handoff_to, self._text_handoff, self._extra_properties ) - contents = _build_reply_contents(self.name, self.handoff_to, self._text_handoff, self._next_call_id()) + contents = _build_reply_contents(self._agent_name, self.handoff_to, self._text_handoff, self._next_call_id()) reply = ChatMessage( role=Role.ASSISTANT, contents=contents, @@ -91,7 +92,7 @@ async def run_stream( # type: ignore[override] conversation = _normalise(messages) self.calls.append(conversation) additional_props = _merge_additional_properties(self.handoff_to, self._text_handoff, self._extra_properties) - contents = _build_reply_contents(self.name, self.handoff_to, self._text_handoff, self._next_call_id()) + contents = _build_reply_contents(self._agent_name, self.handoff_to, self._text_handoff, self._next_call_id()) yield AgentRunResponseUpdate( contents=contents, role=Role.ASSISTANT, @@ -357,3 +358,38 @@ async def test_multiple_runs_dont_leak_conversation(): assert not any("First run message" in msg.text for msg in second_run_user_messages if msg.text), ( "Second run should NOT contain first run's messages" ) + + +async def test_handoff_async_termination_condition() -> None: + """Test that async termination conditions work correctly.""" + termination_call_count = 0 + + async def async_termination(conv: list[ChatMessage]) -> bool: + nonlocal termination_call_count + termination_call_count += 1 + user_count = sum(1 for msg in conv if msg.role == Role.USER) + return user_count >= 2 + + coordinator = _RecordingAgent(name="coordinator") + + workflow = ( + HandoffBuilder(participants=[coordinator]) + .set_coordinator(coordinator) + .with_termination_condition(async_termination) + .build() + ) + + events = await _drain(workflow.run_stream("First user message")) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Second user message"})) + outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)] + assert len(outputs) == 1 + + final_conversation = outputs[0].data + assert isinstance(final_conversation, list) + final_conv_list = cast(list[ChatMessage], final_conversation) + user_messages = [msg for msg in final_conv_list if msg.role == Role.USER] + assert len(user_messages) == 2 + assert termination_call_count > 0