Python: [BREAKING] Python: Intro group chat and refactor orchestrations. Fix as_agent(). Standardize orchestration start msg types. (#1538)

* Intro group chat and refactor magentic. Fix as_agent()

* Cleanup and improvements

* Add as_agent docstring clarification

* Standardize orchestration messages to use agent-style inputs.

* Simplify group chat constructs

* Further cleanup

* Add sk to af group chat migration sample. Update README.

* Improvements and simplifications

* consolidating shared orchestration logic

* Further clean up

* Add group chat sample

* Improve typing

* Fix test imports

* Fix readme links

* Cleanup per PR Feedback
This commit is contained in:
Evan Mattson
2025-10-25 09:14:06 +09:00
committed by GitHub
Unverified
parent 899d8ff775
commit e3aad8e4e0
38 changed files with 5024 additions and 814 deletions
@@ -52,29 +52,29 @@ from ._executor import (
handler,
)
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 (
MagenticAgentDeltaEvent,
MagenticAgentExecutor,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticContext,
MagenticFinalResultEvent,
MagenticManagerBase,
MagenticOrchestratorExecutor,
MagenticOrchestratorMessageEvent,
MagenticPlanReviewDecision,
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
MagenticProgressLedger,
MagenticProgressLedgerItem,
MagenticRequestMessage,
MagenticResponseMessage,
MagenticStartMessage,
StandardMagenticManager,
)
from ._orchestration_state import OrchestrationState
from ._request_info_executor import (
PendingRequestDetails,
RequestInfoExecutor,
@@ -105,6 +105,8 @@ from ._workflow_context import WorkflowContext
from ._workflow_executor import WorkflowExecutor
__all__ = [
"DEFAULT_MANAGER_INSTRUCTIONS",
"DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT",
"DEFAULT_MAX_ITERATIONS",
"AgentExecutor",
"AgentExecutorRequest",
@@ -128,30 +130,26 @@ __all__ = [
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"GroupChatBuilder",
"GroupChatDirective",
"GroupChatStateSnapshot",
"HandoffBuilder",
"HandoffUserInputRequest",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"MagenticAgentDeltaEvent",
"MagenticAgentExecutor",
"MagenticAgentMessageEvent",
"MagenticBuilder",
"MagenticCallbackEvent",
"MagenticCallbackMode",
"MagenticContext",
"MagenticFinalResultEvent",
"MagenticManagerBase",
"MagenticOrchestratorExecutor",
"MagenticOrchestratorMessageEvent",
"MagenticPlanReviewDecision",
"MagenticPlanReviewReply",
"MagenticPlanReviewRequest",
"MagenticProgressLedger",
"MagenticProgressLedgerItem",
"MagenticRequestMessage",
"MagenticResponseMessage",
"MagenticStartMessage",
"ManagerDirectiveModel",
"Message",
"OrchestrationState",
"PendingRequestDetails",
"RequestInfoEvent",
"RequestInfoExecutor",
@@ -50,29 +50,28 @@ from ._executor import (
handler,
)
from ._function_executor import FunctionExecutor, executor
from ._group_chat import (
DEFAULT_MANAGER_INSTRUCTIONS,
DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT,
GroupChatBuilder,
GroupChatDirective,
GroupChatStateSnapshot,
)
from ._handoff import HandoffBuilder, HandoffUserInputRequest
from ._magentic import (
MagenticAgentDeltaEvent,
MagenticAgentExecutor,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticContext,
MagenticFinalResultEvent,
MagenticManagerBase,
MagenticOrchestratorExecutor,
MagenticOrchestratorMessageEvent,
MagenticPlanReviewDecision,
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
MagenticProgressLedger,
MagenticProgressLedgerItem,
MagenticRequestMessage,
MagenticResponseMessage,
MagenticStartMessage,
StandardMagenticManager,
)
from ._orchestration_state import OrchestrationState
from ._request_info_executor import (
PendingRequestDetails,
RequestInfoExecutor,
@@ -103,6 +102,8 @@ from ._workflow_context import WorkflowContext
from ._workflow_executor import WorkflowExecutor
__all__ = [
"DEFAULT_MANAGER_INSTRUCTIONS",
"DEFAULT_MANAGER_STRUCTURED_OUTPUT_PROMPT",
"DEFAULT_MAX_ITERATIONS",
"AgentExecutor",
"AgentExecutorRequest",
@@ -126,30 +127,25 @@ __all__ = [
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"GroupChatBuilder",
"GroupChatDirective",
"GroupChatStateSnapshot",
"HandoffBuilder",
"HandoffUserInputRequest",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"MagenticAgentDeltaEvent",
"MagenticAgentExecutor",
"MagenticAgentMessageEvent",
"MagenticBuilder",
"MagenticCallbackEvent",
"MagenticCallbackMode",
"MagenticContext",
"MagenticFinalResultEvent",
"MagenticManagerBase",
"MagenticOrchestratorExecutor",
"MagenticOrchestratorMessageEvent",
"MagenticPlanReviewDecision",
"MagenticPlanReviewReply",
"MagenticPlanReviewRequest",
"MagenticProgressLedger",
"MagenticProgressLedgerItem",
"MagenticRequestMessage",
"MagenticResponseMessage",
"MagenticStartMessage",
"Message",
"OrchestrationState",
"PendingRequestDetails",
"RequestInfoEvent",
"RequestInfoExecutor",
@@ -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
@@ -19,7 +19,6 @@ from agent_framework import (
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UsageDetails,
)
@@ -29,6 +28,7 @@ from ._events import (
RequestInfoEvent,
WorkflowEvent,
)
from ._message_utils import normalize_messages_input
if TYPE_CHECKING:
from ._workflow import Workflow
@@ -131,7 +131,7 @@ class WorkflowAgent(BaseAgent):
"""
# 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())
@@ -165,7 +165,7 @@ class WorkflowAgent(BaseAgent):
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())
@@ -225,28 +225,6 @@ class WorkflowAgent(BaseAgent):
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,
@@ -12,6 +12,7 @@ from ._events import (
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 @@ class AgentExecutor(Executor):
@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,50 @@ class AgentExecutor(Executor):
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)
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()
@@ -0,0 +1,265 @@
# Copyright (c) Microsoft. All rights reserved.
"""Base class for group chat orchestrators that manages conversation flow and participant selection."""
import inspect
import logging
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable, Sequence
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()
# Shared conversation state management
self._conversation: list[ChatMessage] = []
self._round_index: int = 0
self._max_rounds: int | 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.
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)
# Conversation state management (shared across all patterns)
def _append_messages(self, messages: Sequence[ChatMessage]) -> None:
"""Append messages to the conversation history.
Args:
messages: Messages to append
"""
self._conversation.extend(messages)
def _get_conversation(self) -> list[ChatMessage]:
"""Get a copy of the current conversation.
Returns:
Cloned conversation list
"""
return list(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
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,
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
"""
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)
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)
# Round limit enforcement (shared across all patterns)
def _check_round_limit(self) -> bool:
"""Check if round limit has been reached.
Uses instance variables _round_index and _max_rounds.
Returns:
True if limit reached, False otherwise
"""
if self._max_rounds is None:
return False
if self._round_index >= self._max_rounds:
logger.warning(
"%s reached max_rounds=%s; forcing completion.",
self.__class__.__name__,
self._max_rounds,
)
return True
return False
# 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.
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.
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
@@ -13,6 +13,7 @@ from agent_framework import AgentProtocol, ChatMessage, Role
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
from ._workflow_builder import WorkflowBuilder
from ._workflow_context import WorkflowContext
@@ -50,17 +51,21 @@ class _DispatchToAllParticipants(Executor):
@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)
@@ -0,0 +1,50 @@
# 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 Mapping, Sequence
from typing import Any
from .._types import ChatMessage
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
raise ValueError("No user message in conversation")
def ensure_author(message: ChatMessage, fallback: str) -> ChatMessage:
"""Attach `fallback` author if message is missing `author_name`."""
message.author_name = message.author_name or fallback
return message
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}
@@ -450,13 +450,7 @@ ContextT = TypeVar("ContextT", bound="WorkflowContext[Any, 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:
File diff suppressed because it is too large Load Diff
@@ -35,9 +35,16 @@ from agent_framework import (
from .._agents import ChatAgent
from .._middleware import FunctionInvocationContext, FunctionMiddleware
from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator
from ._checkpoint import CheckpointStorage
from ._conversation_state import decode_chat_messages, encode_chat_messages
from ._executor import Executor, handler
from ._group_chat import (
_default_participant_factory, # type: ignore[reportPrivateUsage]
_GroupChatConfig, # type: ignore[reportPrivateUsage]
assemble_group_chat_workflow,
)
from ._orchestrator_helpers import clean_conversation_for_handoff
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
@@ -49,19 +56,9 @@ logger = logging.getLogger(__name__)
_HANDOFF_TOOL_PATTERN = re.compile(r"(?:handoff|transfer)[_\s-]*to[_\s-]*(?P<target>[\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 +254,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__(
@@ -266,7 +263,7 @@ class _HandoffCoordinator(Executor):
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:
@@ -277,9 +274,12 @@ class _HandoffCoordinator(Executor):
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()}
def _get_author_name(self) -> str:
"""Get the coordinator name for orchestrator-generated messages."""
return "handoff_coordinator"
@handler
async def handle_agent_response(
self,
@@ -290,38 +290,39 @@ class _HandoffCoordinator(Executor):
# 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 = restored
self._conversation = list(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 = list(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 = list(response.agent_run_response.messages)
self._full_conversation.extend(new_messages)
new_messages = response.agent_run_response.messages or []
self._conversation.extend(new_messages)
self._apply_response_metadata(self._full_conversation, response.agent_run_response)
self._apply_response_metadata(self._conversation, response.agent_run_response)
conversation = list(self._full_conversation)
conversation = list(self._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
@@ -332,7 +333,7 @@ class _HandoffCoordinator(Executor):
await self._persist_state(ctx)
if self._termination_condition(conversation):
if await self._check_termination():
logger.info("Handoff workflow termination condition met. Ending conversation.")
await ctx.yield_output(list(conversation))
return
@@ -346,18 +347,18 @@ class _HandoffCoordinator(Executor):
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 = list(message.full_conversation)
# Update authoritative conversation
self._conversation = list(message.full_conversation)
await self._persist_state(ctx)
# Check termination before sending to agent
if self._termination_condition(self._full_conversation):
if await 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 = self._get_cleaned_conversation(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,8 +410,8 @@ class _HandoffCoordinator(Executor):
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)
conversation.extend((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."""
@@ -421,78 +422,41 @@ class _HandoffCoordinator(Executor):
)
return list(conversation)
def _get_cleaned_conversation(self, conversation: list[ChatMessage]) -> list[ChatMessage]:
"""Create a cleaned copy of conversation with tool-related content removed.
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.
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
To avoid these errors, we remove ALL tool-related content from the conversation:
- FunctionApprovalRequestContent and FunctionCallContent from assistant messages
- Tool response messages (Role.TOOL)
This follows the pattern from OpenAI Agents SDK's `remove_all_tools` filter, which strips
all tool-related content from conversation history during handoffs.
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
Preserves:
- User messages
- Assistant messages with text content (tool calls are stripped out)
"""
# 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
)
# 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)
return cleaned
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)}
state_payload = self.snapshot_state()
await ctx.set_executor_state(state_payload)
def _snapshot_pattern_metadata(self) -> dict[str, Any]:
"""Serialize pattern-specific state.
Handoff has no additional metadata beyond base conversation state.
Returns:
Empty dict (no pattern-specific state)
"""
return {}
def _restore_pattern_metadata(self, metadata: dict[str, Any]) -> None:
"""Restore pattern-specific state.
Handoff has no additional metadata beyond base conversation state.
Args:
metadata: Pattern-specific state dict (ignored)
"""
pass
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]
"""Rehydrate the coordinator's conversation history from checkpointed state.
DEPRECATED: Use restore_state() instead. Kept for backward compatibility.
"""
from ._orchestration_state import OrchestrationState
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."""
@@ -766,7 +730,10 @@ class HandoffBuilder:
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]
@@ -814,36 +781,41 @@ class HandoffBuilder:
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 +995,7 @@ class HandoffBuilder:
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)
@@ -1184,12 +1156,16 @@ class HandoffBuilder:
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.
@@ -1198,9 +1174,19 @@ class HandoffBuilder:
.. 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
@@ -1308,6 +1294,14 @@ class HandoffBuilder:
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,48 +1310,50 @@ class HandoffBuilder:
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,
specialist_aliases = {alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists}
def _handoff_orchestrator_factory(_: _GroupChatConfig) -> 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,
)
wiring = _GroupChatConfig(
manager=None,
manager_name=self._starting_agent_id,
participants=participant_specs,
max_rounds=None,
participant_aliases=self._aliases,
participant_executors=self._executors,
)
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)
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
for specialist in specialists.values():
builder.add_edge(coordinator, specialist)
builder.add_edge(specialist, coordinator)
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
if self._checkpoint_storage is not None:
builder = builder.with_checkpointing(self._checkpoint_storage)
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)
return builder.build()
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__}.")
def _resolve_to_id(self, candidate: str | AgentProtocol | Executor) -> str:
"""Resolve a participant reference into a concrete executor identifier."""
if isinstance(candidate, Executor):
File diff suppressed because it is too large Load Diff
@@ -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"]
@@ -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 @@ class DictConvertible:
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:
@@ -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,
)
@@ -0,0 +1,190 @@
# 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
if TYPE_CHECKING:
from ._group_chat import _GroupChatRequestMessage # type: ignore[reportPrivateUsage]
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 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 # type: ignore[reportPrivateUsage]
return _GroupChatRequestMessage(
agent_name=participant_name,
conversation=list(conversation),
instruction=instruction or "",
task=task,
metadata=metadata,
)
class ParticipantRegistry:
"""Simple registry for tracking participant executor IDs and routing info.
Provides a clean interface for the common pattern of mapping participant names
to executor IDs and tracking which are agents vs custom executors.
"""
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())
@@ -0,0 +1,136 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared participant helpers for orchestration builders."""
import re
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from .._agents import AgentProtocol
from ._agent_executor import AgentExecutor
from ._executor import Executor
@dataclass
class GroupChatParticipantSpec:
"""Metadata describing a single participant in group chat orchestrations.
Used by multiple orchestration patterns (GroupChat, Handoff, Magentic) to describe
participants with consistent structure across different workflow types.
Attributes:
name: Unique identifier for the participant used by managers for selection
participant: AgentProtocol or Executor instance representing the participant
description: Human-readable description provided to managers for selection context
"""
name: str
participant: AgentProtocol | Executor
description: str
_SANITIZE_PATTERN = re.compile(r"[^0-9a-zA-Z]+")
def sanitize_identifier(value: str, *, default: str = "agent") -> str:
"""Return a deterministic, lowercase identifier derived from `value`."""
cleaned = _SANITIZE_PATTERN.sub("_", value).strip("_")
if not cleaned:
cleaned = default
if cleaned[0].isdigit():
cleaned = f"{default}_{cleaned}"
return cleaned.lower()
def wrap_participant(participant: AgentProtocol | Executor, *, executor_id: str | None = None) -> Executor:
"""Represent `participant` as an `Executor`."""
if isinstance(participant, Executor):
return participant
if not isinstance(participant, AgentProtocol):
raise TypeError(
f"Participants must implement AgentProtocol or be Executor instances. Got {type(participant).__name__}."
)
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,
}
@@ -40,7 +40,7 @@ import logging
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 @@ from ._executor import (
Executor,
handler,
)
from ._message_utils import normalize_messages_input
from ._workflow import Workflow
from ._workflow_builder import WorkflowBuilder
from ._workflow_context import WorkflowContext
@@ -63,16 +64,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):
@@ -4,56 +4,72 @@ import logging
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)
value_as_dict = cast(dict[str, Any], value_dict)
else:
return None
else:
value_as_dict = cast(dict[str, Any], value)
if isinstance(value, dict):
ctor_kwargs: dict[str, Any] = dict(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.items() if k in field_names}
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}"
)
# 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
@@ -838,11 +838,24 @@ class Workflow(DictConvertible):
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
@@ -21,7 +21,7 @@ from ._events import (
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowWarningEvent,
_framework_event_origin,
_framework_event_origin, # type: ignore
)
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
@@ -0,0 +1,744 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, Callable
from typing import Any
import pytest
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
BaseAgent,
ChatMessage,
GroupChatBuilder,
GroupChatDirective,
GroupChatStateSnapshot,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticContext,
MagenticManagerBase,
MagenticOrchestratorMessageEvent,
Role,
TextContent,
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
_MagenticStartMessage, # type: ignore
)
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()
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):
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:
selector = make_sequence_selector()
alpha = StubAgent("alpha", "ack from alpha")
beta = StubAgent("beta", "ack from beta")
workflow = (
GroupChatBuilder()
.select_speakers(selector, display_name="manager", final_message="done")
.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:
selector = make_sequence_selector()
alpha = StubAgent("alpha", "ack from alpha")
beta = StubAgent("beta", "ack from beta")
workflow = (
GroupChatBuilder()
.select_speakers(selector, display_name="manager", final_message="done")
.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)
# 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()
@@ -54,6 +54,7 @@ class _RecordingAgent(BaseAgent):
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 @@ class _RecordingAgent(BaseAgent):
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 @@ class _RecordingAgent(BaseAgent):
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
@@ -2,7 +2,7 @@
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
import pytest
@@ -15,13 +15,12 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Executor,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticManagerBase,
MagenticPlanReviewDecision,
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
MagenticProgressLedger,
MagenticProgressLedgerItem,
RequestInfoEvent,
Role,
TextContent,
@@ -34,17 +33,19 @@ from agent_framework import (
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"
@@ -114,8 +115,9 @@ class FakeManager(MagenticManagerBase):
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)
@@ -138,14 +140,14 @@ class FakeManager(MagenticManagerBase):
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:
@@ -175,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
@@ -328,13 +330,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},
):
@@ -346,8 +346,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):
@@ -472,24 +472,24 @@ class InvokeOnceManager(MagenticManagerBase):
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:
@@ -533,17 +533,10 @@ class StubAssistantsAgent(BaseAgent):
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 +544,10 @@ async def _collect_agent_responses_setup(participant_obj: object):
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
@@ -559,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)
@@ -685,7 +682,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)},
@@ -704,13 +701,13 @@ class NotProgressingManager(MagenticManagerBase):
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:
@@ -6,7 +6,10 @@ import inspect
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:
+7 -4
View File
@@ -288,9 +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/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 |
@@ -321,4 +325,3 @@ For information on creating new samples, see [SAMPLE_GUIDELINES.md](./SAMPLE_GUI
## More Information
- [Python Package Documentation](../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,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 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 |
@@ -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())
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from agent_framework import ChatAgent, GroupChatBuilder
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(),
)
workflow = (
GroupChatBuilder()
.set_prompt_based_manager(chat_client=OpenAIChatClient(), 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())
@@ -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())
@@ -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:<participant>"),
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())
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from agent_framework import AgentRunUpdateEvent, ChatAgent, GroupChatBuilder, 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(),
)
workflow = (
GroupChatBuilder()
.set_prompt_based_manager(chat_client=OpenAIChatClient(), 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
last_executor_id: str | None = None
async for event in workflow.run_stream(task):
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:
print("=" * 60)
print("FINAL RESPONSE")
print("=" * 60)
print(final_response)
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -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())
@@ -9,8 +9,6 @@ from agent_framework import (
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 main() -> 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 main() -> 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}")
@@ -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 @@ async def main() -> None:
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={},
):
@@ -10,8 +10,6 @@ from agent_framework import (
MagenticAgentDeltaEvent,
MagenticAgentMessageEvent,
MagenticBuilder,
MagenticCallbackEvent,
MagenticCallbackMode,
MagenticFinalResultEvent,
MagenticOrchestratorMessageEvent,
MagenticPlanReviewDecision,
@@ -77,43 +75,11 @@ async def main() -> 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 main() -> 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 main() -> 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 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
@@ -1,10 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
# Semantic Kernel → Microsoft Agent Framework Migration Samples
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.
## Whats Included
## Whats Included
### 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.
@@ -32,7 +33,8 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent F
### Orchestrations
- [sequential.py](orchestrations/sequential.py) — Step-by-step SK Team → AF `SequentialBuilder` migration.
- [concurrent_basic.py](orchestrations/concurrent_basic.py) — Concurrent orchestration parity.
- [handoff.py](orchestrations/handoff.py) — Support triage handoff migration with specialist routing.
- [group_chat.py](orchestrations/group_chat.py) — Group chat coordination with an LLM-backed manager in both SDKs.
- [handoff.py](orchestrations/handoff.py) - Handoff coordination between agents.
- [magentic.py](orchestrations/magentic.py) — Magentic Team orchestration vs. AF builder wiring.
### Processes
@@ -55,7 +57,7 @@ python samples/semantic-kernel-migration/chat_completion/01_basic_chat_completio
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 `samples/semantic-kernel-migration/orchestrations` (Sequential, Concurrent, Group Chat, Handoff, 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:
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:
```
cd samples/semantic-kernel-migration
uv venv --python 3.10 .venv-migration
@@ -0,0 +1,266 @@
# Copyright (c) Microsoft. All rights reserved.
"""Side-by-side group chat orchestrations for Agent Framework and Semantic Kernel."""
import asyncio
import sys
from collections.abc import Sequence
from typing import Any, cast
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
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),
)
workflow = (
GroupChatBuilder()
.set_prompt_based_manager(
chat_client=AzureOpenAIChatClient(credential=credential),
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())
@@ -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 @@ from semantic_kernel.contents import (
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."