From 8fbc4a19b3e6eadc785a1deb2fc5a0c3cb292a92 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 15 Oct 2025 10:29:48 +0900 Subject: [PATCH 01/13] Add Handoff orchestration pattern support --- .../agent_framework/_workflows/__init__.py | 3 + .../agent_framework/_workflows/__init__.pyi | 3 + .../_workflows/_conversation_state.py | 77 ++ .../agent_framework/_workflows/_handoff.py | 1074 +++++++++++++++++ .../core/tests/workflow/test_handoff.py | 332 +++++ .../getting_started/workflows/README.md | 8 + .../workflows/orchestration/handoff_agents.py | 336 ++++++ .../handoff_with_context_window.py | 241 ++++ .../handoff_with_custom_resolver.py | 311 +++++ python/uv.lock | 2 +- 10 files changed, 2386 insertions(+), 1 deletion(-) create mode 100644 python/packages/core/agent_framework/_workflows/_conversation_state.py create mode 100644 python/packages/core/agent_framework/_workflows/_handoff.py create mode 100644 python/packages/core/tests/workflow/test_handoff.py create mode 100644 python/samples/getting_started/workflows/orchestration/handoff_agents.py create mode 100644 python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py create mode 100644 python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 94950e1948..e470fd44ed 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -52,6 +52,7 @@ handler, ) from ._function_executor import FunctionExecutor, executor +from ._handoff import HandoffBuilder, HandoffUserInputRequest from ._magentic import ( MagenticAgentDeltaEvent, MagenticAgentExecutor, @@ -126,6 +127,8 @@ "FileCheckpointStorage", "FunctionExecutor", "GraphConnectivityError", + "HandoffBuilder", + "HandoffUserInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index d98829c56d..9e8061ab85 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -50,6 +50,7 @@ from ._executor import ( handler, ) from ._function_executor import FunctionExecutor, executor +from ._handoff import HandoffBuilder, HandoffUserInputRequest from ._magentic import ( MagenticAgentDeltaEvent, MagenticAgentExecutor, @@ -124,6 +125,8 @@ __all__ = [ "FileCheckpointStorage", "FunctionExecutor", "GraphConnectivityError", + "HandoffBuilder", + "HandoffUserInputRequest", "InMemoryCheckpointStorage", "InProcRunnerContext", "MagenticAgentDeltaEvent", diff --git a/python/packages/core/agent_framework/_workflows/_conversation_state.py b/python/packages/core/agent_framework/_workflows/_conversation_state.py new file mode 100644 index 0000000000..f5f607c5d8 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_conversation_state.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import Iterable +from typing import Any, cast + +from agent_framework import ChatMessage, Role + +from ._runner_context import _decode_checkpoint_value, _encode_checkpoint_value # type: ignore + +"""Utilities for serializing and deserializing chat conversations for persistence. + +These helpers convert rich `ChatMessage` instances to checkpoint-friendly payloads +using the same encoding primitives as the workflow runner. This preserves +`additional_properties` and other metadata without relying on unsafe mechanisms +such as pickling. +""" + + +def encode_chat_messages(messages: Iterable[ChatMessage]) -> list[dict[str, Any]]: + """Serialize chat messages into checkpoint-safe payloads.""" + encoded: list[dict[str, Any]] = [] + for message in messages: + encoded.append({ + "role": _encode_checkpoint_value(message.role), + "contents": [_encode_checkpoint_value(content) for content in message.contents], + "author_name": message.author_name, + "message_id": message.message_id, + "additional_properties": { + key: _encode_checkpoint_value(value) for key, value in message.additional_properties.items() + }, + }) + return encoded + + +def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[ChatMessage]: + """Restore chat messages from checkpoint-safe payloads.""" + restored: list[ChatMessage] = [] + for item in payload: + if not isinstance(item, dict): + continue + + role_value = _decode_checkpoint_value(item.get("role")) + if isinstance(role_value, Role): + role = role_value + elif isinstance(role_value, dict): + role_dict = cast(dict[str, Any], role_value) + role = Role.from_dict(role_dict) + elif isinstance(role_value, str): + role = Role(value=role_value) + else: + role = Role.ASSISTANT + + contents_field = item.get("contents", []) + contents: list[Any] = [] + if isinstance(contents_field, list): + contents_iter: list[Any] = contents_field # type: ignore[assignment] + for entry in contents_iter: + decoded_entry: Any = _decode_checkpoint_value(entry) + contents.append(decoded_entry) + + additional_field = item.get("additional_properties", {}) + additional: dict[str, Any] = {} + if isinstance(additional_field, dict): + additional_dict = cast(dict[str, Any], additional_field) + for key, value in additional_dict.items(): + additional[key] = _decode_checkpoint_value(value) + + restored.append( + ChatMessage( + role=role, + contents=contents, + author_name=item.get("author_name"), + message_id=item.get("message_id"), + additional_properties=additional, + ) + ) + return restored diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py new file mode 100644 index 0000000000..7319e4f85a --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -0,0 +1,1074 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""High-level builder for conversational handoff workflows. + +The handoff pattern models a triage/dispatcher agent that optionally routes +control to specialist agents before handing the conversation back to the user. +The flow is intentionally cyclical: + + user input -> starting agent -> optional specialist -> request user input -> ... + +Key properties: +- The entire conversation is maintained by default and reused on every hop +- Developers can opt into a rolling context window (last N messages) +- The starting agent determines whether to hand off by emitting metadata +- After a specialist responds, the workflow immediately requests new user input +""" + +import logging +import re +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any + +from agent_framework import AgentProtocol, AgentRunResponse, ChatMessage, Role + +from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse +from ._checkpoint import CheckpointStorage +from ._conversation_state import decode_chat_messages, encode_chat_messages +from ._executor import Executor, handler +from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse +from ._workflow import Workflow, WorkflowBuilder +from ._workflow_context import WorkflowContext + +logger = logging.getLogger(__name__) + + +_HANDOFF_HINT_KEYS = ("handoff_to", "handoff", "transfer_to", "agent_id", "agent") +_HANDOFF_TEXT_PATTERN = re.compile(r"handoff[_\s-]*to\s*:?\s*(?P[\w-]+)", re.IGNORECASE) + + +@dataclass +class HandoffUserInputRequest(RequestInfoMessage): + """Request message emitted when the workflow needs fresh user input.""" + + conversation: list[ChatMessage] = field(default_factory=list) + awaiting_agent_id: str | None = None + prompt: str | None = None + + +@dataclass +class _ConversationWithUserInput: + """Internal message carrying full conversation + new user messages from gateway to coordinator.""" + + full_conversation: list[ChatMessage] = field(default_factory=list) + + +class _InputToConversation(Executor): + """Normalises initial workflow input into a list[ChatMessage].""" + + @handler + async def from_str(self, prompt: str, ctx: WorkflowContext[list[ChatMessage]]) -> None: + await ctx.send_message([ChatMessage(Role.USER, text=prompt)]) + + @handler + async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined] + await ctx.send_message([message]) + + @handler + async def from_messages( + self, + messages: list[ChatMessage], + ctx: WorkflowContext[list[ChatMessage]], + ) -> None: # type: ignore[name-defined] + await ctx.send_message(list(messages)) + + +def _default_handoff_resolver(response: AgentExecutorResponse) -> str | None: + """Extract a target specialist identifier from an agent response.""" + agent_response = response.agent_run_response + + # Structured value + value = agent_response.value + candidate = _extract_handoff_candidate(value) + if candidate: + return candidate + + # Additional properties on the response payload + props = agent_response.additional_properties or {} + candidate = _extract_from_mapping(props) + if candidate: + return candidate + + # Inspect most recent assistant message metadata + for msg in reversed(agent_response.messages): + props = getattr(msg, "additional_properties", {}) or {} + candidate = _extract_from_mapping(props) + if candidate: + return candidate + text = msg.text or "" + match = _HANDOFF_TEXT_PATTERN.search(text) + if match: + parsed = match.group("target").strip() + if parsed: + return parsed + + return None + + +def _extract_handoff_candidate(candidate: Any) -> str | None: + if candidate is None: + return None + if isinstance(candidate, str): + return candidate.strip() or None + if isinstance(candidate, Mapping): + return _extract_from_mapping(candidate) + attr = getattr(candidate, "handoff_to", None) + if isinstance(attr, str) and attr.strip(): + return attr.strip() + attr = getattr(candidate, "agent_id", None) + if isinstance(attr, str) and attr.strip(): + return attr.strip() + return None + + +def _extract_from_mapping(mapping: Mapping[str, Any]) -> str | None: + for key in _HANDOFF_HINT_KEYS: + value = mapping.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +class _HandoffCoordinator(Executor): + """Coordinates agent-to-agent transfers and user turn requests.""" + + def __init__( + self, + *, + starting_agent_id: str, + specialist_ids: Mapping[str, str], + input_gateway_id: str, + context_window: int | None, + resolver: Callable[[AgentExecutorResponse], str | AgentProtocol | Executor | None], + termination_condition: Callable[[list[ChatMessage]], bool], + id: str, + ) -> None: + super().__init__(id) + self._starting_agent_id = starting_agent_id + self._specialist_by_alias = dict(specialist_ids) + self._specialist_ids = set(specialist_ids.values()) + self._input_gateway_id = input_gateway_id + self._context_window = context_window + self._resolver = resolver + self._termination_condition = termination_condition + self._full_conversation: list[ChatMessage] = [] + + @handler + async def handle_agent_response( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage]], + ) -> None: + # Hydrate coordinator state (and detect new run) using checkpointable executor state + state = await ctx.get_state() + if not state: + self._full_conversation = [] + elif not self._full_conversation: + restored = self._restore_conversation_from_state(state) + if restored: + self._full_conversation = restored + + source = ctx.get_source_executor_id() + is_starting_agent = source == self._starting_agent_id + + # On first turn of a run, full_conversation is empty + # On subsequent turns with context window, response.full_conversation may be trimmed + # Solution: Track new messages only, build authoritative history incrementally + if not self._full_conversation: + # First response from starting agent - initialize with authoritative conversation snapshot + self._full_conversation = self._conversation_from_response(response) + else: + # Subsequent responses - append only new messages from this agent + new_messages = list(response.agent_run_response.messages) + self._full_conversation.extend(new_messages) + + self._apply_response_metadata(self._full_conversation, response.agent_run_response) + + conversation = list(self._full_conversation) + await self._persist_state(ctx) + + if is_starting_agent: + target = self._resolve_specialist(response) + if target is not None: + trimmed = self._trim(conversation) + request = AgentExecutorRequest(messages=trimmed, should_respond=True) + await ctx.send_message(request, target_id=target) + return + + # Check termination condition before requesting more user input + if self._termination_condition(conversation): + logger.info("Handoff workflow termination condition met. Ending conversation.") + await ctx.yield_output(list(conversation)) + return + + await ctx.send_message(list(conversation), target_id=self._input_gateway_id) + return + + if source not in self._specialist_ids: + raise RuntimeError(f"HandoffCoordinator received response from unknown executor '{source}'.") + + # Check termination condition after specialist response + if self._termination_condition(conversation): + logger.info("Handoff workflow termination condition met. Ending conversation.") + await ctx.yield_output(list(conversation)) + return + + await ctx.send_message(list(conversation), target_id=self._input_gateway_id) + + @handler + async def handle_user_input( + self, + message: _ConversationWithUserInput, + 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) + await self._persist_state(ctx) + + # Check termination before sending to agent + if self._termination_condition(self._full_conversation): + logger.info("Handoff workflow termination condition met. Ending conversation.") + await ctx.yield_output(list(self._full_conversation)) + return + + # Trim and send to starting agent + trimmed = self._trim(self._full_conversation) + request = AgentExecutorRequest(messages=trimmed, should_respond=True) + await ctx.send_message(request, target_id=self._starting_agent_id) + + def _resolve_specialist(self, response: AgentExecutorResponse) -> str | None: + try: + resolved = self._resolver(response) + except Exception as exc: # pragma: no cover - defensive guard + logger.exception("handoff resolver raised %s", exc) + return None + + if resolved is None: + return None + + resolved_id: str | None + if isinstance(resolved, Executor): + resolved_id = resolved.id + elif isinstance(resolved, AgentProtocol): + name = getattr(resolved, "name", None) + if name is None: + raise ValueError("Resolver returned AgentProtocol without a name; cannot map to executor id.") + resolved_id = self._specialist_by_alias.get(name) or name + elif isinstance(resolved, str): + resolved_id = self._specialist_by_alias.get(resolved) + if resolved_id is None: + lowered = resolved.lower() + for alias, exec_id in self._specialist_by_alias.items(): + if alias.lower() == lowered: + resolved_id = exec_id + break + if resolved_id is None: + resolved_id = resolved + else: + raise TypeError( + f"Resolver must return Executor, AgentProtocol, str, or None. Got {type(resolved).__name__}." + ) + + if resolved_id not in self._specialist_ids: + logger.warning("Resolver selected '%s' which is not a registered specialist.", resolved_id) + return None + return resolved_id + + def _conversation_from_response(self, response: AgentExecutorResponse) -> list[ChatMessage]: + conversation = response.full_conversation + if conversation is None: + raise RuntimeError( + "AgentExecutorResponse.full_conversation missing; AgentExecutor must populate it in handoff workflows." + ) + return list(conversation) + + def _trim(self, conversation: list[ChatMessage]) -> list[ChatMessage]: + if self._context_window is None: + return list(conversation) + return list(conversation[-self._context_window :]) + + async def _persist_state(self, ctx: WorkflowContext[Any, Any]) -> None: + """Store authoritative conversation snapshot without losing rich metadata.""" + state_payload = {"full_conversation": encode_chat_messages(self._full_conversation)} + await ctx.set_state(state_payload) + + def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[ChatMessage]: + raw_conv = state.get("full_conversation") + if not isinstance(raw_conv, list): + return [] + return decode_chat_messages(raw_conv) + + def _apply_response_metadata(self, conversation: list[ChatMessage], agent_response: AgentRunResponse) -> None: + if not agent_response.additional_properties: + return + + # Find the most recent assistant message contributed by this response + for message in reversed(conversation): + if message.role == Role.ASSISTANT: + metadata = agent_response.additional_properties or {} + if not metadata: + return + # Merge metadata without mutating shared dict from agent response + merged = dict(message.additional_properties or {}) + for key, value in metadata.items(): + merged.setdefault(key, value) + message.additional_properties = merged + break + + +class _UserInputGateway(Executor): + """Bridges conversation context with RequestInfoExecutor and re-enters the loop.""" + + def __init__( + self, + *, + request_executor_id: str, + starting_agent_id: str, + prompt: str | None, + id: str, + ) -> None: + super().__init__(id) + self._request_executor_id = request_executor_id + self._starting_agent_id = starting_agent_id + self._prompt = prompt or "Provide your next input for the conversation." + + @handler + async def request_input( + self, + conversation: list[ChatMessage], + ctx: WorkflowContext[HandoffUserInputRequest], + ) -> None: + if not conversation: + raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.") + request = HandoffUserInputRequest( + conversation=list(conversation), + awaiting_agent_id=self._starting_agent_id, + prompt=self._prompt, + ) + request.source_executor_id = self.id + await ctx.send_message(request, target_id=self._request_executor_id) + + @handler + async def resume_from_user( + self, + response: RequestResponse[HandoffUserInputRequest, Any], + ctx: WorkflowContext[_ConversationWithUserInput], + ) -> None: + # Reconstruct full conversation with new user input + conversation = list(response.original_request.conversation) + user_messages = _as_user_messages(response.data) + conversation.extend(user_messages) + + # Send full conversation back to coordinator (not trimmed) + # Coordinator will update its authoritative history and trim for agent + message = _ConversationWithUserInput(full_conversation=conversation) + # CRITICAL: Must specify target to avoid broadcasting to all connected executors + # Gateway is connected to both request_info and coordinator, we want coordinator only + await ctx.send_message(message, target_id="handoff-coordinator") + + +def _as_user_messages(payload: Any) -> list[ChatMessage]: + if isinstance(payload, ChatMessage): + if payload.role == Role.USER: + return [payload] + return [ChatMessage(Role.USER, text=payload.text)] + if isinstance(payload, list) and all(isinstance(msg, ChatMessage) for msg in payload): + return [msg if msg.role == Role.USER else ChatMessage(Role.USER, text=msg.text) for msg in payload] + if isinstance(payload, Mapping): # User supplied structured data + text = payload.get("text") or payload.get("content") + if isinstance(text, str) and text.strip(): + return [ChatMessage(Role.USER, text=text.strip())] + return [ChatMessage(Role.USER, text=str(payload))] + + +def _default_termination_condition(conversation: list[ChatMessage]) -> bool: + """Default termination: stop after 10 user messages to prevent infinite loops.""" + user_message_count = sum(1 for msg in conversation if msg.role == Role.USER) + return user_message_count >= 10 + + +class HandoffBuilder: + r"""Fluent builder for conversational handoff workflows with triage and specialist agents. + + The handoff pattern models a customer support or multi-agent conversation where: + - A **triage/dispatcher agent** receives user input and decides whether to handle it directly + or hand off to a **specialist agent**. + - After a specialist responds, control returns to the user for more input, creating a cyclical flow: + user -> triage -> [optional specialist] -> user -> triage -> ... + - The workflow automatically requests user input after each agent response, maintaining conversation continuity. + - A **termination condition** determines when the workflow should stop requesting input and complete. + + Key Features: + - **Automatic handoff detection**: The triage agent includes "HANDOFF_TO: " in its response + to trigger a handoff. Custom resolvers can parse different formats. + - **Full conversation history**: By default, the entire conversation (including any + `ChatMessage.additional_properties`) is preserved and passed to each agent. Use + `.with_context_window(N)` to limit the history to the last N messages when you want a rolling window. + - **Termination control**: By default, terminates after 10 user messages. Override with + `.with_termination_condition(lambda conv: ...)` for custom logic (e.g., detect "goodbye"). + - **Checkpointing**: Optional persistence for resumable workflows. + + Usage: + + .. code-block:: python + + from agent_framework import HandoffBuilder + from agent_framework.openai import OpenAIChatClient + + chat_client = OpenAIChatClient() + + # Create triage and specialist agents + triage = chat_client.create_agent( + instructions=( + "You are a frontline support agent. Assess the user's issue and decide " + "whether to hand off to 'refund_agent' or 'shipping_agent'. If handing off, " + "include 'HANDOFF_TO: ' in your response." + ), + name="triage_agent", + ) + + refund = chat_client.create_agent( + instructions="You handle refund requests. Ask for order details and process refunds.", + name="refund_agent", + ) + + shipping = chat_client.create_agent( + instructions="You resolve shipping issues. Track packages and update delivery status.", + name="shipping_agent", + ) + + # Build the handoff workflow with default termination (10 user messages) + workflow = ( + HandoffBuilder( + name="customer_support", + participants=[triage, refund, shipping], + ) + .starting_agent("triage_agent") + .build() + ) + + # Run the workflow + events = await workflow.run_stream("My package hasn't arrived yet") + async for event in events: + if isinstance(event, RequestInfoEvent): + # Request user input + user_response = input("You: ") + await workflow.send_response(event.data.request_id, user_response) + + **Custom Termination Condition:** + + .. code-block:: python + + # Terminate when user says goodbye or after 5 exchanges + workflow = ( + HandoffBuilder(participants=[triage, refund, shipping]) + .starting_agent("triage_agent") + .with_termination_condition( + lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5 + or any("goodbye" in msg.text.lower() for msg in conv[-2:]) + ) + .build() + ) + + **Context Window (Rolling History):** + + .. code-block:: python + + # Only keep last 10 messages in conversation for each agent + workflow = ( + HandoffBuilder(participants=[triage, refund, shipping]) + .starting_agent("triage_agent") + .with_context_window(10) + .build() + ) + + **Custom Handoff Resolver:** + + .. code-block:: python + + # Parse handoff from structured agent response + def custom_resolver(response): + # Check additional_properties for handoff metadata + props = response.agent_run_response.additional_properties or {} + return props.get("route_to") + + + workflow = ( + HandoffBuilder(participants=[triage, refund, shipping]) + .starting_agent("triage_agent") + .handoff_resolver(custom_resolver) + .build() + ) + + **Checkpointing:** + + .. code-block:: python + + from agent_framework import InMemoryCheckpointStorage + + storage = InMemoryCheckpointStorage() + workflow = ( + HandoffBuilder(participants=[triage, refund, shipping]) + .starting_agent("triage_agent") + .with_checkpointing(storage) + .build() + ) + + Args: + name: Optional workflow name for identification and logging. + participants: List of agents (AgentProtocol) or executors to participate in the handoff. + The first agent you specify as starting_agent becomes the triage agent. + description: Optional human-readable description of the workflow. + + Raises: + ValueError: If participants list is empty, contains duplicates, or starting_agent not specified. + TypeError: If participants are not AgentProtocol or Executor instances. + """ + + def __init__( + self, + *, + name: str | None = None, + participants: Sequence[AgentProtocol | Executor] | None = None, + description: str | None = None, + ) -> None: + """Initialize a HandoffBuilder for creating conversational handoff workflows. + + The builder starts in an unconfigured state and requires you to call: + 1. `.participants([...])` - Register agents + 2. `.starting_agent(...)` - Designate which agent receives initial user input + 3. `.build()` - Construct the final Workflow + + Optional configuration methods allow you to customize handoff detection, + context management, termination logic, and persistence. + + Args: + name: Optional workflow identifier used in logging and debugging. + If not provided, a default name will be generated. + participants: Optional list of agents (AgentProtocol) or executors that will + participate in the handoff workflow. You can also call + `.participants([...])` later. Each participant must have a + unique identifier (name for agents, id for executors). + description: Optional human-readable description explaining the workflow's + purpose. Useful for documentation and observability. + + Note: + Participants must have stable names/ids because the handoff resolver + uses these identifiers to route control between agents. Agent names + should match the handoff target strings (e.g., "HANDOFF_TO: billing" + requires an agent named "billing"). + """ + self._name = name + self._description = description + self._executors: dict[str, Executor] = {} + self._aliases: dict[str, str] = {} + self._starting_agent_id: str | None = None + self._context_window: int | None = None + self._resolver: Callable[[AgentExecutorResponse], Any] = _default_handoff_resolver + self._checkpoint_storage: CheckpointStorage | None = None + self._request_prompt: str | None = None + self._termination_condition: Callable[[list[ChatMessage]], bool] = _default_termination_condition + + if participants: + self.participants(participants) + + def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "HandoffBuilder": + """Register the agents or executors that will participate in the handoff workflow. + + Each participant must have a unique identifier (name for agents, id for executors). + The workflow will automatically create an alias map so agents can be referenced by + their name, display_name, or executor id when routing. + + Args: + participants: Sequence of AgentProtocol or Executor instances. Each must have + a unique identifier. For agents, the name attribute is used as the + primary identifier and must match handoff target strings. + + Returns: + Self for method chaining. + + Raises: + ValueError: If participants is empty or contains duplicates. + TypeError: If participants are not AgentProtocol or Executor instances. + + Example: + + .. code-block:: python + + from agent_framework import HandoffBuilder + from agent_framework.openai import OpenAIChatClient + + client = OpenAIChatClient() + triage = client.create_agent(instructions="...", name="triage") + refund = client.create_agent(instructions="...", name="refund_agent") + billing = client.create_agent(instructions="...", name="billing_agent") + + builder = HandoffBuilder().participants([triage, refund, billing]) + # Now you can call .starting_agent() to designate the entry point + + Note: + This method resets any previously configured starting_agent, so you must call + `.starting_agent(...)` again after changing participants. + """ + if not participants: + raise ValueError("participants cannot be empty") + + wrapped: list[Executor] = [] + seen_ids: set[str] = set() + alias_map: dict[str, str] = {} + + for p in participants: + executor = self._wrap_participant(p) + if executor.id in seen_ids: + raise ValueError(f"Duplicate participant with id '{executor.id}' detected") + seen_ids.add(executor.id) + wrapped.append(executor) + + alias_map[executor.id] = executor.id + if isinstance(p, AgentProtocol): + name = getattr(p, "name", None) + if name: + alias_map[name] = executor.id + display = getattr(p, "display_name", None) + if isinstance(display, str) and display: + alias_map[display] = executor.id + + self._executors = {executor.id: executor for executor in wrapped} + self._aliases = alias_map + self._starting_agent_id = None + return self + + def starting_agent(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder": + """Designate which agent receives initial user input and acts as the triage/dispatcher. + + The starting agent is responsible for analyzing user requests and deciding whether to: + 1. Handle the request directly and respond to the user, OR + 2. Hand off to a specialist agent by including handoff metadata in the response + + After a specialist responds, the workflow automatically returns control to the user, + creating a cyclical flow: user -> starting_agent -> [specialist] -> user -> ... + + Args: + agent: The agent to use as the entry point. Can be: + - Agent name (str): e.g., "triage_agent" + - AgentProtocol instance: The actual agent object + - Executor instance: A custom executor wrapping an agent + + Returns: + Self for method chaining. + + Raises: + ValueError: If participants(...) hasn't been called yet, or if the specified + agent is not in the participants list. + + Example: + + .. code-block:: python + + builder = ( + HandoffBuilder().participants([triage, refund, billing]).starting_agent("triage") # Use agent name + ) + + # Or pass the agent object directly: + builder = ( + HandoffBuilder().participants([triage, refund, billing]).starting_agent(triage) # Use agent instance + ) + + Note: + The starting agent determines routing by including "HANDOFF_TO: " + in its response, or by setting structured metadata that the handoff resolver + can parse. Use `.handoff_resolver(...)` to customize detection logic. + """ + if not self._executors: + raise ValueError("Call participants(...) before starting_agent(...)") + resolved = self._resolve_to_id(agent) + if resolved not in self._executors: + raise ValueError(f"starting_agent '{resolved}' is not part of the participants list") + self._starting_agent_id = resolved + return self + + def with_context_window(self, message_count: int | None) -> "HandoffBuilder": + """Limit conversation history to a rolling window of recent messages. + + By default, the handoff workflow passes the entire conversation history to each agent. + This can lead to excessive token usage in long conversations. Use a context window to + send only the most recent N messages to agents, reducing costs while maintaining focus + on recent context. + + Args: + message_count: Maximum number of recent messages to include when calling agents. + If None, uses the full conversation history (default behavior). + Must be positive if specified. + + Returns: + Self for method chaining. + + Raises: + ValueError: If message_count is not positive (when provided). + + Example: + + .. code-block:: python + + # Keep only last 10 messages for each agent call + workflow = ( + HandoffBuilder(participants=[triage, refund, billing]) + .starting_agent("triage") + .with_context_window(10) + .build() + ) + + # After 15 messages in the conversation: + # - Full conversation: 15 messages stored + # - Agent sees: Only messages 6-15 (last 10) + # - User sees: All 15 messages in output + + Use Cases: + - Long support conversations where early context becomes irrelevant + - Cost optimization by reducing tokens sent to LLM + - Forcing agents to focus on recent exchanges rather than full history + - Conversations with repetitive patterns where distant history adds noise + + Note: + The context window applies to messages sent TO agents, not the conversation + stored by the workflow. The full conversation is maintained internally and + returned in the final output. This is purely for token efficiency. + """ + if message_count is not None and message_count <= 0: + raise ValueError("message_count must be positive when provided") + self._context_window = message_count + return self + + def handoff_resolver( + self, + resolver: Callable[[AgentExecutorResponse], str | AgentProtocol | Executor | None], + ) -> "HandoffBuilder": + r"""Customize how the workflow detects handoff requests from the starting agent. + + By default, the workflow looks for "HANDOFF_TO: " in the starting agent's + response text. Use this method to implement custom detection logic that reads structured + metadata, function call results, or parses different text patterns. + + The resolver is called after each starting agent response to determine if a handoff + should occur and which specialist to route to. + + Args: + resolver: Function that receives an AgentExecutorResponse and returns: + - str: Name or ID of the specialist agent to hand off to + - AgentProtocol: The specialist agent instance + - Executor: A custom executor to route to + - None: No handoff, starting agent continues handling the conversation + + Returns: + Self for method chaining. + + Example (Structured Metadata): + + .. code-block:: python + + def custom_resolver(response: AgentExecutorResponse) -> str | None: + # Read handoff from response metadata + props = response.agent_run_response.additional_properties or {} + return props.get("route_to") + + + workflow = ( + HandoffBuilder(participants=[triage, refund, billing]) + .starting_agent("triage") + .handoff_resolver(custom_resolver) + .build() + ) + + Example (Function Call Result): + + .. code-block:: python + + def function_call_resolver(response: AgentExecutorResponse) -> str | None: + # Check if agent used a function call to specify routing + value = response.agent_run_response.value + if isinstance(value, dict): + return value.get("handoff_to") + return None + + + workflow = ( + HandoffBuilder(participants=[triage, refund, billing]) + .starting_agent("triage") + .handoff_resolver(function_call_resolver) + .build() + ) + + Example (Custom Text Pattern): + + .. code-block:: python + + import re + + + def regex_resolver(response: AgentExecutorResponse) -> str | None: + # Look for "ROUTE: agent_name" instead of "HANDOFF_TO: agent_name" + for msg in response.agent_run_response.messages: + match = re.search(r"ROUTE:\s*(\w+)", msg.text or "") + if match: + return match.group(1) + return None + + + workflow = ( + HandoffBuilder(participants=[triage, refund, billing]) + .starting_agent("triage") + .handoff_resolver(regex_resolver) + .build() + ) + + Note: + If the resolver returns an agent name that doesn't match any specialist, + a warning is logged and no handoff occurs. Make sure resolver returns + match the names of agents in participants. + """ + self._resolver = resolver + return self + + def request_prompt(self, prompt: str | None) -> "HandoffBuilder": + """Set a custom prompt message displayed when requesting user input. + + By default, the workflow uses a generic prompt: "Provide your next input for the + conversation." Use this method to customize the message shown to users when the + workflow needs their response. + + Args: + prompt: Custom prompt text to display, or None to use the default prompt. + + Returns: + Self for method chaining. + + Example: + + .. code-block:: python + + workflow = ( + HandoffBuilder(participants=[triage, refund, billing]) + .starting_agent("triage") + .request_prompt("How can we help you today?") + .build() + ) + + # For more context-aware prompts, you can access the prompt via + # RequestInfoEvent.data.prompt in your event handling loop + + Note: + The prompt is static and set once during workflow construction. If you need + dynamic prompts based on conversation state, you'll need to handle that in + your application's event processing logic. + """ + self._request_prompt = prompt + return self + + def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "HandoffBuilder": + """Enable workflow state persistence for resumable conversations. + + Checkpointing allows the workflow to save its state at key points, enabling you to: + - Resume conversations after application restarts + - Implement long-running support tickets that span multiple sessions + - Recover from failures without losing conversation context + - Audit and replay conversation history + + Args: + checkpoint_storage: Storage backend implementing CheckpointStorage interface. + Common implementations: InMemoryCheckpointStorage (testing), + database-backed storage (production). + + Returns: + Self for method chaining. + + Example (In-Memory): + + .. code-block:: python + + from agent_framework import InMemoryCheckpointStorage + + storage = InMemoryCheckpointStorage() + workflow = ( + HandoffBuilder(participants=[triage, refund, billing]) + .starting_agent("triage") + .with_checkpointing(storage) + .build() + ) + + # Run workflow with a session ID for resumption + async for event in workflow.run_stream("Help me", session_id="user_123"): + # Process events... + pass + + # Later, resume the same conversation + async for event in workflow.run_stream("I need a refund", session_id="user_123"): + # Conversation continues from where it left off + pass + + Use Cases: + - Customer support systems with persistent ticket history + - Multi-day conversations that need to survive server restarts + - Compliance requirements for conversation auditing + - A/B testing different agent configurations on same conversation + + Note: + Checkpointing adds overhead for serialization and storage I/O. Use it when + persistence is required, not for simple stateless request-response patterns. + """ + self._checkpoint_storage = checkpoint_storage + return self + + def with_termination_condition(self, condition: Callable[[list[ChatMessage]], bool]) -> "HandoffBuilder": + """Set a custom termination condition for the handoff workflow. + + Args: + condition: Function that receives the full conversation and returns True + if the workflow should terminate (not request further user input). + + Returns: + Self for chaining. + + Example: + + .. code-block:: python + + builder.with_termination_condition( + lambda conv: len(conv) > 20 or any("goodbye" in msg.text.lower() for msg in conv[-2:]) + ) + """ + self._termination_condition = condition + return self + + def build(self) -> Workflow: + """Construct the final Workflow instance from the configured builder. + + This method validates the configuration and assembles all internal components: + - Input normalization executor + - Starting agent executor + - Handoff coordinator + - Specialist agent executors + - User input gateway + - Request/response handling + + Returns: + A fully configured Workflow ready to execute via `.run()` or `.run_stream()`. + + Raises: + ValueError: If participants or starting_agent were not configured, or if + required configuration is invalid. + + Example (Minimal): + + .. code-block:: python + + workflow = HandoffBuilder(participants=[triage, refund, billing]).starting_agent("triage").build() + + # Run the workflow + async for event in workflow.run_stream("I need help"): + # Handle events... + pass + + Example (Full Configuration): + + .. code-block:: python + + from agent_framework import InMemoryCheckpointStorage + + storage = InMemoryCheckpointStorage() + workflow = ( + HandoffBuilder( + name="support_workflow", + participants=[triage, refund, billing], + description="Customer support with specialist routing", + ) + .starting_agent("triage") + .with_context_window(10) + .with_termination_condition(lambda conv: len(conv) > 20) + .handoff_resolver(custom_resolver) + .request_prompt("How can we help?") + .with_checkpointing(storage) + .build() + ) + + Note: + After calling build(), the builder instance should not be reused. Create a + new builder if you need to construct another workflow with different configuration. + """ + if not self._executors: + raise ValueError("No participants provided. Call participants([...]) first.") + if self._starting_agent_id is None: + raise ValueError("starting_agent must be defined before build().") + + starting_executor = self._executors[self._starting_agent_id] + specialists = { + exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id + } + + if not specialists: + logger.warning("Handoff workflow has no specialist agents; the starting agent will loop with the user.") + + input_node = _InputToConversation(id="input-conversation") + request_info = RequestInfoExecutor(id=f"{starting_executor.id}_handoff_requests") + user_gateway = _UserInputGateway( + request_executor_id=request_info.id, + starting_agent_id=starting_executor.id, + 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, + context_window=self._context_window, + resolver=self._resolver, + termination_condition=self._termination_condition, + id="handoff-coordinator", + ) + + 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) + + 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) + + return builder.build() + + def _wrap_participant(self, participant: AgentProtocol | Executor) -> Executor: + 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: + if isinstance(candidate, Executor): + return candidate.id + if isinstance(candidate, AgentProtocol): + name: str | None = getattr(candidate, "name", None) + if not name: + raise ValueError("AgentProtocol without a name cannot be resolved to an executor id.") + return self._aliases.get(name, name) + if isinstance(candidate, str): + if candidate in self._aliases: + return self._aliases[candidate] + return candidate + raise TypeError(f"Invalid starting agent reference: {type(candidate).__name__}") diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py new file mode 100644 index 0000000000..1a4fa860f2 --- /dev/null +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -0,0 +1,332 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import AsyncIterable, AsyncIterator +from dataclasses import dataclass +from typing import Any, cast + +import pytest + +from agent_framework import ( + AgentRunResponse, + AgentRunResponseUpdate, + BaseAgent, + ChatMessage, + HandoffBuilder, + HandoffUserInputRequest, + RequestInfoEvent, + Role, + TextContent, + WorkflowEvent, + WorkflowOutputEvent, +) + + +@dataclass +class _ComplexMetadata: + reason: str + payload: dict[str, str] + + +@pytest.fixture +def complex_metadata() -> _ComplexMetadata: + return _ComplexMetadata(reason="route", payload={"code": "X1"}) + + +def _metadata_from_conversation(conversation: list[ChatMessage], key: str) -> list[object]: + return [msg.additional_properties[key] for msg in conversation if key in msg.additional_properties] + + +def _conversation_debug(conversation: list[ChatMessage]) -> list[tuple[str, str | None, str]]: + return [ + (msg.role.value if hasattr(msg.role, "value") else str(msg.role), msg.author_name, msg.text) + for msg in conversation + ] + + +class _RecordingAgent(BaseAgent): + def __init__( + self, + *, + name: str, + handoff_to: str | None = None, + text_handoff: bool = False, + extra_properties: dict[str, object] | None = None, + ) -> None: + super().__init__(id=name, name=name, display_name=name) + self.handoff_to = handoff_to + self.calls: list[list[ChatMessage]] = [] + self._text_handoff = text_handoff + self._extra_properties = dict(extra_properties or {}) + + async def run( # type: ignore[override] + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: Any = None, + **kwargs: Any, + ) -> AgentRunResponse: + conversation = _normalise(messages) + self.calls.append(conversation) + suffix = f"\nHANDOFF_TO: {self.handoff_to}" if self._text_handoff and self.handoff_to else "" + additional_properties: dict[str, object] = {} + if self.handoff_to and not self._text_handoff: + additional_properties["handoff_to"] = self.handoff_to + additional_properties.update(self._extra_properties) + + reply = ChatMessage( + role=Role.ASSISTANT, + text=f"{self.name} reply{suffix}", + author_name=self.display_name, + additional_properties=additional_properties, + ) + value = None + if not self._text_handoff and self.handoff_to: + value = {"handoff_to": self.handoff_to, **self._extra_properties} + return AgentRunResponse(messages=[reply], value=value) + + async def run_stream( # type: ignore[override] + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: Any = None, + **kwargs: Any, + ) -> AsyncIterator[AgentRunResponseUpdate]: + conversation = _normalise(messages) + self.calls.append(conversation) + text = f"{self.name} reply" + if self._text_handoff and self.handoff_to: + text += f"\nHANDOFF_TO: {self.handoff_to}" + + # In streaming mode, additional_properties must be set on the update + # so they're preserved in the final ChatMessage + additional_props: dict[str, Any] = {} + if self.handoff_to and not self._text_handoff: + additional_props["handoff_to"] = self.handoff_to + additional_props.update(self._extra_properties) + yield AgentRunResponseUpdate(contents=[TextContent(text=text)], additional_properties=additional_props) + + +def _normalise(messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> list[ChatMessage]: + if isinstance(messages, list): + result: list[ChatMessage] = [] + for msg in messages: + if isinstance(msg, ChatMessage): + result.append(msg) + elif isinstance(msg, str): + result.append(ChatMessage(Role.USER, text=msg)) + return result + if isinstance(messages, ChatMessage): + return [messages] + if isinstance(messages, str): + return [ChatMessage(Role.USER, text=messages)] + return [] + + +async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + return [event async for event in stream] + + +async def test_handoff_routes_to_specialist_and_requests_user_input(): + triage = _RecordingAgent(name="triage", handoff_to="specialist") + specialist = _RecordingAgent(name="specialist") + + workflow = HandoffBuilder(participants=[triage, specialist]).starting_agent("triage").build() + + events = await _drain(workflow.run_stream("Need help with a refund")) + + assert triage.calls, "Starting agent should receive initial conversation" + assert specialist.calls, "Specialist should be invoked after handoff" + assert len(specialist.calls[0]) == 2 # user + triage reply + + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests, "Workflow should request additional user input" + request_payload = requests[-1].data + assert isinstance(request_payload, HandoffUserInputRequest) + assert len(request_payload.conversation) == 3 # user, triage, specialist + + follow_up = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Thanks"})) + assert any(isinstance(ev, RequestInfoEvent) for ev in follow_up) + + +async def test_context_window_limits_agent_history(): + triage = _RecordingAgent(name="triage", handoff_to="specialist") + specialist = _RecordingAgent(name="specialist") + + workflow = ( + HandoffBuilder(participants=[triage, specialist]) + .starting_agent("triage") + .with_context_window(2) + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 4) + .build() + ) + + # Start conversation + events = await _drain(workflow.run_stream("Damaged shipment, need replacement")) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + # Second user message + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Order 1234"})) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + # Third user message + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "It's urgent"})) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + + # Fourth user message - triggers termination + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Thanks"})) + outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)] + assert outputs, "Workflow should emit WorkflowOutputEvent with full conversation" + + # Verify agents saw limited history (context window = 2) + assert len(triage.calls) >= 2 + assert all(len(call) <= 2 for call in triage.calls) + + assert specialist.calls + assert all(len(call) <= 2 for call in specialist.calls) + + # CRITICAL: Verify final output contains FULL conversation, not just last 2 messages + final_conversation = outputs[-1].data + assert isinstance(final_conversation, list) + final_conversation_list = cast(list[ChatMessage], final_conversation) + user_messages = [msg for msg in final_conversation_list if msg.role == Role.USER] + assert len(user_messages) == 4, "Full conversation should contain all 4 user messages" + assert any("Damaged shipment" in msg.text for msg in user_messages if msg.text) + assert any("Order 1234" in msg.text for msg in user_messages if msg.text) + assert any("urgent" in msg.text for msg in user_messages if msg.text) + assert any("Thanks" in msg.text for msg in user_messages if msg.text) + + +async def test_handoff_preserves_complex_additional_properties(complex_metadata: _ComplexMetadata): + triage = _RecordingAgent(name="triage", handoff_to="specialist", extra_properties={"complex": complex_metadata}) + specialist = _RecordingAgent(name="specialist") + + # Sanity check: agent response contains complex metadata before entering workflow + triage_response = await triage.run([ChatMessage(role=Role.USER, text="Need help with a return")]) + assert triage_response.messages + assert "complex" in triage_response.messages[0].additional_properties + + workflow = ( + HandoffBuilder(participants=[triage, specialist]) + .starting_agent("triage") + .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role == Role.USER) >= 2) + .build() + ) + + # Initial run should preserve complex metadata in the triage response + events = await _drain(workflow.run_stream("Need help with a return")) + agent_events = [ev for ev in events if hasattr(ev, "data") and hasattr(ev.data, "messages")] + if agent_events: + first_agent_event = agent_events[0] + first_agent_event_data = first_agent_event.data + if first_agent_event_data and hasattr(first_agent_event_data, "messages"): + first_agent_message = first_agent_event_data.messages[0] # type: ignore[attr-defined] + assert "complex" in first_agent_message.additional_properties, "Agent event lost complex metadata" + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests, "Workflow should request additional user input" + + request_data = requests[-1].data + assert isinstance(request_data, HandoffUserInputRequest) + conversation_snapshot = request_data.conversation + metadata_values = _metadata_from_conversation(conversation_snapshot, "complex") + assert metadata_values, ( + "Expected triage message in conversation, found " + f"additional_properties={[msg.additional_properties for msg in conversation_snapshot]}," + f" messages={_conversation_debug(conversation_snapshot)}" + ) + assert any(isinstance(value, _ComplexMetadata) for value in metadata_values), ( + "Complex metadata lost after first hop" + ) + restored_meta = next(value for value in metadata_values if isinstance(value, _ComplexMetadata)) + assert restored_meta.payload["code"] == "X1" + + # Respond and ensure metadata survives subsequent cycles + follow_up_events = await _drain( + workflow.send_responses_streaming({requests[-1].request_id: "Here are more details"}) + ) + follow_up_requests = [ev for ev in follow_up_events if isinstance(ev, RequestInfoEvent)] + outputs = [ev for ev in follow_up_events if isinstance(ev, WorkflowOutputEvent)] + + follow_up_conversation: list[ChatMessage] + if follow_up_requests: + follow_up_request_data = follow_up_requests[-1].data + assert isinstance(follow_up_request_data, HandoffUserInputRequest) + follow_up_conversation = follow_up_request_data.conversation + else: + assert outputs, "Workflow produced neither follow-up request nor output" + output_data = outputs[-1].data + follow_up_conversation = cast(list[ChatMessage], output_data) if isinstance(output_data, list) else [] + + metadata_values_after = _metadata_from_conversation(follow_up_conversation, "complex") + assert metadata_values_after, "Expected triage message after follow-up" + assert any(isinstance(value, _ComplexMetadata) for value in metadata_values_after), ( + "Complex metadata lost after restore" + ) + + restored_meta_after = next(value for value in metadata_values_after if isinstance(value, _ComplexMetadata)) + assert restored_meta_after.payload["code"] == "X1" + + +async def test_text_based_handoff_detection(): + triage = _RecordingAgent(name="triage", handoff_to="specialist", text_handoff=True) + specialist = _RecordingAgent(name="specialist") + + workflow = HandoffBuilder(participants=[triage, specialist]).starting_agent("triage").build() + + _ = await _drain(workflow.run_stream("Package arrived broken")) + + assert specialist.calls, "Specialist should be invoked using text handoff hint" + assert len(specialist.calls[0]) >= 2 + + +async def test_multiple_runs_dont_leak_conversation(): + """Verify that running the same workflow multiple times doesn't leak conversation history.""" + triage = _RecordingAgent(name="triage", handoff_to="specialist") + specialist = _RecordingAgent(name="specialist") + + workflow = ( + HandoffBuilder(participants=[triage, specialist]) + .starting_agent("triage") + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2) + .build() + ) + + # First run + events = await _drain(workflow.run_stream("First run 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 message"})) + outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)] + assert outputs, "First run should emit output" + + first_run_conversation = outputs[-1].data + assert isinstance(first_run_conversation, list) + first_run_conv_list = cast(list[ChatMessage], first_run_conversation) + first_run_user_messages = [msg for msg in first_run_conv_list if msg.role == Role.USER] + assert len(first_run_user_messages) == 2 + assert any("First run message" in msg.text for msg in first_run_user_messages if msg.text) + + # Second run - should start fresh, not include first run's messages + triage.calls.clear() + specialist.calls.clear() + + events = await _drain(workflow.run_stream("Second run different message")) + requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] + assert requests + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Another message"})) + outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)] + assert outputs, "Second run should emit output" + + second_run_conversation = outputs[-1].data + assert isinstance(second_run_conversation, list) + second_run_conv_list = cast(list[ChatMessage], second_run_conversation) + second_run_user_messages = [msg for msg in second_run_conv_list if msg.role == Role.USER] + assert len(second_run_user_messages) == 2, ( + "Second run should have exactly 2 user messages, not accumulate first run" + ) + assert any("Second run different message" in msg.text for msg in second_run_user_messages if msg.text) + 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" + ) diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 17780a7aac..b5063090a2 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -89,6 +89,9 @@ 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 | +| Handoff Orchestration | [orchestration/handoff_agents.py](./orchestration/handoff_agents.py) | Triage agent routes to specialists then requests new user input; cyclical workflow pattern | +| Handoff with Context Window | [orchestration/handoff_with_context_window.py](./orchestration/handoff_with_context_window.py) | Limit conversation history to rolling window for token efficiency | +| Handoff with Custom Resolver | [orchestration/handoff_with_custom_resolver.py](./orchestration/handoff_with_custom_resolver.py) | Use structured outputs (Pydantic) for deterministic routing decisions | | Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming | | Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution | | Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | @@ -97,6 +100,11 @@ Once comfortable with these, explore the rest of the samples below. **Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast. +**Handoff workflow tip**: The default handoff loop retains the full conversation, including any +`ChatMessage.additional_properties` emitted by your agents. Opt into `.with_context_window(N)` when +you want a rolling window for token savings; otherwise every specialist and the triage agent receive +the entire conversation so routing metadata remains intact. + ### parallelism | Sample | File | Concepts | diff --git a/python/samples/getting_started/workflows/orchestration/handoff_agents.py b/python/samples/getting_started/workflows/orchestration/handoff_agents.py new file mode 100644 index 0000000000..625551f24f --- /dev/null +++ b/python/samples/getting_started/workflows/orchestration/handoff_agents.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from collections.abc import AsyncIterable +from typing import cast + +from agent_framework import ( + ChatAgent, + ChatMessage, + HandoffBuilder, + HandoffUserInputRequest, + RequestInfoEvent, + WorkflowEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.openai import OpenAIChatClient + +"""Sample: Handoff workflow orchestrating triage and specialist Azure OpenAI agents. + +This sample demonstrates the handoff pattern where a triage agent receives user input, +decides whether to handle it directly or route to a specialist, and maintains a +conversational loop until a termination condition is met. + +Flow: + user input -> triage agent -> [optional specialist] -> user input -> ... + +The triage agent signals handoff by including "HANDOFF_TO: " in its response. +The HandoffBuilder automatically detects this and routes to the appropriate specialist. + +Prerequisites: + - `az login` (Azure CLI authentication) + - Environment variables configured for OpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.) + +Key Concepts: + - HandoffBuilder: High-level API for triage + specialist workflows + - Termination condition: Controls when the workflow stops requesting user input + - Request/response cycle: Workflow requests input, user responds, cycle continues +""" + + +def create_agents(chat_client: OpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]: + """Create and configure the triage and specialist agents. + + The triage agent is responsible for: + - Receiving all user input first + - Deciding whether to handle the request directly or hand off to a specialist + - Signaling handoff by including 'HANDOFF_TO: ' in its response + + Specialist agents are invoked only when the triage agent explicitly hands off to them. + After a specialist responds, control returns to the triage agent. + + Returns: + Tuple of (triage_agent, refund_agent, order_agent, support_agent) + """ + # Triage agent: Acts as the frontline dispatcher + # NOTE: The instructions explicitly tell it to output "HANDOFF_TO: " when routing. + # The HandoffBuilder's default resolver parses this pattern automatically. + triage = chat_client.create_agent( + instructions=( + "You are frontline support triage. Read the latest user message and decide whether " + "to hand off to refund_agent, order_agent, or support_agent. Provide a brief natural-language " + "response for the user. If you need to hand off to a specialist, include a final line exactly " + "formatted as 'HANDOFF_TO: ' where is one of refund_agent, order_agent, or " + "support_agent. If you can handle the conversation yourself, do NOT include any HANDOFF_TO line." + ), + name="triage_agent", + ) + + # Refund specialist: Handles refund requests + refund = chat_client.create_agent( + instructions=( + "You handle refund workflows. Ask for any order identifiers you require and outline the refund steps." + ), + name="refund_agent", + ) + + # Order/shipping specialist: Resolves delivery issues + order = chat_client.create_agent( + instructions=( + "You resolve shipping and fulfillment issues. Clarify the delivery problem and describe the actions " + "you will take to remedy it." + ), + name="order_agent", + ) + + # General support specialist: Fallback for other issues + support = chat_client.create_agent( + instructions=( + "You are a general support agent. Offer empathetic troubleshooting and gather missing details if the " + "issue does not match other specialists." + ), + name="support_agent", + ) + + return triage, refund, order, support + + +async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + """Collect all events from an async stream into a list. + + This helper drains the workflow's event stream so we can process events + synchronously after each workflow step completes. + + Args: + stream: Async iterable of WorkflowEvent + + Returns: + List of all events from the stream + """ + return [event async for event in stream] + + +def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: + """Process workflow events and extract any pending user input requests. + + This function inspects each event type and: + - Prints workflow status changes (IDLE, IDLE_WITH_PENDING_REQUESTS, etc.) + - Displays final conversation snapshots when workflow completes + - Prints user input request prompts + - Collects all RequestInfoEvent instances for response handling + + Args: + events: List of WorkflowEvent to process + + Returns: + List of RequestInfoEvent representing pending user input requests + """ + requests: list[RequestInfoEvent] = [] + + for event in events: + # WorkflowStatusEvent: Indicates workflow state changes + if isinstance(event, WorkflowStatusEvent) and event.state in { + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + }: + print(f"[status] {event.state.name}") + + # WorkflowOutputEvent: Contains the final conversation when workflow terminates + elif isinstance(event, WorkflowOutputEvent): + conversation = cast(list[ChatMessage], event.data) + if isinstance(conversation, list): + print("\n=== Final Conversation Snapshot ===") + for message in conversation: + speaker = message.author_name or message.role.value + print(f"- {speaker}: {message.text}") + print("===================================") + + # RequestInfoEvent: Workflow is requesting user input + elif isinstance(event, RequestInfoEvent): + if isinstance(event.data, HandoffUserInputRequest): + _print_handoff_request(event.data) + requests.append(event) + + return requests + + +def _print_handoff_request(request: HandoffUserInputRequest) -> None: + """Display a user input request prompt with conversation context. + + The HandoffUserInputRequest contains the full conversation history so far, + allowing the user to see what's been discussed before providing their next input. + + Args: + request: The user input request containing conversation and prompt + """ + print("\n=== User Input Requested ===") + for message in request.conversation: + speaker = message.author_name or message.role.value + print(f"- {speaker}: {message.text}") + print("============================") + + +async def main() -> None: + """Main entry point for the handoff workflow demo. + + This function demonstrates: + 1. Creating triage and specialist agents + 2. Building a handoff workflow with custom termination condition + 3. Running the workflow with scripted user responses + 4. Processing events and handling user input requests + + The workflow uses scripted responses instead of interactive input to make + the demo reproducible and testable. In a production application, you would + replace the scripted_responses with actual user input collection. + """ + # Initialize the OpenAI chat client (uses Azure OpenAI by default) + chat_client = OpenAIChatClient() + + # Create all agents: triage + specialists + triage, refund, order, support = create_agents(chat_client) + + # Build the handoff workflow + # - participants: All agents that can participate (triage MUST be first or explicitly set as starting_agent) + # - starting_agent: The triage agent receives all user input first + # - with_termination_condition: Custom logic to stop the request/response loop + # Default is 10 user messages; here we terminate after 4 to match our scripted demo + workflow = ( + HandoffBuilder( + name="customer_support_handoff", + participants=[triage, refund, order, support], + ) + .starting_agent("triage_agent") + .with_termination_condition( + # Terminate after 4 user messages (initial + 3 scripted responses) + # Count only USER role messages to avoid counting agent responses + lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4 + ) + .build() + ) + + # Scripted user responses for reproducible demo + # In a real application, replace this with: + # user_input = input("Your response: ") + # or integrate with a UI/chat interface + scripted_responses = [ + "My order 1234 arrived damaged and the packaging was destroyed.", + "Yes, I'd like a refund if that's possible.", + "Thanks for resolving this.", + ] + + # Start the workflow with the initial user message + # run_stream() returns an async iterator of WorkflowEvent + print("\n[Starting workflow with initial user message...]") + events = await _drain(workflow.run_stream("Hello, I need assistance with my recent purchase.")) + pending_requests = _handle_events(events) + + # Process the request/response cycle + # The workflow will continue requesting input until: + # 1. The termination condition is met (4 user messages in this case), OR + # 2. We run out of scripted responses + response_index = 0 + + while pending_requests and response_index < len(scripted_responses): + # Get the next scripted response + user_response = scripted_responses[response_index] + print(f"\n[User responding: {user_response}]") + + # Send response(s) to all pending requests + # In this demo, there's typically one request per cycle, but the API supports multiple + responses = {req.request_id: user_response for req in pending_requests} + + # Send responses and get new events + events = await _drain(workflow.send_responses_streaming(responses)) + pending_requests = _handle_events(events) + response_index += 1 + + """ + Sample Output: + + [Starting workflow with initial user message...] + + === User Input Requested === + - user: Hello, I need assistance with my recent purchase. + - triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing? + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User responding: My order 1234 arrived damaged and the packaging was destroyed.] + + === User Input Requested === + - user: Hello, I need assistance with my recent purchase. + - triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing? + - user: My order 1234 arrived damaged and the packaging was destroyed. + - triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue. + + HANDOFF_TO: support_agent + - support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out. + + To assist you better, could you please let me know: + - Which item(s) from order 1234 arrived damaged? + - Could you describe the damage, or provide photos if possible? + - Would you prefer a replacement or a refund? + + Once I have this information, I can help resolve this for you as quickly as possible. + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User responding: Yes, I'd like a refund if that's possible.] + + === User Input Requested === + - user: Hello, I need assistance with my recent purchase. + - triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing? + - user: My order 1234 arrived damaged and the packaging was destroyed. + - triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue. + + HANDOFF_TO: support_agent + - support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out. + + To assist you better, could you please let me know: + - Which item(s) from order 1234 arrived damaged? + - Could you describe the damage, or provide photos if possible? + - Would you prefer a replacement or a refund? + + Once I have this information, I can help resolve this for you as quickly as possible. + - user: Yes, I'd like a refund if that's possible. + - triage_agent: Thank you for letting me know you'd prefer a refund. I'll connect you with a specialist who can process your refund request. + + HANDOFF_TO: refund_agent + - refund_agent: Thank you for confirming that you'd like a refund for order 1234. + + Here's what will happen next: + + ... + + HANDOFF_TO: refund_agent + - refund_agent: Thank you for confirming that you'd like a refund for order 1234. + + Here's what will happen next: + + **1. Verification:** + I will need to verify a few more details to proceed. + - Can you confirm the items in order 1234 that arrived damaged? + - Do you have any photos of the damaged items/packaging? (Photos help speed up the process.) + + **2. Refund Request Submission:** + - Once I have the details, I will submit your refund request for review. + + **3. Return Instructions (if needed):** + - In some cases, we may provide instructions on how to return the damaged items. + - You will receive a prepaid return label if necessary. + + **4. Refund Processing:** + - After your request is approved (and any returns are received if required), your refund will be processed. + - Refunds usually appear on your original payment method within 5-10 business days. + + Could you please reply with the specific item(s) damaged and, if possible, attach photos? This will help me get your refund started right away. + - user: Thanks for resolving this. + =================================== + [status] IDLE + """ # noqa: E501 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py new file mode 100644 index 0000000000..0a2ed2ece6 --- /dev/null +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py @@ -0,0 +1,241 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from collections.abc import AsyncIterable +from typing import cast + +from agent_framework import ( + ChatAgent, + ChatMessage, + HandoffBuilder, + HandoffUserInputRequest, + RequestInfoEvent, + WorkflowEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.openai import OpenAIChatClient + +"""Sample: Handoff workflow with context window (rolling history). + +This sample demonstrates how to use `.with_context_window(N)` to limit the conversation +history sent to each agent. This is useful for: +- Reducing token usage and API costs +- Focusing agents on recent context only +- Managing long conversations that would exceed token limits + +Instead of sending the entire conversation history to each agent, only the last N messages +are included. This creates a "rolling window" effect where older messages are dropped. + +Prerequisites: + - `az login` (Azure CLI authentication) + - Environment variables configured for OpenAIChatClient +""" + + +def create_agents(chat_client: OpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: + """Create triage and specialist agents for the demo. + + Returns: + Tuple of (triage_agent, technical_agent, billing_agent) + """ + triage = chat_client.create_agent( + instructions=( + "You are a triage agent for customer support. Assess the user's issue and route to " + "technical_agent for technical problems or billing_agent for billing issues. " + "Include 'HANDOFF_TO: ' when routing. Be concise." + ), + name="triage_agent", + ) + + technical = chat_client.create_agent( + instructions=( + "You are a technical support specialist. Help users troubleshoot technical issues. " + "Ask clarifying questions and provide solutions. Be concise." + ), + name="technical_agent", + ) + + billing = chat_client.create_agent( + instructions=( + "You are a billing specialist. Help users with payment, invoice, and subscription questions. Be concise." + ), + name="billing_agent", + ) + + return triage, technical, billing + + +async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + """Collect all events from an async stream into a list.""" + return [event async for event in stream] + + +def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: + """Process workflow events and extract pending user input requests.""" + requests: list[RequestInfoEvent] = [] + + for event in events: + if isinstance(event, WorkflowStatusEvent) and event.state in { + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + }: + print(f"[status] {event.state.name}") + + elif isinstance(event, WorkflowOutputEvent): + conversation = cast(list[ChatMessage], event.data) + if isinstance(conversation, list): + print("\n=== Final Conversation (Full History) ===") + for message in conversation: + speaker = message.author_name or message.role.value + # Truncate long messages for display + text = message.text[:100] + "..." if len(message.text) > 100 else message.text + print(f"- {speaker}: {text}") + print("==========================================") + + elif isinstance(event, RequestInfoEvent): + if isinstance(event.data, HandoffUserInputRequest): + _print_handoff_request(event.data) + requests.append(event) + + return requests + + +def _print_handoff_request(request: HandoffUserInputRequest) -> None: + """Display a user input request with conversation context. + + NOTE: This shows the FULL conversation as stored by the workflow, + but each agent only receives the last N messages (context window). + """ + print("\n=== User Input Requested ===") + print(f"Context available to agents: Last {len(request.conversation)} messages") + for i, message in enumerate(request.conversation, 1): + speaker = message.author_name or message.role.value + # Truncate long messages for display + text = message.text[:80] + "..." if len(message.text) > 80 else message.text + print(f"{i}. {speaker}: {text}") + print("============================") + + +async def main() -> None: + """Demonstrate handoff workflow with context window limiting conversation history. + + This sample shows how the context window affects what each agent sees: + - The workflow maintains the FULL conversation history internally + - Each agent receives only the last N messages (context window) + - This reduces token usage and focuses agents on recent context + + We use a small context window (4 messages) to make the effect visible in the demo. + In production, you might use 10-20 messages depending on your needs. + """ + chat_client = OpenAIChatClient() + triage, technical, billing = create_agents(chat_client) + + # Build workflow with a 4-message context window + # This means each agent will only see the 4 most recent messages, + # even though the full conversation may be much longer. + # + # Why use a context window? + # 1. Reduce token costs (fewer tokens per API call) + # 2. Focus agents on recent context (older messages may be irrelevant) + # 3. Prevent exceeding token limits in very long conversations + workflow = ( + HandoffBuilder( + name="support_with_context_window", + participants=[triage, technical, billing], + ) + .starting_agent("triage_agent") + .with_context_window(4) # Only send last 4 messages to each agent + .with_termination_condition( + # Terminate after 6 user messages to demonstrate longer conversation + lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 6 + ) + .build() + ) + + # Scripted responses simulating a longer conversation + # This will generate more than 4 total messages, demonstrating + # how the context window drops older messages + scripted_responses = [ + "I'm having trouble connecting to the VPN.", # Response 1 + "I'm on Windows 11, using the company VPN client.", # Response 2 + "It says 'Connection timeout' after 30 seconds.", # Response 3 + "I already tried restarting, same issue.", # Response 4 + "Thanks for the help!", # Response 5 + ] + + print("\n[Starting workflow with context window of 4 messages...]") + print("[Each agent will only see the 4 most recent messages]\n") + + # Start workflow + events = await _drain(workflow.run_stream("Hello, I need technical support.")) + pending_requests = _handle_events(events) + + response_index = 0 + conversation_length = 1 # Start with 1 (initial message) + + while pending_requests and response_index < len(scripted_responses): + user_response = scripted_responses[response_index] + print(f"\n[User responding (message #{conversation_length + 1}): {user_response}]") + print(f"[Total messages so far: {conversation_length + 1}]") + + # At this point, if conversation_length > 4, agents will only see last 4 messages + if conversation_length + 1 > 4: + print(f"[Agents will see only messages {conversation_length + 1 - 3} through {conversation_length + 1}]") + + responses = {req.request_id: user_response for req in pending_requests} + events = await _drain(workflow.send_responses_streaming(responses)) + pending_requests = _handle_events(events) + + response_index += 1 + conversation_length += 2 # +1 for user message, +1 for agent response (approximate) + + """ + Sample Output: + + [Starting workflow with context window of 4 messages...] + [Each agent will only see the 4 most recent messages] + + + === User Input Requested === + Context available to agents: Last 3 messages + 1. user: Hello, I need technical support. + 2. triage_agent: Thank you for contacting support. I will route your request to a technical speci... + 3. technical_agent: Hello! I'm here to help. Could you please describe the issue you're experiencing... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User responding (message #2): I'm having trouble connecting to the VPN.] + [Total messages so far: 2] + + === User Input Requested === + Context available to agents: Last 6 messages + 1. user: Hello, I need technical support. + 2. triage_agent: Thank you for contacting support. I will route your request to a technical speci... + 3. technical_agent: Hello! I'm here to help. Could you please describe the issue you're experiencing... + 4. user: I'm having trouble connecting to the VPN. + 5. triage_agent: Thank you for letting us know you're having trouble connecting to the VPN. I wil... + 6. technical_agent: I'm sorry you're having trouble with the VPN connection. To help diagnose the is... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + ... + + 1. Are you getting a specific erro... + - user: It says 'Connection timeout' after 30 seconds. + - triage_agent: Thank you for providing the error message. I will route your request to a technical specialist for f... + - technical_agent: Thank you for the error message. A "Connection timeout" typically means your computer can't reach th... + - user: I already tried restarting, same issue. + - triage_agent: Thank you for the update. I will route your request to a technical specialist for advanced troublesh... + - technical_agent: Thank you for letting me know. Let's try a few more steps: + + 1. **Can you access the internet (e.g., ... + - user: Thanks for the help! + ========================================== + [status] IDLE + """ # noqa: E501 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py new file mode 100644 index 0000000000..a29e82d7c9 --- /dev/null +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py @@ -0,0 +1,311 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +from collections.abc import AsyncIterable +from typing import Any, Literal, cast + +from agent_framework import ( + ChatAgent, + ChatMessage, + HandoffBuilder, + HandoffUserInputRequest, + RequestInfoEvent, + WorkflowEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework._workflows._agent_executor import AgentExecutorResponse +from agent_framework.openai import OpenAIChatClient +from pydantic import BaseModel, Field + +"""Sample: Handoff workflow with custom resolver using structured outputs. + +This sample demonstrates a production-ready approach to custom handoff resolution +using Pydantic models with response_format for guaranteed structured outputs. + +Instead of parsing text or relying on instructions to return JSON, this approach: +- Uses response_format to enforce a Pydantic schema +- Gets guaranteed structured output from the model +- Eliminates parsing errors and validation issues +- Makes routing decisions deterministic + +The pattern: +1. Define a Pydantic model for the triage agent's response schema +2. Pass the model as response_format when creating the agent +3. Custom resolver reads the parsed .value field from the response +4. Extract routing decision from the structured data + +Prerequisites: + - `az login` (Azure CLI authentication) + - Environment variables configured for OpenAIChatClient + - Model must support structured outputs (e.g., gpt-4o, gpt-4o-mini) +""" + + +class TriageResponse(BaseModel): + """Structured response from the triage agent. + + This Pydantic model defines the exact schema the triage agent must follow. + The model enforces this structure via response_format. + """ + + action: Literal["route", "handle"] = Field(description="Whether to route to a specialist or handle directly") + target: str | None = Field( + default=None, + description="Target agent name if action is 'route' (e.g., 'refund_agent', 'cancellation_agent')", + ) + response: str = Field(description="Natural language response to the user explaining what will happen") + + +def structured_output_resolver(response: AgentExecutorResponse) -> str | None: + """Parse handoff target from structured Pydantic model response. + + This resolver expects the triage agent to use response_format with TriageResponse. + The agent's .value field will contain the parsed Pydantic model. + + Args: + response: The agent's response after processing user input + + Returns: + The target agent ID to hand off to, or None if triage handles it + + Example: + Agent returns: TriageResponse(action="route", target="refund_agent", response="...") + Resolver extracts: "refund_agent" + """ + agent_response = response.agent_run_response + + # Check if agent returned structured output via response_format + if agent_response.value is None: + print("[Resolver] No structured value in response") + return None + + # The value should be our TriageResponse Pydantic model + if not isinstance(agent_response.value, TriageResponse): + print(f"[Resolver] Unexpected value type: {type(agent_response.value).__name__}") + return None + + triage_response = agent_response.value + + if triage_response.action == "route": + target = triage_response.target + if target: + print(f"[Resolver] Routing to '{target}' (from structured output)") + return target.strip() + print("[Resolver] Action is 'route' but no target specified") + return None + + if triage_response.action == "handle": + print("[Resolver] Triage handling directly (action='handle')") + return None + + print(f"[Resolver] Unknown action: {triage_response.action}") + return None + + +def create_agents(chat_client: OpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: + """Create triage and specialist agents with structured output configuration. + + The triage agent uses response_format=TriageResponse to guarantee structured output. + This eliminates the need for JSON parsing or verbose instructions. + + Returns: + Tuple of (triage_agent, refund_agent, cancellation_agent) + """ + # Triage agent with response_format for guaranteed structured output + triage = chat_client.create_agent( + instructions=( + "You are a customer service triage agent. Analyze user requests and determine routing.\n\n" + "Available specialists:\n" + "- 'refund_agent' for refund requests\n" + "- 'cancellation_agent' for subscription cancellations\n\n" + "If you can answer directly, set action='handle'.\n" + "If a specialist is needed, set action='route' and specify the target agent.\n" + "Always provide a helpful response explaining what will happen." + ), + name="triage_agent", + response_format=TriageResponse, # Enforce structured output schema + ) + + refund = chat_client.create_agent( + instructions="You handle refund requests. Ask for order number and process refunds.", + name="refund_agent", + ) + + cancellation = chat_client.create_agent( + instructions="You handle subscription cancellations. Confirm details and process.", + name="cancellation_agent", + ) + + return triage, refund, cancellation + + +async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + """Collect all events from an async stream into a list.""" + return [event async for event in stream] + + +def _render_message_text(message: ChatMessage, *, truncate: int | None = None) -> str: + """Render message text, unwrapping structured outputs when available.""" + text = message.text or "" + if text: + payload: Any + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if isinstance(payload, dict): + payload_dict = cast(dict[str, Any], payload) + response_value = payload_dict.get("response") + if isinstance(response_value, str): + text = response_value.strip() + if truncate is not None and len(text) > truncate: + return text[:truncate] + "..." + return text + + +def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: + """Process workflow events and extract pending user input requests.""" + requests: list[RequestInfoEvent] = [] + + for event in events: + if isinstance(event, WorkflowStatusEvent) and event.state in { + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + }: + print(f"[status] {event.state.name}") + + elif isinstance(event, WorkflowOutputEvent): + conversation = cast(list[ChatMessage], event.data) + if isinstance(conversation, list): + print("\n=== Final Conversation ===") + for message in conversation: + speaker = message.author_name or message.role.value + text = _render_message_text(message, truncate=100) + print(f"- {speaker}: {text}") + print("==========================") + + elif isinstance(event, RequestInfoEvent): + if isinstance(event.data, HandoffUserInputRequest): + _print_handoff_request(event.data) + requests.append(event) + + return requests + + +def _print_handoff_request(request: HandoffUserInputRequest) -> None: + """Display a user input request with conversation context.""" + print("\n=== User Input Requested ===") + for message in request.conversation: + speaker = message.author_name or message.role.value + text = _render_message_text(message, truncate=80) + print(f"- {speaker}: {text}") + print("============================") + + +async def main() -> None: + """Demonstrate handoff workflow with structured output resolver. + + This sample shows the production-ready pattern for handoff detection: + 1. Define a Pydantic model for the triage agent's response + 2. Use response_format to enforce the schema + 3. Custom resolver reads the structured .value field + 4. No JSON parsing, no text instructions, no ambiguity + + Key Benefits: + - Guaranteed structured output from the model + - Type-safe routing decisions + - No parsing errors or validation issues + - Clean, maintainable code + """ + chat_client = OpenAIChatClient() + triage, refund, cancellation = create_agents(chat_client) + + # Build workflow with structured output resolver + # The resolver reads the TriageResponse Pydantic model from agent.value + workflow = ( + HandoffBuilder( + name="support_with_structured_outputs", + participants=[triage, refund, cancellation], + ) + .starting_agent("triage_agent") + .handoff_resolver(structured_output_resolver) # Use structured output resolver + .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4) + .build() + ) + + # Scripted responses demonstrating different routing scenarios + scripted_responses = [ + "Yes, please proceed with the cancellation.", + "Thank you for your help.", + ] + + print("\n[Starting workflow with structured output resolver...]") + print("[Triage agent uses response_format=TriageResponse]") + print("[Resolver reads parsed Pydantic model from .value]\n") + + # Start workflow + events = await _drain(workflow.run_stream("I want to cancel my subscription.")) + pending_requests = _handle_events(events) + + response_index = 0 + + while pending_requests and response_index < len(scripted_responses): + user_response = scripted_responses[response_index] + print(f"\n[User responding: {user_response}]") + + responses = {req.request_id: user_response for req in pending_requests} + events = await _drain(workflow.send_responses_streaming(responses)) + pending_requests = _handle_events(events) + response_index += 1 + + """ + Sample Output: + + [Starting workflow with structured output resolver...] + [Triage agent uses response_format=TriageResponse] + [Resolver reads parsed Pydantic model from .value] + + [Resolver] Routing to 'cancellation_agent' (from structured output) + + === User Input Requested === + - user: I want to cancel my subscription. + - triage_agent: I understand you'd like to cancel your subscription. I'll connect you with our c... + - cancellation_agent: I'm here to help you with your cancellation request. To proceed, could you pleas... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User responding: Yes, please proceed with the cancellation.] + [Resolver] Routing to 'cancellation_agent' (from structured output) + + === User Input Requested === + - user: I want to cancel my subscription. + - triage_agent: I understand you'd like to cancel your subscription. I'll connect you with our c... + - cancellation_agent: I'm here to help you with your cancellation request. To proceed, could you pleas... + - user: Yes, please proceed with the cancellation. + - triage_agent: Thank you for confirming. I will now connect you with our cancellation specialis... + - cancellation_agent: Thank you for your confirmation. For security and verification purposes, could y... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User responding: Thank you for your help.] + [Resolver] Triage handling directly (action='handle') + + === User Input Requested === + - user: I want to cancel my subscription. + - triage_agent: I understand you'd like to cancel your subscription. I'll connect you with our c... + - cancellation_agent: I'm here to help you with your cancellation request. To proceed, could you pleas... + - user: Yes, please proceed with the cancellation. + - triage_agent: Thank you for confirming. I will now connect you with our cancellation specialis... + - cancellation_agent: Thank you for your confirmation. For security and verification purposes, could y... + - user: Thank you for your help. + - triage_agent: You're very welcome! If you have any more questions or need further assistance, ... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index b2633681e7..505a17b059 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -128,7 +128,7 @@ dev = [ { name = "ruff", specifier = ">=0.11.8" }, { name = "tomli" }, { name = "tomli-w" }, - { name = "uv", specifier = ">=0.8.2,<0.9.0" }, + { name = "uv", specifier = ">=0.8.2,<0.10.0" }, ] docs = [ { name = "debugpy", specifier = ">=1.8.16" }, From f8c3b146a33d2e577440c49376160fd358e27f16 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 15 Oct 2025 10:53:06 +0900 Subject: [PATCH 02/13] PR feedback --- .../core/agent_framework/_workflows/_handoff.py | 7 +++++-- .../orchestration/handoff_with_custom_resolver.py | 12 +++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 7319e4f85a..5ff31fdc18 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -241,9 +241,12 @@ async def handle_user_input( def _resolve_specialist(self, response: AgentExecutorResponse) -> str | None: try: resolved = self._resolver(response) - except Exception as exc: # pragma: no cover - defensive guard - logger.exception("handoff resolver raised %s", exc) + except (ValueError, KeyError) as exc: + logger.exception("handoff resolver raised an expected error: %s", exc) return None + except Exception as exc: # pragma: no cover - defensive guard + logger.exception("handoff resolver raised an unexpected error: %s", exc) + raise if resolved is None: return None diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py index a29e82d7c9..f128ed6d91 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py @@ -151,11 +151,13 @@ def _render_message_text(message: ChatMessage, *, truncate: int | None = None) - """Render message text, unwrapping structured outputs when available.""" text = message.text or "" if text: - payload: Any - try: - payload = json.loads(text) - except json.JSONDecodeError: - payload = None + stripped = text.lstrip() + payload: Any = None + if stripped.startswith("{") or stripped.startswith("["): + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None if isinstance(payload, dict): payload_dict = cast(dict[str, Any], payload) response_value = payload_dict.get("response") From e333dd9c77a766e10768b926b8055c5cc25182f2 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 15 Oct 2025 12:10:37 +0900 Subject: [PATCH 03/13] Use AOAI client in samples --- .../workflows/orchestration/handoff_agents.py | 11 ++++++----- .../orchestration/handoff_with_context_window.py | 9 +++++---- .../orchestration/handoff_with_custom_resolver.py | 9 +++++---- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_agents.py b/python/samples/getting_started/workflows/orchestration/handoff_agents.py index 625551f24f..eca084920c 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_agents.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_agents.py @@ -15,7 +15,8 @@ WorkflowRunState, WorkflowStatusEvent, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential """Sample: Handoff workflow orchestrating triage and specialist Azure OpenAI agents. @@ -31,7 +32,7 @@ Prerequisites: - `az login` (Azure CLI authentication) - - Environment variables configured for OpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.) + - Environment variables configured for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.) Key Concepts: - HandoffBuilder: High-level API for triage + specialist workflows @@ -40,7 +41,7 @@ """ -def create_agents(chat_client: OpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]: +def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]: """Create and configure the triage and specialist agents. The triage agent is responsible for: @@ -185,8 +186,8 @@ async def main() -> None: the demo reproducible and testable. In a production application, you would replace the scripted_responses with actual user input collection. """ - # Initialize the OpenAI chat client (uses Azure OpenAI by default) - chat_client = OpenAIChatClient() + # Initialize the Azure OpenAI chat client + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) # Create all agents: triage + specialists triage, refund, order, support = create_agents(chat_client) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py index 0a2ed2ece6..4b8be3d4d7 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py @@ -15,7 +15,8 @@ WorkflowRunState, WorkflowStatusEvent, ) -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential """Sample: Handoff workflow with context window (rolling history). @@ -30,11 +31,11 @@ Prerequisites: - `az login` (Azure CLI authentication) - - Environment variables configured for OpenAIChatClient + - Environment variables configured for AzureOpenAIChatClient """ -def create_agents(chat_client: OpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: +def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: """Create triage and specialist agents for the demo. Returns: @@ -129,7 +130,7 @@ async def main() -> None: We use a small context window (4 messages) to make the effect visible in the demo. In production, you might use 10-20 messages depending on your needs. """ - chat_client = OpenAIChatClient() + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) triage, technical, billing = create_agents(chat_client) # Build workflow with a 4-message context window diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py index f128ed6d91..e579db89f7 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py @@ -17,7 +17,8 @@ WorkflowStatusEvent, ) from agent_framework._workflows._agent_executor import AgentExecutorResponse -from agent_framework.openai import OpenAIChatClient +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential from pydantic import BaseModel, Field """Sample: Handoff workflow with custom resolver using structured outputs. @@ -39,7 +40,7 @@ Prerequisites: - `az login` (Azure CLI authentication) - - Environment variables configured for OpenAIChatClient + - Environment variables configured for AzureOpenAIChatClient - Model must support structured outputs (e.g., gpt-4o, gpt-4o-mini) """ @@ -105,7 +106,7 @@ def structured_output_resolver(response: AgentExecutorResponse) -> str | None: return None -def create_agents(chat_client: OpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: +def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: """Create triage and specialist agents with structured output configuration. The triage agent uses response_format=TriageResponse to guarantee structured output. @@ -222,7 +223,7 @@ async def main() -> None: - No parsing errors or validation issues - Clean, maintainable code """ - chat_client = OpenAIChatClient() + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) triage, refund, cancellation = create_agents(chat_client) # Build workflow with structured output resolver From f7e105feb0127055badb017d5dab115f564c46c9 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 16 Oct 2025 08:12:47 +0900 Subject: [PATCH 04/13] Adjust to tool --- .../core/agent_framework/_workflows/_agent.py | 57 +- .../agent_framework/_workflows/_handoff.py | 757 +++++++++++++----- .../tests/workflow/test_workflow_agent.py | 48 +- .../observability/workflow_observability.py | 2 +- .../getting_started/workflows/README.md | 5 +- .../workflows/orchestration/handoff_agents.py | 28 +- .../handoff_with_context_window.py | 18 +- .../handoff_with_custom_resolver.py | 314 -------- python/test_specialist_handoff.py | 62 ++ 9 files changed, 722 insertions(+), 569 deletions(-) delete mode 100644 python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py create mode 100644 python/test_specialist_handoff.py diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 9e766d354c..7e6b8db994 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -14,6 +14,8 @@ AgentThread, BaseAgent, ChatMessage, + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, FunctionCallContent, FunctionResultContent, Role, @@ -266,16 +268,20 @@ def _convert_workflow_event_to_agent_update( # Store the pending request for later correlation self.pending_requests[request_id] = event - # Convert to function call content - # TODO(ekzhu): update this to FunctionApprovalRequestContent - # monitor: https://github.com/microsoft/agent-framework/issues/285 + args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict() + function_call = FunctionCallContent( call_id=request_id, name=self.REQUEST_INFO_FUNCTION_NAME, - arguments=self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict(), + arguments=args, + ) + approval_request = FunctionApprovalRequestContent( + id=request_id, + function_call=function_call, + additional_properties={"request_id": request_id}, ) return AgentRunResponseUpdate( - contents=[function_call], + contents=[function_call, approval_request], role=Role.ASSISTANT, author_name=self.name, response_id=response_id, @@ -293,25 +299,48 @@ def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict function_responses: dict[str, Any] = {} for message in input_messages: for content in message.contents: - # TODO(ekzhu): update this to FunctionApprovalResponseContent - # monitor: https://github.com/microsoft/agent-framework/issues/285 - if isinstance(content, FunctionResultContent): + if isinstance(content, FunctionApprovalResponseContent): + # Parse the function arguments to recover request payload + arguments_payload = content.function_call.arguments + if isinstance(arguments_payload, str): + try: + parsed_args = self.RequestInfoFunctionArgs.from_json(arguments_payload) + except ValueError as exc: + raise AgentExecutionException( + "FunctionApprovalResponseContent arguments must decode to a mapping." + ) from exc + elif isinstance(arguments_payload, dict): + parsed_args = self.RequestInfoFunctionArgs.from_dict(arguments_payload) + else: + raise AgentExecutionException( + "FunctionApprovalResponseContent arguments must be a mapping or JSON string." + ) + + request_id = parsed_args.request_id or content.id + if not content.approved: + raise AgentExecutionException( + f"Request '{request_id}' was not approved by the caller." + ) + + if request_id in self.pending_requests: + function_responses[request_id] = parsed_args.data + elif bool(self.pending_requests): + raise AgentExecutionException( + "Only responses for pending requests are allowed when there are outstanding approvals." + ) + elif isinstance(content, FunctionResultContent): request_id = content.call_id - # Check if we have a pending request for this call_id if request_id in self.pending_requests: response_data = content.result if hasattr(content, "result") else str(content) function_responses[request_id] = response_data elif bool(self.pending_requests): - # Function result for unknown request when we have pending requests - this is an error raise AgentExecutionException( - "Only FunctionResultContent for pending requests is allowed in input messages " - "when there are pending requests." + "Only function responses for pending requests are allowed while requests are outstanding." ) else: if bool(self.pending_requests): - # Non-function content when we have pending requests - this is an error raise AgentExecutionException( - "Only FunctionResultContent is allowed in input messages when there are pending requests." + "Unexpected content type while awaiting request info responses." ) return function_responses diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 5ff31fdc18..949efe5dff 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -11,18 +11,30 @@ Key properties: - The entire conversation is maintained by default and reused on every hop - Developers can opt into a rolling context window (last N messages) -- The starting agent determines whether to hand off by emitting metadata +- The starting agent signals a handoff by invoking an approval-gated tool call that names the specialist - After a specialist responds, the workflow immediately requests new user input """ import logging import re -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import Any -from agent_framework import AgentProtocol, AgentRunResponse, ChatMessage, Role - +from agent_framework import ( + AgentProtocol, + AgentRunResponse, + AIFunction, + ChatMessage, + FunctionApprovalRequestContent, + FunctionCallContent, + FunctionResultContent, + Role, + ai_function, +) + +from .._agents import ChatAgent +from .._middleware import FunctionInvocationContext, FunctionMiddleware from ._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from ._checkpoint import CheckpointStorage from ._conversation_state import decode_chat_messages, encode_chat_messages @@ -35,14 +47,72 @@ _HANDOFF_HINT_KEYS = ("handoff_to", "handoff", "transfer_to", "agent_id", "agent") -_HANDOFF_TEXT_PATTERN = re.compile(r"handoff[_\s-]*to\s*:?\s*(?P[\w-]+)", re.IGNORECASE) +_HANDOFF_TOOL_PATTERN = re.compile(r"(?:handoff|transfer)[_\s-]*to[_\s-]*(?P[\w-]+)", re.IGNORECASE) + + +def _sanitize_alias(value: str) -> str: + 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]: + sanitized = _sanitize_alias(alias) + tool_name = f"handoff_to_{sanitized}" + doc = description or f"Handoff to the {alias} agent." + + # Note: approval_mode is intentionally NOT set for handoff tools. + # Handoff tools are framework-internal signals that trigger routing logic, + # not actual function executions. They are automatically intercepted and + # never actually execute, so approval is unnecessary and causes issues + # with tool_calls/responses pairing when cleaning conversations. + @ai_function(name=tool_name, description=doc) + def _handoff_tool(context: str | None = None) -> str: + return f"Handoff to {alias}" + + return _handoff_tool + + +def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: + options = agent.chat_options + middleware = list(agent.middleware or []) + + return ChatAgent( + chat_client=agent.chat_client, + instructions=options.instructions, + id=agent.id, + name=agent.name, + description=agent.description, + chat_message_store_factory=agent.chat_message_store_factory, + context_providers=agent.context_provider, + middleware=middleware, + frequency_penalty=options.frequency_penalty, + logit_bias=dict(options.logit_bias) if options.logit_bias else None, + max_tokens=options.max_tokens, + metadata=dict(options.metadata) if options.metadata else None, + model_id=options.model_id, + presence_penalty=options.presence_penalty, + response_format=options.response_format, + seed=options.seed, + stop=options.stop, + store=options.store, + temperature=options.temperature, + tool_choice=options.tool_choice, # type: ignore[arg-type] + tools=list(options.tools) if options.tools else None, + top_p=options.top_p, + user=options.user, + additional_chat_options=dict(options.additional_properties), + ) @dataclass class HandoffUserInputRequest(RequestInfoMessage): """Request message emitted when the workflow needs fresh user input.""" - conversation: list[ChatMessage] = field(default_factory=list) + conversation: list[ChatMessage] = field(default_factory=lambda: []) # type: ignore[misc] awaiting_agent_id: str | None = None prompt: str | None = None @@ -51,7 +121,30 @@ class HandoffUserInputRequest(RequestInfoMessage): class _ConversationWithUserInput: """Internal message carrying full conversation + new user messages from gateway to coordinator.""" - full_conversation: list[ChatMessage] = field(default_factory=list) + full_conversation: list[ChatMessage] = field(default_factory=lambda: []) # type: ignore[misc] + + +class _AutoHandoffMiddleware(FunctionMiddleware): + """Intercept handoff tool invocations and short-circuit execution with synthetic results.""" + + def __init__(self, handoff_targets: Mapping[str, str]) -> None: + self._targets = {name.lower(): target for name, target in handoff_targets.items()} + + async def process( + self, + context: FunctionInvocationContext, + next: Callable[[FunctionInvocationContext], Awaitable[None]], + ) -> None: + name = getattr(context.function, "name", "") + normalized = name.lower() if name else "" + target = self._targets.get(normalized) + if target is None: + await next(context) + return + + # Short-circuit execution and provide deterministic response payload for the tool call. + context.result = {"handoff_to": target} + context.terminate = True class _InputToConversation(Executor): @@ -74,59 +167,148 @@ async def from_messages( await ctx.send_message(list(messages)) -def _default_handoff_resolver(response: AgentExecutorResponse) -> str | None: - """Extract a target specialist identifier from an agent response.""" - agent_response = response.agent_run_response +def _extract_from_mapping(mapping: Mapping[str, Any]) -> str | None: + for key in _HANDOFF_HINT_KEYS: + value = mapping.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None - # Structured value - value = agent_response.value - candidate = _extract_handoff_candidate(value) - if candidate: - return candidate - # Additional properties on the response payload - props = agent_response.additional_properties or {} - candidate = _extract_from_mapping(props) +@dataclass +class _HandoffResolution: + target: str + function_call: FunctionCallContent | None = None + + +def _resolve_handoff_target(agent_response: AgentRunResponse) -> _HandoffResolution | None: + """Detect handoff intent from tool invocation metadata (approval-gated or raw calls).""" + # Check agent_response.value for handoff target + if agent_response.value: + if isinstance(agent_response.value, Mapping): + candidate = _extract_from_mapping(agent_response.value) # type: ignore[arg-type] + if candidate: + return _HandoffResolution(target=candidate) + elif isinstance(agent_response.value, str) and agent_response.value.strip(): + return _HandoffResolution(target=agent_response.value.strip()) + + # Check agent_response.additional_properties for handoff target + if agent_response.additional_properties: + candidate = _extract_from_mapping(agent_response.additional_properties) + if candidate: + return _HandoffResolution(target=candidate) + + for request in agent_response.user_input_requests: + if isinstance(request, FunctionApprovalRequestContent): + candidate = _candidate_from_approval_request(request) + if candidate: + return _HandoffResolution(target=candidate, function_call=request.function_call) + + for message in agent_response.messages: + # Check message additional_properties for handoff target + if message.additional_properties: + candidate = _extract_from_mapping(message.additional_properties) + if candidate: + return _HandoffResolution(target=candidate) + + # Check message text for handoff hint patterns (e.g., "HANDOFF_TO: specialist") + if message.text: + text_candidate = _candidate_from_text(message.text) + if text_candidate: + return _HandoffResolution(target=text_candidate) + + for content in getattr(message, "contents", ()): + if isinstance(content, FunctionApprovalRequestContent): + candidate = _candidate_from_approval_request(content) + if candidate: + return _HandoffResolution(target=candidate, function_call=content.function_call) + elif isinstance(content, FunctionCallContent): + candidate = _candidate_from_function_call(content) + if candidate: + return _HandoffResolution(target=candidate, function_call=content) + elif isinstance(content, FunctionResultContent): + candidate = _candidate_from_function_result(content) + if candidate: + return _HandoffResolution(target=candidate) + + return None + + +def _candidate_from_approval_request(request: FunctionApprovalRequestContent) -> str | None: + candidate = _candidate_from_function_call(request.function_call) if candidate: return candidate - # Inspect most recent assistant message metadata - for msg in reversed(agent_response.messages): - props = getattr(msg, "additional_properties", {}) or {} - candidate = _extract_from_mapping(props) + if request.additional_properties: + return _extract_from_mapping(request.additional_properties) + return None + + +def _candidate_from_function_call(function_call: FunctionCallContent) -> str | None: + arguments = function_call.parse_arguments() + if isinstance(arguments, Mapping): + candidate = _extract_from_mapping(arguments) if candidate: return candidate - text = msg.text or "" - match = _HANDOFF_TEXT_PATTERN.search(text) - if match: - parsed = match.group("target").strip() - if parsed: - return parsed + elif isinstance(arguments, str) and arguments.strip(): + return arguments.strip() + if function_call.additional_properties: + candidate = _extract_from_mapping(function_call.additional_properties) + if candidate: + return candidate + + name_candidate = _candidate_from_tool_name(function_call.name) + if name_candidate: + return name_candidate + + if isinstance(function_call.name, str) and function_call.name.strip(): + return function_call.name.strip() return None -def _extract_handoff_candidate(candidate: Any) -> str | None: - if candidate is None: +def _candidate_from_function_result(result: FunctionResultContent) -> str | None: + payload = result.result + if isinstance(payload, Mapping): + candidate = _extract_from_mapping(payload) # type: ignore[arg-type] + if candidate: + return candidate + elif isinstance(payload, str) and payload.strip(): + return payload.strip() + return None + + +def _candidate_from_tool_name(name: str | None) -> str | None: + if not name: return None - if isinstance(candidate, str): - return candidate.strip() or None - if isinstance(candidate, Mapping): - return _extract_from_mapping(candidate) - attr = getattr(candidate, "handoff_to", None) - if isinstance(attr, str) and attr.strip(): - return attr.strip() - attr = getattr(candidate, "agent_id", None) - if isinstance(attr, str) and attr.strip(): - return attr.strip() + match = _HANDOFF_TOOL_PATTERN.search(name) + if match: + parsed = match.group("target").strip() + if parsed: + return parsed return None -def _extract_from_mapping(mapping: Mapping[str, Any]) -> str | None: - for key in _HANDOFF_HINT_KEYS: - value = mapping.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() +def _candidate_from_text(text: str) -> str | None: + """Extract handoff target from message text (e.g., 'HANDOFF_TO: specialist').""" + if not text: + return None + + # Pattern 1: HANDOFF_TO: target or TRANSFER_TO: target (with colon) + colon_pattern = re.compile(r"(?:handoff|transfer)[_\s-]*to\s*:\s*(?P[\w-]+)", re.IGNORECASE | re.MULTILINE) + match = colon_pattern.search(text) + if match: + parsed = match.group("target").strip() + if parsed: + return parsed + + # Pattern 2: handoff_to target or transfer_to target (tool-style naming) + match = _HANDOFF_TOOL_PATTERN.search(text) + if match: + parsed = match.group("target").strip() + if parsed: + return parsed + return None @@ -140,9 +322,9 @@ def __init__( specialist_ids: Mapping[str, str], input_gateway_id: str, context_window: int | None, - resolver: Callable[[AgentExecutorResponse], str | AgentProtocol | Executor | None], termination_condition: Callable[[list[ChatMessage]], bool], id: str, + handoff_tool_targets: Mapping[str, str] | None = None, ) -> None: super().__init__(id) self._starting_agent_id = starting_agent_id @@ -150,9 +332,9 @@ def __init__( self._specialist_ids = set(specialist_ids.values()) self._input_gateway_id = input_gateway_id self._context_window = context_window - self._resolver = resolver self._termination_condition = termination_condition self._full_conversation: list[ChatMessage] = [] + self._handoff_tool_targets = {k.lower(): v for k, v in (handoff_tool_targets or {}).items()} @handler async def handle_agent_response( @@ -177,43 +359,55 @@ async def handle_agent_response( # Solution: Track new messages only, build authoritative history incrementally if not self._full_conversation: # First response from starting agent - initialize with authoritative conversation snapshot - self._full_conversation = self._conversation_from_response(response) + # Keep the FULL conversation including tool calls (OpenAI SDK default behavior) + full_conv = self._conversation_from_response(response) + self._full_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) self._apply_response_metadata(self._full_conversation, response.agent_run_response) conversation = list(self._full_conversation) - await self._persist_state(ctx) - if is_starting_agent: - target = self._resolve_specialist(response) - if target is not None: - trimmed = self._trim(conversation) - request = AgentExecutorRequest(messages=trimmed, should_respond=True) - await ctx.send_message(request, target_id=target) - return + # 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) + trimmed = self._trim(conversation) + # Clean tool-related content before sending to next agent + cleaned = self._get_cleaned_conversation(trimmed) + request = AgentExecutorRequest(messages=cleaned, should_respond=True) + await ctx.send_message(request, target_id=target) + return - # Check termination condition before requesting more user input + # No handoff detected - route based on where the response came from + if is_starting_agent: + # Starting agent responded without handoff - check termination then request user input if self._termination_condition(conversation): + await self._persist_state(ctx) logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(conversation)) return + await self._persist_state(ctx) await ctx.send_message(list(conversation), target_id=self._input_gateway_id) return + # Specialist responded without handoff - return to user for input if source not in self._specialist_ids: raise RuntimeError(f"HandoffCoordinator received response from unknown executor '{source}'.") # Check termination condition after specialist response if self._termination_condition(conversation): + await self._persist_state(ctx) logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(conversation)) return + await self._persist_state(ctx) await ctx.send_message(list(conversation), target_id=self._input_gateway_id) @handler @@ -233,51 +427,60 @@ async def handle_user_input( await ctx.yield_output(list(self._full_conversation)) return - # Trim and send to starting agent + # Trim and clean before sending to starting agent trimmed = self._trim(self._full_conversation) - request = AgentExecutorRequest(messages=trimmed, should_respond=True) + cleaned = self._get_cleaned_conversation(trimmed) + request = AgentExecutorRequest(messages=cleaned, should_respond=True) await ctx.send_message(request, target_id=self._starting_agent_id) - def _resolve_specialist(self, response: AgentExecutorResponse) -> str | None: - try: - resolved = self._resolver(response) - except (ValueError, KeyError) as exc: - logger.exception("handoff resolver raised an expected error: %s", exc) - return None - except Exception as exc: # pragma: no cover - defensive guard - logger.exception("handoff resolver raised an unexpected error: %s", exc) - raise - - if resolved is None: + def _resolve_specialist(self, agent_response: AgentRunResponse, conversation: list[ChatMessage]) -> str | None: + resolution = _resolve_handoff_target(agent_response) + if not resolution: return None + candidate = resolution.target + normalized = candidate.lower() resolved_id: str | None - if isinstance(resolved, Executor): - resolved_id = resolved.id - elif isinstance(resolved, AgentProtocol): - name = getattr(resolved, "name", None) - if name is None: - raise ValueError("Resolver returned AgentProtocol without a name; cannot map to executor id.") - resolved_id = self._specialist_by_alias.get(name) or name - elif isinstance(resolved, str): - resolved_id = self._specialist_by_alias.get(resolved) - if resolved_id is None: - lowered = resolved.lower() - for alias, exec_id in self._specialist_by_alias.items(): - if alias.lower() == lowered: - resolved_id = exec_id - break - if resolved_id is None: - resolved_id = resolved + if normalized in self._handoff_tool_targets: + resolved_id = self._handoff_tool_targets[normalized] else: - raise TypeError( - f"Resolver must return Executor, AgentProtocol, str, or None. Got {type(resolved).__name__}." - ) + resolved_id = self._specialist_by_alias.get(candidate) - if resolved_id not in self._specialist_ids: - logger.warning("Resolver selected '%s' which is not a registered specialist.", resolved_id) - return None - return resolved_id + if resolved_id: + if resolution.function_call: + self._append_tool_acknowledgement(conversation, resolution.function_call, resolved_id) + return resolved_id + + lowered = candidate.lower() + for alias, exec_id in self._specialist_by_alias.items(): + if alias.lower() == lowered: + if resolution.function_call: + self._append_tool_acknowledgement(conversation, resolution.function_call, exec_id) + return exec_id + + logger.warning("Handoff requested unknown specialist '%s'.", candidate) + return None + + def _append_tool_acknowledgement( + self, + conversation: list[ChatMessage], + function_call: FunctionCallContent, + resolved_id: str, + ) -> None: + call_id = getattr(function_call, "call_id", None) + if not call_id: + return + + result_payload: Any = {"handoff_to": resolved_id} + result_content = FunctionResultContent(call_id=call_id, result=result_payload) + tool_message = ChatMessage( + role=Role.TOOL, + contents=[result_content], + 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) def _conversation_from_response(self, response: AgentExecutorResponse) -> list[ChatMessage]: conversation = response.full_conversation @@ -292,6 +495,69 @@ def _trim(self, conversation: list[ChatMessage]) -> list[ChatMessage]: return list(conversation) return list(conversation[-self._context_window :]) + 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) + """ + from agent_framework import FunctionApprovalRequestContent, FunctionCallContent + + # 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 hasattr(msg, "contents") and 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)} @@ -301,7 +567,7 @@ def _restore_conversation_from_state(self, state: Mapping[str, Any]) -> list[Cha raw_conv = state.get("full_conversation") if not isinstance(raw_conv, list): return [] - return decode_chat_messages(raw_conv) + return decode_chat_messages(raw_conv) # type: ignore[arg-type] def _apply_response_metadata(self, conversation: list[ChatMessage], agent_response: AgentRunResponse) -> None: if not agent_response.additional_properties: @@ -377,13 +643,17 @@ def _as_user_messages(payload: Any) -> list[ChatMessage]: if payload.role == Role.USER: return [payload] return [ChatMessage(Role.USER, text=payload.text)] - if isinstance(payload, list) and all(isinstance(msg, ChatMessage) for msg in payload): - return [msg if msg.role == Role.USER else ChatMessage(Role.USER, text=msg.text) for msg in payload] + if isinstance(payload, list): + # Check if all items are ChatMessage instances + all_chat_messages = all(isinstance(msg, ChatMessage) for msg in payload) # type: ignore[arg-type] + if all_chat_messages: + messages: list[ChatMessage] = payload # type: ignore[assignment] + return [msg if msg.role == Role.USER else ChatMessage(Role.USER, text=msg.text) for msg in messages] if isinstance(payload, Mapping): # User supplied structured data - text = payload.get("text") or payload.get("content") + text = payload.get("text") or payload.get("content") # type: ignore[union-attr] if isinstance(text, str) and text.strip(): return [ChatMessage(Role.USER, text=text.strip())] - return [ChatMessage(Role.USER, text=str(payload))] + return [ChatMessage(Role.USER, text=str(payload))] # type: ignore[arg-type] def _default_termination_condition(conversation: list[ChatMessage]) -> bool: @@ -404,8 +674,10 @@ class HandoffBuilder: - A **termination condition** determines when the workflow should stop requesting input and complete. Key Features: - - **Automatic handoff detection**: The triage agent includes "HANDOFF_TO: " in its response - to trigger a handoff. Custom resolvers can parse different formats. + - **Automatic handoff detection**: The triage agent invokes an approval-gated handoff tool whose + arguments (for example ``{"handoff_to": "shipping_agent"}``) identify the specialist to receive control. + - **Auto-generated tools**: By default the builder synthesizes `handoff_to_` tools for the starting agent, + so you don't manually define placeholder functions. - **Full conversation history**: By default, the entire conversation (including any `ChatMessage.additional_properties`) is preserved and passed to each agent. Use `.with_context_window(N)` to limit the history to the last N messages when you want a rolling window. @@ -426,8 +698,8 @@ class HandoffBuilder: triage = chat_client.create_agent( instructions=( "You are a frontline support agent. Assess the user's issue and decide " - "whether to hand off to 'refund_agent' or 'shipping_agent'. If handing off, " - "include 'HANDOFF_TO: ' in your response." + "whether to hand off to 'refund_agent' or 'shipping_agent'. When delegation is " + "required, call the matching handoff tool (for example `handoff_to_refund_agent`)." ), name="triage_agent", ) @@ -487,24 +759,6 @@ class HandoffBuilder: .build() ) - **Custom Handoff Resolver:** - - .. code-block:: python - - # Parse handoff from structured agent response - def custom_resolver(response): - # Check additional_properties for handoff metadata - props = response.agent_run_response.additional_properties or {} - return props.get("route_to") - - - workflow = ( - HandoffBuilder(participants=[triage, refund, shipping]) - .starting_agent("triage_agent") - .handoff_resolver(custom_resolver) - .build() - ) - **Checkpointing:** .. code-block:: python @@ -537,15 +791,15 @@ def __init__( participants: Sequence[AgentProtocol | Executor] | None = None, description: str | None = None, ) -> None: - """Initialize a HandoffBuilder for creating conversational handoff workflows. + r"""Initialize a HandoffBuilder for creating conversational handoff workflows. The builder starts in an unconfigured state and requires you to call: 1. `.participants([...])` - Register agents 2. `.starting_agent(...)` - Designate which agent receives initial user input 3. `.build()` - Construct the final Workflow - Optional configuration methods allow you to customize handoff detection, - context management, termination logic, and persistence. + Optional configuration methods allow you to customize context management, + termination logic, and persistence. Args: name: Optional workflow identifier used in logging and debugging. @@ -558,10 +812,10 @@ def __init__( purpose. Useful for documentation and observability. Note: - Participants must have stable names/ids because the handoff resolver - uses these identifiers to route control between agents. Agent names - should match the handoff target strings (e.g., "HANDOFF_TO: billing" - requires an agent named "billing"). + Participants must have stable names/ids because the workflow maps the + handoff tool arguments to these identifiers. Agent names should match + the strings emitted by the triage agent's handoff tool (e.g., a tool that + outputs ``{\"handoff_to\": \"billing\"}`` requires an agent named ``billing``). """ self._name = name self._description = description @@ -569,10 +823,11 @@ def __init__( self._aliases: dict[str, str] = {} self._starting_agent_id: str | None = None self._context_window: int | None = None - self._resolver: Callable[[AgentExecutorResponse], Any] = _default_handoff_resolver self._checkpoint_storage: CheckpointStorage | None = None self._request_prompt: str | None = None self._termination_condition: Callable[[list[ChatMessage]], bool] = _default_termination_condition + self._auto_register_handoff_tools: bool = True + self._handoff_map: dict[str, list[str]] | None = None # Maps agent_id -> [target_agent_ids] if participants: self.participants(participants) @@ -622,6 +877,14 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han seen_ids: set[str] = set() alias_map: dict[str, str] = {} + def _register_alias(alias: str | None, exec_id: str) -> None: + 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) if executor.id in seen_ids: @@ -629,14 +892,13 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han seen_ids.add(executor.id) wrapped.append(executor) - alias_map[executor.id] = executor.id + _register_alias(executor.id, executor.id) if isinstance(p, AgentProtocol): name = getattr(p, "name", None) - if name: - alias_map[name] = executor.id + _register_alias(name, executor.id) display = getattr(p, "display_name", None) if isinstance(display, str) and display: - alias_map[display] = executor.id + _register_alias(display, executor.id) self._executors = {executor.id: executor for executor in wrapped} self._aliases = alias_map @@ -644,7 +906,7 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han return self def starting_agent(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder": - """Designate which agent receives initial user input and acts as the triage/dispatcher. + r"""Designate which agent receives initial user input and acts as the triage/dispatcher. The starting agent is responsible for analyzing user requests and deciding whether to: 1. Handle the request directly and respond to the user, OR @@ -680,9 +942,10 @@ def starting_agent(self, agent: str | AgentProtocol | Executor) -> "HandoffBuild ) Note: - The starting agent determines routing by including "HANDOFF_TO: " - in its response, or by setting structured metadata that the handoff resolver - can parse. Use `.handoff_resolver(...)` to customize detection logic. + The starting agent determines routing by invoking a handoff tool call whose + arguments identify the target specialist (for example ``{\"handoff_to\": \"billing\"}``). + Decorate the tool with ``approval_mode="always_require"`` to ensure the workflow + intercepts the call before execution and can make the transition. """ if not self._executors: raise ValueError("Call participants(...) before starting_agent(...)") @@ -744,95 +1007,139 @@ def with_context_window(self, message_count: int | None) -> "HandoffBuilder": self._context_window = message_count return self - def handoff_resolver( - self, - resolver: Callable[[AgentExecutorResponse], str | AgentProtocol | Executor | None], - ) -> "HandoffBuilder": - r"""Customize how the workflow detects handoff requests from the starting agent. + def with_handoffs(self, handoff_map: dict[str, list[str]]) -> "HandoffBuilder": + """Configure which agents can hand off to which other agents. - By default, the workflow looks for "HANDOFF_TO: " in the starting agent's - response text. Use this method to implement custom detection logic that reads structured - metadata, function call results, or parses different text patterns. + By default, only the starting agent can hand off to all other participants. + Use this method to enable specialist-to-specialist handoffs, where any agent + can delegate to specific other agents. - The resolver is called after each starting agent response to determine if a handoff - should occur and which specialist to route to. + The handoff map keys can be agent names, display names, or executor IDs. + The values are lists of target agent identifiers that the source agent can hand off to. Args: - resolver: Function that receives an AgentExecutorResponse and returns: - - str: Name or ID of the specialist agent to hand off to - - AgentProtocol: The specialist agent instance - - Executor: A custom executor to route to - - None: No handoff, starting agent continues handling the conversation + handoff_map: Dictionary mapping source agent identifiers to lists of target + agent identifiers. For example: + { + "triage_agent": ["billing_agent", "support_agent"], + "support_agent": ["escalation_agent"], + } Returns: Self for method chaining. - Example (Structured Metadata): - - .. code-block:: python - - def custom_resolver(response: AgentExecutorResponse) -> str | None: - # Read handoff from response metadata - props = response.agent_run_response.additional_properties or {} - return props.get("route_to") - - - workflow = ( - HandoffBuilder(participants=[triage, refund, billing]) - .starting_agent("triage") - .handoff_resolver(custom_resolver) - .build() - ) + Example: + >>> workflow = ( + ... HandoffBuilder(participants=[triage, billing, support, escalation]) + ... .starting_agent("triage_agent") + ... .with_handoffs({ + ... "triage_agent": ["billing_agent", "support_agent"], + ... "support_agent": ["escalation_agent"], + ... }) + ... .build() + ... ) - Example (Function Call Result): + Note: + - Handoff tools will be automatically registered for each agent based on this map + - If not specified, only the starting agent gets handoff tools to all other agents + - Agents not in the map cannot hand off to anyone (except the starting agent by default) + """ + self._handoff_map = dict(handoff_map) + return self - .. code-block:: python + def auto_register_handoff_tools(self, enabled: bool) -> "HandoffBuilder": + """Configure whether the builder should synthesize handoff tools for the starting agent.""" + self._auto_register_handoff_tools = enabled + return self - def function_call_resolver(response: AgentExecutorResponse) -> str | None: - # Check if agent used a function call to specify routing - value = response.agent_run_response.value - if isinstance(value, dict): - return value.get("handoff_to") - return None + def _apply_auto_tools(self, agent: ChatAgent, specialists: Mapping[str, Executor]) -> dict[str, str]: + chat_options = agent.chat_options + existing_tools = list(chat_options.tools or []) + existing_names = {getattr(tool, "name", "") for tool in existing_tools if hasattr(tool, "name")} + + tool_targets: dict[str, str] = {} + new_tools: list[Any] = [] + for exec_id in specialists: + alias = exec_id + sanitized = _sanitize_alias(alias) + tool = _create_handoff_tool(alias) + if tool.name not in existing_names: + new_tools.append(tool) + tool_targets[tool.name.lower()] = exec_id + tool_targets[sanitized] = exec_id + tool_targets[alias.lower()] = exec_id + + if new_tools: + chat_options.tools = existing_tools + new_tools + else: + chat_options.tools = existing_tools + return tool_targets - workflow = ( - HandoffBuilder(participants=[triage, refund, billing]) - .starting_agent("triage") - .handoff_resolver(function_call_resolver) - .build() - ) + def _resolve_agent_id(self, agent_identifier: str) -> str: + """Resolve an agent identifier to an executor ID. - Example (Custom Text Pattern): + Args: + agent_identifier: Can be agent name, display name, or executor ID - .. code-block:: python + Returns: + The executor ID - import re + Raises: + ValueError: If the identifier cannot be resolved + """ + # Check if it's already an executor ID + if agent_identifier in self._executors: + return agent_identifier + # Check if it's an alias + if agent_identifier in self._aliases: + return self._aliases[agent_identifier] - def regex_resolver(response: AgentExecutorResponse) -> str | None: - # Look for "ROUTE: agent_name" instead of "HANDOFF_TO: agent_name" - for msg in response.agent_run_response.messages: - match = re.search(r"ROUTE:\s*(\w+)", msg.text or "") - if match: - return match.group(1) - return None + # Not found + raise ValueError(f"Agent identifier '{agent_identifier}' not found in participants") + def _prepare_agent_with_handoffs( + self, + executor: AgentExecutor, + target_agents: Mapping[str, Executor], + ) -> tuple[AgentExecutor, dict[str, str]]: + """Prepare an agent by adding handoff tools for the specified target agents. - workflow = ( - HandoffBuilder(participants=[triage, refund, billing]) - .starting_agent("triage") - .handoff_resolver(regex_resolver) - .build() - ) + Args: + executor: The agent executor to prepare + target_agents: Map of executor IDs to target executors this agent can hand off to - Note: - If the resolver returns an agent name that doesn't match any specialist, - a warning is logged and no handoff occurs. Make sure resolver returns - match the names of agents in participants. + Returns: + Tuple of (updated executor, tool_targets map) """ - self._resolver = resolver - return self + agent = getattr(executor, "_agent", None) + if not isinstance(agent, ChatAgent): + return executor, {} + + cloned_agent = _clone_chat_agent(agent) + tool_targets = self._apply_auto_tools(cloned_agent, target_agents) + if tool_targets: + middleware = _AutoHandoffMiddleware(tool_targets) + existing_middleware = list(cloned_agent.middleware or []) + existing_middleware.append(middleware) + cloned_agent.middleware = existing_middleware + + new_executor = AgentExecutor( + cloned_agent, + agent_thread=getattr(executor, "_agent_thread", None), + output_response=getattr(executor, "_output_response", False), + id=executor.id, + ) + return new_executor, tool_targets + + def _prepare_starting_agent( + self, + executor: AgentExecutor, + specialists: Mapping[str, Executor], + ) -> tuple[AgentExecutor, dict[str, str]]: + """Legacy method - delegates to _prepare_agent_with_handoffs.""" + return self._prepare_agent_with_handoffs(executor, specialists) def request_prompt(self, prompt: str | None) -> "HandoffBuilder": """Set a custom prompt message displayed when requesting user input. @@ -989,7 +1296,6 @@ def build(self) -> Workflow: .starting_agent("triage") .with_context_window(10) .with_termination_condition(lambda conv: len(conv) > 20) - .handoff_resolver(custom_resolver) .request_prompt("How can we help?") .with_checkpointing(storage) .build() @@ -1009,6 +1315,45 @@ def build(self) -> Workflow: exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id } + # Build handoff tool registry for all agents that need them + handoff_tool_targets: dict[str, str] = {} + if self._auto_register_handoff_tools: + # Determine which agents should have handoff tools + if self._handoff_map: + # Use explicit handoff map + for source_agent_id, target_agent_ids in self._handoff_map.items(): + # Resolve source agent ID + source_exec_id = self._resolve_agent_id(source_agent_id) + if source_exec_id not in self._executors: + raise ValueError(f"Handoff source agent '{source_agent_id}' not found in participants") + + executor = self._executors[source_exec_id] + if isinstance(executor, AgentExecutor): + # Resolve target agent IDs and prepare this agent + targets_map: dict[str, Executor] = {} + for target_id in target_agent_ids: + target_exec_id = self._resolve_agent_id(target_id) + if target_exec_id not in self._executors: + raise ValueError(f"Handoff target agent '{target_id}' not found in participants") + targets_map[target_exec_id] = self._executors[target_exec_id] + + # Register handoff tools for this agent + updated_executor, tool_targets = self._prepare_agent_with_handoffs(executor, targets_map) + self._executors[source_exec_id] = updated_executor + handoff_tool_targets.update(tool_targets) + else: + # Default behavior: only starting agent gets handoff tools to all specialists + if isinstance(starting_executor, AgentExecutor) and specialists: + starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists) + self._executors[self._starting_agent_id] = starting_executor + handoff_tool_targets.update(tool_targets) + + # Update references after potential agent modifications + starting_executor = self._executors[self._starting_agent_id] + specialists = { + exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id + } + if not specialists: logger.warning("Handoff workflow has no specialist agents; the starting agent will loop with the user.") @@ -1025,9 +1370,9 @@ def build(self) -> Workflow: specialist_ids={alias: exec_id for alias, exec_id in self._aliases.items() if exec_id in specialists}, input_gateway_id=user_gateway.id, context_window=self._context_window, - resolver=self._resolver, termination_condition=self._termination_condition, id="handoff-coordinator", + handoff_tool_targets=handoff_tool_targets, ) builder = WorkflowBuilder(name=self._name, description=self._description) diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 842ec142ef..567944a104 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -11,8 +11,9 @@ AgentRunUpdateEvent, ChatMessage, Executor, + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, FunctionCallContent, - FunctionResultContent, RequestInfoExecutor, RequestInfoMessage, Role, @@ -163,35 +164,56 @@ async def test_end_to_end_request_info_handling(self): updates: list[AgentRunResponseUpdate] = [] async for update in agent.run_stream("Start request"): updates.append(update) - # Should have received a function call for the request info + # Should have received an approval request for the request info assert len(updates) > 0 - # Find the function call update (RequestInfoEvent converted to function call) - function_call_update: AgentRunResponseUpdate | None = None + approval_update: AgentRunResponseUpdate | None = None for update in updates: - if update.contents and hasattr(update.contents[0], "name") and update.contents[0].name == "request_info": # type: ignore[attr-defined] - function_call_update = update + if any(isinstance(content, FunctionApprovalRequestContent) for content in update.contents): + approval_update = update break - assert function_call_update is not None, "Should have received a request_info function call" - function_call: FunctionCallContent = function_call_update.contents[0] # type: ignore[assignment] + assert approval_update is not None, "Should have received a request_info approval request" + + function_call = next( + content for content in approval_update.contents if isinstance(content, FunctionCallContent) + ) + approval_request = next( + content for content in approval_update.contents if isinstance(content, FunctionApprovalRequestContent) + ) # Verify the function call has expected structure assert function_call.call_id is not None assert function_call.name == "request_info" assert isinstance(function_call.arguments, dict) - assert "request_id" in function_call.arguments + assert function_call.arguments.get("request_id") == approval_request.id + + # Approval request should reference the same function call + assert approval_request.function_call.call_id == function_call.call_id + assert approval_request.function_call.name == function_call.name # Verify the request is tracked in pending_requests assert len(agent.pending_requests) == 1 assert function_call.call_id in agent.pending_requests - # Now provide a function result response to test continuation - response_message = ChatMessage( - role=Role.USER, - contents=[FunctionResultContent(call_id=function_call.call_id, result="User provided answer")], + # Now provide an approval response with updated arguments to test continuation + response_args = WorkflowAgent.RequestInfoFunctionArgs( + request_id=approval_request.id, + data="User provided answer", + ).to_dict() + + approval_response = FunctionApprovalResponseContent( + approved=True, + id=approval_request.id, + function_call=FunctionCallContent( + call_id=function_call.call_id, + name=function_call.name, + arguments=response_args, + ), ) + response_message = ChatMessage(role=Role.USER, contents=[approval_response]) + # Continue the workflow with the response continuation_result = await agent.run(response_message) diff --git a/python/samples/getting_started/observability/workflow_observability.py b/python/samples/getting_started/observability/workflow_observability.py index 10e30024ef..9b56def216 100644 --- a/python/samples/getting_started/observability/workflow_observability.py +++ b/python/samples/getting_started/observability/workflow_observability.py @@ -77,7 +77,7 @@ async def run_sequential_workflow() -> None: print(f"Starting workflow with input: '{input_text}'") output_event = None - async for event in workflow.run_stream(input_text): + async for event in workflow.run_stream("Hello world"): if isinstance(event, WorkflowOutputEvent): # The WorkflowOutputEvent contains the final result. output_event = event diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index b5063090a2..6c559708a0 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -89,9 +89,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 | -| Handoff Orchestration | [orchestration/handoff_agents.py](./orchestration/handoff_agents.py) | Triage agent routes to specialists then requests new user input; cyclical workflow pattern | -| Handoff with Context Window | [orchestration/handoff_with_context_window.py](./orchestration/handoff_with_context_window.py) | Limit conversation history to rolling window for token efficiency | -| Handoff with Custom Resolver | [orchestration/handoff_with_custom_resolver.py](./orchestration/handoff_with_custom_resolver.py) | Use structured outputs (Pydantic) for deterministic routing decisions | +| Handoff Orchestration | [orchestration/handoff_agents.py](./orchestration/handoff_agents.py) | Tool-triggered handoffs route to specialists, then request user input; cyclical workflow pattern | +| Handoff with Context Window | [orchestration/handoff_with_context_window.py](./orchestration/handoff_with_context_window.py) | Tool-triggered handoffs with rolling history for token efficiency | | Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming | | Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution | | Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | diff --git a/python/samples/getting_started/workflows/orchestration/handoff_agents.py b/python/samples/getting_started/workflows/orchestration/handoff_agents.py index eca084920c..c8cb583c53 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_agents.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_agents.py @@ -21,14 +21,17 @@ """Sample: Handoff workflow orchestrating triage and specialist Azure OpenAI agents. This sample demonstrates the handoff pattern where a triage agent receives user input, -decides whether to handle it directly or route to a specialist, and maintains a +decides whether to handle it directly or route to a specialist via a handoff tool call, +and maintains a conversational loop until a termination condition is met. Flow: user input -> triage agent -> [optional specialist] -> user input -> ... -The triage agent signals handoff by including "HANDOFF_TO: " in its response. -The HandoffBuilder automatically detects this and routes to the appropriate specialist. +The triage agent signals handoff by invoking an approval-gated tool (for example, +``handoff_to_refund_agent``). The HandoffBuilder auto-registers these tools for the +starting agent, intercepts the tool call, and routes +control to the requested specialist. Prerequisites: - `az login` (Azure CLI authentication) @@ -47,7 +50,7 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg The triage agent is responsible for: - Receiving all user input first - Deciding whether to handle the request directly or hand off to a specialist - - Signaling handoff by including 'HANDOFF_TO: ' in its response + - Signaling handoff by calling one of the explicit handoff tools exposed to it Specialist agents are invoked only when the triage agent explicitly hands off to them. After a specialist responds, control returns to the triage agent. @@ -56,15 +59,14 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg Tuple of (triage_agent, refund_agent, order_agent, support_agent) """ # Triage agent: Acts as the frontline dispatcher - # NOTE: The instructions explicitly tell it to output "HANDOFF_TO: " when routing. - # The HandoffBuilder's default resolver parses this pattern automatically. + # NOTE: The instructions explicitly tell it to call the correct handoff tool when routing. + # The HandoffBuilder intercepts these tool calls and routes to the matching specialist. triage = chat_client.create_agent( instructions=( "You are frontline support triage. Read the latest user message and decide whether " "to hand off to refund_agent, order_agent, or support_agent. Provide a brief natural-language " - "response for the user. If you need to hand off to a specialist, include a final line exactly " - "formatted as 'HANDOFF_TO: ' where is one of refund_agent, order_agent, or " - "support_agent. If you can handle the conversation yourself, do NOT include any HANDOFF_TO line." + "response for the user. When delegation is required, call the matching handoff tool " + "(`handoff_to_refund_agent`, `handoff_to_order_agent`, or `handoff_to_support_agent`)." ), name="triage_agent", ) @@ -266,7 +268,7 @@ async def main() -> None: - user: My order 1234 arrived damaged and the packaging was destroyed. - triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue. - HANDOFF_TO: support_agent + Tool Call: handoff_to_support_agent (awaiting approval) - support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out. To assist you better, could you please let me know: @@ -286,7 +288,7 @@ async def main() -> None: - user: My order 1234 arrived damaged and the packaging was destroyed. - triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue. - HANDOFF_TO: support_agent + Tool Call: handoff_to_support_agent (awaiting approval) - support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out. To assist you better, could you please let me know: @@ -298,14 +300,14 @@ async def main() -> None: - user: Yes, I'd like a refund if that's possible. - triage_agent: Thank you for letting me know you'd prefer a refund. I'll connect you with a specialist who can process your refund request. - HANDOFF_TO: refund_agent + Tool Call: handoff_to_refund_agent (awaiting approval) - refund_agent: Thank you for confirming that you'd like a refund for order 1234. Here's what will happen next: ... - HANDOFF_TO: refund_agent + Tool Call: handoff_to_refund_agent (awaiting approval) - refund_agent: Thank you for confirming that you'd like a refund for order 1234. Here's what will happen next: diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py index 4b8be3d4d7..a850ace1d1 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py @@ -21,7 +21,8 @@ """Sample: Handoff workflow with context window (rolling history). This sample demonstrates how to use `.with_context_window(N)` to limit the conversation -history sent to each agent. This is useful for: +history sent to each agent while relying on the auto-registered handoff tools provided +by `HandoffBuilder`. This is useful for: - Reducing token usage and API costs - Focusing agents on recent context only - Managing long conversations that would exceed token limits @@ -44,8 +45,9 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg triage = chat_client.create_agent( instructions=( "You are a triage agent for customer support. Assess the user's issue and route to " - "technical_agent for technical problems or billing_agent for billing issues. " - "Include 'HANDOFF_TO: ' when routing. Be concise." + "technical_agent for technical problems or billing_agent for billing issues. Provide a concise " + "response for the user, and when delegation is required call the matching handoff tool " + "(`handoff_to_technical_agent` or `handoff_to_billing_agent`)." ), name="triage_agent", ) @@ -88,7 +90,10 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: conversation = cast(list[ChatMessage], event.data) if isinstance(conversation, list): print("\n=== Final Conversation (Full History) ===") + # Filter out messages with no text for cleaner display for message in conversation: + if not message.text.strip(): + continue speaker = message.author_name or message.role.value # Truncate long messages for display text = message.text[:100] + "..." if len(message.text) > 100 else message.text @@ -108,10 +113,13 @@ def _print_handoff_request(request: HandoffUserInputRequest) -> None: NOTE: This shows the FULL conversation as stored by the workflow, but each agent only receives the last N messages (context window). + Filters out messages with no text (e.g., tool calls) for cleaner display. """ print("\n=== User Input Requested ===") - print(f"Context available to agents: Last {len(request.conversation)} messages") - for i, message in enumerate(request.conversation, 1): + # Filter messages to show only those with actual text content + messages_with_text = [msg for msg in request.conversation if msg.text.strip()] + print(f"Context available to agents: Last {len(messages_with_text)} messages") + for i, message in enumerate(messages_with_text, 1): speaker = message.author_name or message.role.value # Truncate long messages for display text = message.text[:80] + "..." if len(message.text) > 80 else message.text diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py b/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py deleted file mode 100644 index e579db89f7..0000000000 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_custom_resolver.py +++ /dev/null @@ -1,314 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import json -from collections.abc import AsyncIterable -from typing import Any, Literal, cast - -from agent_framework import ( - ChatAgent, - ChatMessage, - HandoffBuilder, - HandoffUserInputRequest, - RequestInfoEvent, - WorkflowEvent, - WorkflowOutputEvent, - WorkflowRunState, - WorkflowStatusEvent, -) -from agent_framework._workflows._agent_executor import AgentExecutorResponse -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential -from pydantic import BaseModel, Field - -"""Sample: Handoff workflow with custom resolver using structured outputs. - -This sample demonstrates a production-ready approach to custom handoff resolution -using Pydantic models with response_format for guaranteed structured outputs. - -Instead of parsing text or relying on instructions to return JSON, this approach: -- Uses response_format to enforce a Pydantic schema -- Gets guaranteed structured output from the model -- Eliminates parsing errors and validation issues -- Makes routing decisions deterministic - -The pattern: -1. Define a Pydantic model for the triage agent's response schema -2. Pass the model as response_format when creating the agent -3. Custom resolver reads the parsed .value field from the response -4. Extract routing decision from the structured data - -Prerequisites: - - `az login` (Azure CLI authentication) - - Environment variables configured for AzureOpenAIChatClient - - Model must support structured outputs (e.g., gpt-4o, gpt-4o-mini) -""" - - -class TriageResponse(BaseModel): - """Structured response from the triage agent. - - This Pydantic model defines the exact schema the triage agent must follow. - The model enforces this structure via response_format. - """ - - action: Literal["route", "handle"] = Field(description="Whether to route to a specialist or handle directly") - target: str | None = Field( - default=None, - description="Target agent name if action is 'route' (e.g., 'refund_agent', 'cancellation_agent')", - ) - response: str = Field(description="Natural language response to the user explaining what will happen") - - -def structured_output_resolver(response: AgentExecutorResponse) -> str | None: - """Parse handoff target from structured Pydantic model response. - - This resolver expects the triage agent to use response_format with TriageResponse. - The agent's .value field will contain the parsed Pydantic model. - - Args: - response: The agent's response after processing user input - - Returns: - The target agent ID to hand off to, or None if triage handles it - - Example: - Agent returns: TriageResponse(action="route", target="refund_agent", response="...") - Resolver extracts: "refund_agent" - """ - agent_response = response.agent_run_response - - # Check if agent returned structured output via response_format - if agent_response.value is None: - print("[Resolver] No structured value in response") - return None - - # The value should be our TriageResponse Pydantic model - if not isinstance(agent_response.value, TriageResponse): - print(f"[Resolver] Unexpected value type: {type(agent_response.value).__name__}") - return None - - triage_response = agent_response.value - - if triage_response.action == "route": - target = triage_response.target - if target: - print(f"[Resolver] Routing to '{target}' (from structured output)") - return target.strip() - print("[Resolver] Action is 'route' but no target specified") - return None - - if triage_response.action == "handle": - print("[Resolver] Triage handling directly (action='handle')") - return None - - print(f"[Resolver] Unknown action: {triage_response.action}") - return None - - -def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: - """Create triage and specialist agents with structured output configuration. - - The triage agent uses response_format=TriageResponse to guarantee structured output. - This eliminates the need for JSON parsing or verbose instructions. - - Returns: - Tuple of (triage_agent, refund_agent, cancellation_agent) - """ - # Triage agent with response_format for guaranteed structured output - triage = chat_client.create_agent( - instructions=( - "You are a customer service triage agent. Analyze user requests and determine routing.\n\n" - "Available specialists:\n" - "- 'refund_agent' for refund requests\n" - "- 'cancellation_agent' for subscription cancellations\n\n" - "If you can answer directly, set action='handle'.\n" - "If a specialist is needed, set action='route' and specify the target agent.\n" - "Always provide a helpful response explaining what will happen." - ), - name="triage_agent", - response_format=TriageResponse, # Enforce structured output schema - ) - - refund = chat_client.create_agent( - instructions="You handle refund requests. Ask for order number and process refunds.", - name="refund_agent", - ) - - cancellation = chat_client.create_agent( - instructions="You handle subscription cancellations. Confirm details and process.", - name="cancellation_agent", - ) - - return triage, refund, cancellation - - -async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: - """Collect all events from an async stream into a list.""" - return [event async for event in stream] - - -def _render_message_text(message: ChatMessage, *, truncate: int | None = None) -> str: - """Render message text, unwrapping structured outputs when available.""" - text = message.text or "" - if text: - stripped = text.lstrip() - payload: Any = None - if stripped.startswith("{") or stripped.startswith("["): - try: - payload = json.loads(text) - except json.JSONDecodeError: - payload = None - if isinstance(payload, dict): - payload_dict = cast(dict[str, Any], payload) - response_value = payload_dict.get("response") - if isinstance(response_value, str): - text = response_value.strip() - if truncate is not None and len(text) > truncate: - return text[:truncate] + "..." - return text - - -def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: - """Process workflow events and extract pending user input requests.""" - requests: list[RequestInfoEvent] = [] - - for event in events: - if isinstance(event, WorkflowStatusEvent) and event.state in { - WorkflowRunState.IDLE, - WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, - }: - print(f"[status] {event.state.name}") - - elif isinstance(event, WorkflowOutputEvent): - conversation = cast(list[ChatMessage], event.data) - if isinstance(conversation, list): - print("\n=== Final Conversation ===") - for message in conversation: - speaker = message.author_name or message.role.value - text = _render_message_text(message, truncate=100) - print(f"- {speaker}: {text}") - print("==========================") - - elif isinstance(event, RequestInfoEvent): - if isinstance(event.data, HandoffUserInputRequest): - _print_handoff_request(event.data) - requests.append(event) - - return requests - - -def _print_handoff_request(request: HandoffUserInputRequest) -> None: - """Display a user input request with conversation context.""" - print("\n=== User Input Requested ===") - for message in request.conversation: - speaker = message.author_name or message.role.value - text = _render_message_text(message, truncate=80) - print(f"- {speaker}: {text}") - print("============================") - - -async def main() -> None: - """Demonstrate handoff workflow with structured output resolver. - - This sample shows the production-ready pattern for handoff detection: - 1. Define a Pydantic model for the triage agent's response - 2. Use response_format to enforce the schema - 3. Custom resolver reads the structured .value field - 4. No JSON parsing, no text instructions, no ambiguity - - Key Benefits: - - Guaranteed structured output from the model - - Type-safe routing decisions - - No parsing errors or validation issues - - Clean, maintainable code - """ - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - triage, refund, cancellation = create_agents(chat_client) - - # Build workflow with structured output resolver - # The resolver reads the TriageResponse Pydantic model from agent.value - workflow = ( - HandoffBuilder( - name="support_with_structured_outputs", - participants=[triage, refund, cancellation], - ) - .starting_agent("triage_agent") - .handoff_resolver(structured_output_resolver) # Use structured output resolver - .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4) - .build() - ) - - # Scripted responses demonstrating different routing scenarios - scripted_responses = [ - "Yes, please proceed with the cancellation.", - "Thank you for your help.", - ] - - print("\n[Starting workflow with structured output resolver...]") - print("[Triage agent uses response_format=TriageResponse]") - print("[Resolver reads parsed Pydantic model from .value]\n") - - # Start workflow - events = await _drain(workflow.run_stream("I want to cancel my subscription.")) - pending_requests = _handle_events(events) - - response_index = 0 - - while pending_requests and response_index < len(scripted_responses): - user_response = scripted_responses[response_index] - print(f"\n[User responding: {user_response}]") - - responses = {req.request_id: user_response for req in pending_requests} - events = await _drain(workflow.send_responses_streaming(responses)) - pending_requests = _handle_events(events) - response_index += 1 - - """ - Sample Output: - - [Starting workflow with structured output resolver...] - [Triage agent uses response_format=TriageResponse] - [Resolver reads parsed Pydantic model from .value] - - [Resolver] Routing to 'cancellation_agent' (from structured output) - - === User Input Requested === - - user: I want to cancel my subscription. - - triage_agent: I understand you'd like to cancel your subscription. I'll connect you with our c... - - cancellation_agent: I'm here to help you with your cancellation request. To proceed, could you pleas... - ============================ - [status] IDLE_WITH_PENDING_REQUESTS - - [User responding: Yes, please proceed with the cancellation.] - [Resolver] Routing to 'cancellation_agent' (from structured output) - - === User Input Requested === - - user: I want to cancel my subscription. - - triage_agent: I understand you'd like to cancel your subscription. I'll connect you with our c... - - cancellation_agent: I'm here to help you with your cancellation request. To proceed, could you pleas... - - user: Yes, please proceed with the cancellation. - - triage_agent: Thank you for confirming. I will now connect you with our cancellation specialis... - - cancellation_agent: Thank you for your confirmation. For security and verification purposes, could y... - ============================ - [status] IDLE_WITH_PENDING_REQUESTS - - [User responding: Thank you for your help.] - [Resolver] Triage handling directly (action='handle') - - === User Input Requested === - - user: I want to cancel my subscription. - - triage_agent: I understand you'd like to cancel your subscription. I'll connect you with our c... - - cancellation_agent: I'm here to help you with your cancellation request. To proceed, could you pleas... - - user: Yes, please proceed with the cancellation. - - triage_agent: Thank you for confirming. I will now connect you with our cancellation specialis... - - cancellation_agent: Thank you for your confirmation. For security and verification purposes, could y... - - user: Thank you for your help. - - triage_agent: You're very welcome! If you have any more questions or need further assistance, ... - ============================ - [status] IDLE_WITH_PENDING_REQUESTS - """ - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/test_specialist_handoff.py b/python/test_specialist_handoff.py new file mode 100644 index 0000000000..7ede154207 --- /dev/null +++ b/python/test_specialist_handoff.py @@ -0,0 +1,62 @@ +# Test specialist-to-specialist handoffs + +import asyncio + +from agent_framework import HandoffBuilder +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + + +async def main(): + """Test specialist-to-specialist handoffs.""" + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + + # Create three agents: triage, replacement, and delivery + triage = chat_client.create_agent( + instructions=("You are a triage agent. Route replacement issues to 'replacement_agent'. Be concise."), + name="triage_agent", + ) + + replacement = chat_client.create_agent( + instructions=( + "You handle product replacements. If you need delivery/shipping info, " + "hand off to 'delivery_agent' by calling handoff_to_delivery_agent. " + "Be concise." + ), + name="replacement_agent", + ) + + delivery = chat_client.create_agent( + instructions=("You handle delivery and shipping inquiries. Provide tracking info. Be concise."), + name="delivery_agent", + ) + + # Build workflow with specialist-to-specialist handoffs + workflow = ( + HandoffBuilder( + name="multi_tier_support", + participants=[triage, replacement, delivery], + ) + .starting_agent("triage_agent") + .with_handoffs({ + "triage_agent": ["replacement_agent", "delivery_agent"], + "replacement_agent": ["delivery_agent"], # Replacement can hand off to delivery + }) + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role.value == "user") >= 3) + .build() + ) + + print("\n[Starting workflow with specialist-to-specialist handoffs enabled]\n") + + # Start workflow + events = [] + async for event in workflow.run_stream("I need a replacement for my damaged item and want to check shipping"): + events.append(event) + print(f"Event: {type(event).__name__}") + + print(f"\nTotal events: {len(events)}") + print("\nWorkflow complete!") + + +if __name__ == "__main__": + asyncio.run(main()) From 07abcbb4961692ce26ba4ed004d4d6a6386a357e Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 16 Oct 2025 10:54:57 +0900 Subject: [PATCH 05/13] Handoff to sub-agent via ai function --- .../core/agent_framework/_workflows/_agent.py | 8 +- .../agent_framework/_workflows/_handoff.py | 142 ++++------ .../core/tests/workflow/test_handoff.py | 57 ++-- .../getting_started/workflows/README.md | 12 +- .../{handoff_agents.py => handoff_simple.py} | 25 +- .../handoff_specialist_to_specialist.py | 224 ++++++++++++++++ .../handoff_with_context_window.py | 250 ------------------ 7 files changed, 319 insertions(+), 399 deletions(-) rename python/samples/getting_started/workflows/orchestration/{handoff_agents.py => handoff_simple.py} (94%) create mode 100644 python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py delete mode 100644 python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 7e6b8db994..cf50f192c5 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -318,9 +318,7 @@ def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict request_id = parsed_args.request_id or content.id if not content.approved: - raise AgentExecutionException( - f"Request '{request_id}' was not approved by the caller." - ) + raise AgentExecutionException(f"Request '{request_id}' was not approved by the caller.") if request_id in self.pending_requests: function_responses[request_id] = parsed_args.data @@ -339,9 +337,7 @@ def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict ) else: if bool(self.pending_requests): - raise AgentExecutionException( - "Unexpected content type while awaiting request info responses." - ) + raise AgentExecutionException("Unexpected content type while awaiting request info responses.") return function_responses class _ResponseState(TypedDict): diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 949efe5dff..fcb5f4ca1f 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -9,9 +9,8 @@ user input -> starting agent -> optional specialist -> request user input -> ... Key properties: -- The entire conversation is maintained by default and reused on every hop -- Developers can opt into a rolling context window (last N messages) -- The starting agent signals a handoff by invoking an approval-gated tool call that names the specialist +- The entire conversation is maintained and reused on every hop +- The starting agent signals a handoff by invoking a tool call that names the specialist - After a specialist responds, the workflow immediately requests new user input """ @@ -321,7 +320,6 @@ def __init__( starting_agent_id: str, specialist_ids: Mapping[str, str], input_gateway_id: str, - context_window: int | None, termination_condition: Callable[[list[ChatMessage]], bool], id: str, handoff_tool_targets: Mapping[str, str] | None = None, @@ -331,7 +329,6 @@ def __init__( self._specialist_by_alias = dict(specialist_ids) self._specialist_ids = set(specialist_ids.values()) self._input_gateway_id = input_gateway_id - self._context_window = context_window 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()} @@ -355,8 +352,7 @@ async def handle_agent_response( is_starting_agent = source == self._starting_agent_id # On first turn of a run, full_conversation is empty - # On subsequent turns with context window, response.full_conversation may be trimmed - # Solution: Track new messages only, build authoritative history incrementally + # Track new messages only, build authoritative history incrementally if not self._full_conversation: # First response from starting agent - initialize with authoritative conversation snapshot # Keep the FULL conversation including tool calls (OpenAI SDK default behavior) @@ -376,9 +372,8 @@ async def handle_agent_response( target = self._resolve_specialist(response.agent_run_response, conversation) if target is not None: await self._persist_state(ctx) - trimmed = self._trim(conversation) # Clean tool-related content before sending to next agent - cleaned = self._get_cleaned_conversation(trimmed) + cleaned = self._get_cleaned_conversation(conversation) request = AgentExecutorRequest(messages=cleaned, should_respond=True) await ctx.send_message(request, target_id=target) return @@ -427,9 +422,8 @@ async def handle_user_input( await ctx.yield_output(list(self._full_conversation)) return - # Trim and clean before sending to starting agent - trimmed = self._trim(self._full_conversation) - cleaned = self._get_cleaned_conversation(trimmed) + # Clean before sending to starting agent + cleaned = self._get_cleaned_conversation(self._full_conversation) request = AgentExecutorRequest(messages=cleaned, should_respond=True) await ctx.send_message(request, target_id=self._starting_agent_id) @@ -490,11 +484,6 @@ def _conversation_from_response(self, response: AgentExecutorResponse) -> list[C ) return list(conversation) - def _trim(self, conversation: list[ChatMessage]) -> list[ChatMessage]: - if self._context_window is None: - return list(conversation) - return list(conversation[-self._context_window :]) - def _get_cleaned_conversation(self, conversation: list[ChatMessage]) -> list[ChatMessage]: """Create a cleaned copy of conversation with tool-related content removed. @@ -673,19 +662,41 @@ class HandoffBuilder: - The workflow automatically requests user input after each agent response, maintaining conversation continuity. - A **termination condition** determines when the workflow should stop requesting input and complete. + Routing Patterns: + + **Single-Tier (Default):** Only the triage agent can hand off to specialists. After any specialist + responds, control returns to the user. This is the recommended pattern for most scenarios because: + + - **User stays in the loop**: Provides visibility and control after each specialist interaction + - **Simpler mental model**: Star topology with triage as hub, specialists as spokes + - **Prevents scope creep**: Specialists focus on domain expertise, not routing logic + - **Easier debugging**: All routing decisions flow through one central agent + - **Better error handling**: User can intervene immediately if a specialist fails + + **Multi-Tier (Advanced):** Specialists can hand off to other specialists using `.with_handoffs()`. + Use this pattern only when you have genuine business requirements for specialist collaboration: + + - **Linear multi-step processes**: Sequential workflows requiring multiple specialists + - **Escalation patterns**: Level 1 → Level 2 → Engineering team + - **Information gathering chains**: One specialist needs data from another before responding + - **Reducing user interruptions**: Async channels (email) where roundtrips are expensive + + Warning: Multi-tier routing adds complexity. Specialists can create unexpected delegation chains, + and users lose visibility into intermediate steps. Only use when the workflow genuinely requires + specialists to collaborate directly without user involvement. + Key Features: - - **Automatic handoff detection**: The triage agent invokes an approval-gated handoff tool whose + - **Automatic handoff detection**: The triage agent invokes a handoff tool whose arguments (for example ``{"handoff_to": "shipping_agent"}``) identify the specialist to receive control. - **Auto-generated tools**: By default the builder synthesizes `handoff_to_` tools for the starting agent, so you don't manually define placeholder functions. - - **Full conversation history**: By default, the entire conversation (including any - `ChatMessage.additional_properties`) is preserved and passed to each agent. Use - `.with_context_window(N)` to limit the history to the last N messages when you want a rolling window. + - **Full conversation history**: The entire conversation (including any + `ChatMessage.additional_properties`) is preserved and passed to each agent. - **Termination control**: By default, terminates after 10 user messages. Override with `.with_termination_condition(lambda conv: ...)` for custom logic (e.g., detect "goodbye"). - **Checkpointing**: Optional persistence for resumable workflows. - Usage: + Usage (Single-Tier): .. code-block:: python @@ -714,7 +725,7 @@ class HandoffBuilder: name="shipping_agent", ) - # Build the handoff workflow with default termination (10 user messages) + # Build the handoff workflow - default single-tier routing workflow = ( HandoffBuilder( name="customer_support", @@ -732,30 +743,40 @@ class HandoffBuilder: user_response = input("You: ") await workflow.send_response(event.data.request_id, user_response) - **Custom Termination Condition:** + **Multi-Tier Routing with .with_handoffs():** .. code-block:: python - # Terminate when user says goodbye or after 5 exchanges + # Enable specialist-to-specialist handoffs workflow = ( - HandoffBuilder(participants=[triage, refund, shipping]) + HandoffBuilder(participants=[triage, replacement, delivery, billing]) .starting_agent("triage_agent") - .with_termination_condition( - lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5 - or any("goodbye" in msg.text.lower() for msg in conv[-2:]) - ) + .with_handoffs({ + # Triage can route to any specialist + "triage_agent": ["replacement_agent", "delivery_agent", "billing_agent"], + # Replacement can delegate to delivery or billing + "replacement_agent": ["delivery_agent", "billing_agent"], + # Delivery can escalate to billing if needed + "delivery_agent": ["billing_agent"], + }) .build() ) - **Context Window (Rolling History):** + # Flow: User → Triage → Replacement → Delivery → Back to User + # (Replacement hands off to Delivery without returning to user) + + **Custom Termination Condition:** .. code-block:: python - # Only keep last 10 messages in conversation for each agent + # Terminate when user says goodbye or after 5 exchanges workflow = ( HandoffBuilder(participants=[triage, refund, shipping]) .starting_agent("triage_agent") - .with_context_window(10) + .with_termination_condition( + lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5 + or any("goodbye" in msg.text.lower() for msg in conv[-2:]) + ) .build() ) @@ -822,7 +843,6 @@ def __init__( self._executors: dict[str, Executor] = {} self._aliases: dict[str, str] = {} self._starting_agent_id: str | None = None - self._context_window: int | None = None self._checkpoint_storage: CheckpointStorage | None = None self._request_prompt: str | None = None self._termination_condition: Callable[[list[ChatMessage]], bool] = _default_termination_condition @@ -955,58 +975,6 @@ def starting_agent(self, agent: str | AgentProtocol | Executor) -> "HandoffBuild self._starting_agent_id = resolved return self - def with_context_window(self, message_count: int | None) -> "HandoffBuilder": - """Limit conversation history to a rolling window of recent messages. - - By default, the handoff workflow passes the entire conversation history to each agent. - This can lead to excessive token usage in long conversations. Use a context window to - send only the most recent N messages to agents, reducing costs while maintaining focus - on recent context. - - Args: - message_count: Maximum number of recent messages to include when calling agents. - If None, uses the full conversation history (default behavior). - Must be positive if specified. - - Returns: - Self for method chaining. - - Raises: - ValueError: If message_count is not positive (when provided). - - Example: - - .. code-block:: python - - # Keep only last 10 messages for each agent call - workflow = ( - HandoffBuilder(participants=[triage, refund, billing]) - .starting_agent("triage") - .with_context_window(10) - .build() - ) - - # After 15 messages in the conversation: - # - Full conversation: 15 messages stored - # - Agent sees: Only messages 6-15 (last 10) - # - User sees: All 15 messages in output - - Use Cases: - - Long support conversations where early context becomes irrelevant - - Cost optimization by reducing tokens sent to LLM - - Forcing agents to focus on recent exchanges rather than full history - - Conversations with repetitive patterns where distant history adds noise - - Note: - The context window applies to messages sent TO agents, not the conversation - stored by the workflow. The full conversation is maintained internally and - returned in the final output. This is purely for token efficiency. - """ - if message_count is not None and message_count <= 0: - raise ValueError("message_count must be positive when provided") - self._context_window = message_count - return self - def with_handoffs(self, handoff_map: dict[str, list[str]]) -> "HandoffBuilder": """Configure which agents can hand off to which other agents. @@ -1294,7 +1262,6 @@ def build(self) -> Workflow: description="Customer support with specialist routing", ) .starting_agent("triage") - .with_context_window(10) .with_termination_condition(lambda conv: len(conv) > 20) .request_prompt("How can we help?") .with_checkpointing(storage) @@ -1369,7 +1336,6 @@ def build(self) -> Workflow: 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, - context_window=self._context_window, termination_condition=self._termination_condition, id="handoff-coordinator", handoff_tool_targets=handoff_tool_targets, diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 1a4fa860f2..8071ad511a 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -148,55 +148,38 @@ async def test_handoff_routes_to_specialist_and_requests_user_input(): assert any(isinstance(ev, RequestInfoEvent) for ev in follow_up) -async def test_context_window_limits_agent_history(): +async def test_specialist_to_specialist_handoff(): + """Test that specialists can hand off to other specialists via .with_handoffs() configuration.""" triage = _RecordingAgent(name="triage", handoff_to="specialist") - specialist = _RecordingAgent(name="specialist") + specialist = _RecordingAgent(name="specialist", handoff_to="escalation") + escalation = _RecordingAgent(name="escalation") workflow = ( - HandoffBuilder(participants=[triage, specialist]) + HandoffBuilder(participants=[triage, specialist, escalation]) .starting_agent("triage") - .with_context_window(2) - .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 4) + .with_handoffs({ + "triage": ["specialist", "escalation"], + "specialist": ["escalation"], + }) + .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2) .build() ) - # Start conversation - events = await _drain(workflow.run_stream("Damaged shipment, need replacement")) + # Start conversation - triage hands off to specialist + events = await _drain(workflow.run_stream("Need technical support")) requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] assert requests - # Second user message - events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Order 1234"})) - requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] - assert requests + # Specialist should have been called + assert len(specialist.calls) > 0 - # Third user message - events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "It's urgent"})) - requests = [ev for ev in events if isinstance(ev, RequestInfoEvent)] - assert requests - - # Fourth user message - triggers termination - events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Thanks"})) + # Second user message - specialist hands off to escalation + events = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "This is complex"})) outputs = [ev for ev in events if isinstance(ev, WorkflowOutputEvent)] - assert outputs, "Workflow should emit WorkflowOutputEvent with full conversation" - - # Verify agents saw limited history (context window = 2) - assert len(triage.calls) >= 2 - assert all(len(call) <= 2 for call in triage.calls) - - assert specialist.calls - assert all(len(call) <= 2 for call in specialist.calls) - - # CRITICAL: Verify final output contains FULL conversation, not just last 2 messages - final_conversation = outputs[-1].data - assert isinstance(final_conversation, list) - final_conversation_list = cast(list[ChatMessage], final_conversation) - user_messages = [msg for msg in final_conversation_list if msg.role == Role.USER] - assert len(user_messages) == 4, "Full conversation should contain all 4 user messages" - assert any("Damaged shipment" in msg.text for msg in user_messages if msg.text) - assert any("Order 1234" in msg.text for msg in user_messages if msg.text) - assert any("urgent" in msg.text for msg in user_messages if msg.text) - assert any("Thanks" in msg.text for msg in user_messages if msg.text) + assert outputs + + # Escalation should have been called + assert len(escalation.calls) > 0 async def test_handoff_preserves_complex_additional_properties(complex_metadata: _ComplexMetadata): diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 6c559708a0..45b8253672 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -89,8 +89,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 | -| Handoff Orchestration | [orchestration/handoff_agents.py](./orchestration/handoff_agents.py) | Tool-triggered handoffs route to specialists, then request user input; cyclical workflow pattern | -| Handoff with Context Window | [orchestration/handoff_with_context_window.py](./orchestration/handoff_with_context_window.py) | Tool-triggered handoffs with rolling history for token efficiency | +| 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 `.with_handoffs()` configuration | | Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming | | Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution | | Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | @@ -99,10 +99,10 @@ Once comfortable with these, explore the rest of the samples below. **Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast. -**Handoff workflow tip**: The default handoff loop retains the full conversation, including any -`ChatMessage.additional_properties` emitted by your agents. Opt into `.with_context_window(N)` when -you want a rolling window for token savings; otherwise every specialist and the triage agent receive -the entire conversation so routing metadata remains intact. +**Handoff workflow tip**: Handoff workflows maintain the full conversation history including any +`ChatMessage.additional_properties` emitted by your agents. This ensures routing metadata remains +intact across all agent transitions. For specialist-to-specialist handoffs, use `.with_handoffs()` +to configure which agents can route to which others. ### parallelism diff --git a/python/samples/getting_started/workflows/orchestration/handoff_agents.py b/python/samples/getting_started/workflows/orchestration/handoff_simple.py similarity index 94% rename from python/samples/getting_started/workflows/orchestration/handoff_agents.py rename to python/samples/getting_started/workflows/orchestration/handoff_simple.py index c8cb583c53..ff24f04826 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_agents.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_simple.py @@ -18,27 +18,28 @@ from agent_framework.azure import AzureOpenAIChatClient from azure.identity import AzureCliCredential -"""Sample: Handoff workflow orchestrating triage and specialist Azure OpenAI agents. +"""Sample: Simple handoff workflow with single-tier triage-to-specialist routing. -This sample demonstrates the handoff pattern where a triage agent receives user input, -decides whether to handle it directly or route to a specialist via a handoff tool call, -and maintains a -conversational loop until a termination condition is met. +This sample demonstrates the basic handoff pattern where only the triage agent can +route to specialists. Specialists cannot hand off to other specialists - after any +specialist responds, control returns to the user for the next input. -Flow: - user input -> triage agent -> [optional specialist] -> user input -> ... +Routing Pattern: + User → Triage Agent → Specialist → Back to User → Triage Agent → ... -The triage agent signals handoff by invoking an approval-gated tool (for example, -``handoff_to_refund_agent``). The HandoffBuilder auto-registers these tools for the -starting agent, intercepts the tool call, and routes -control to the requested specialist. +This is the simplest handoff configuration, suitable for straightforward support +scenarios where a triage agent dispatches to domain specialists, and each specialist +works independently. + +For multi-tier specialist-to-specialist handoffs, see handoff_specialist_to_specialist.py. Prerequisites: - `az login` (Azure CLI authentication) - Environment variables configured for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.) Key Concepts: - - HandoffBuilder: High-level API for triage + specialist workflows + - Single-tier routing: Only triage agent has handoff capabilities + - Auto-registered handoff tools: HandoffBuilder creates tools automatically - Termination condition: Controls when the workflow stops requesting user input - Request/response cycle: Workflow requests input, user responds, cycle continues """ diff --git a/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py b/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py new file mode 100644 index 0000000000..3f5bc716c8 --- /dev/null +++ b/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Sample: Multi-tier handoff workflow with specialist-to-specialist routing. + +This sample demonstrates advanced handoff routing where specialist agents can hand off +to other specialists, enabling complex multi-tier workflows. Unlike the simple handoff +pattern (see handoff_simple.py), specialists here can delegate to other specialists +without returning control to the user until the specialist chain completes. + +Routing Pattern: + User → Triage → Specialist A → Specialist B → Back to User + +This pattern is useful for complex support scenarios where different specialists need +to collaborate or escalate to each other before returning to the user. For example: + - Replacement agent needs shipping info → hands off to delivery agent + - Technical support needs billing info → hands off to billing agent + - Level 1 support escalates to Level 2 → hands off to escalation agent + +Configuration uses `.with_handoffs()` to explicitly define the routing graph. + +Prerequisites: + - `az login` (Azure CLI authentication) + - Environment variables configured for AzureOpenAIChatClient +""" + +import asyncio +from collections.abc import AsyncIterable +from typing import cast + +from agent_framework import ( + ChatMessage, + HandoffBuilder, + HandoffUserInputRequest, + RequestInfoEvent, + WorkflowEvent, + WorkflowOutputEvent, + WorkflowRunState, + WorkflowStatusEvent, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential + + +def create_agents(chat_client: AzureOpenAIChatClient): + """Create triage and specialist agents with multi-tier handoff capabilities. + + Returns: + Tuple of (triage_agent, replacement_agent, delivery_agent, billing_agent) + """ + triage = chat_client.create_agent( + instructions=( + "You are a customer support triage agent. Assess the user's issue and route appropriately:\n" + "- For product replacement issues: call handoff_to_replacement_agent\n" + "- For delivery/shipping inquiries: call handoff_to_delivery_agent\n" + "- For billing/payment issues: call handoff_to_billing_agent\n" + "Be concise and friendly." + ), + name="triage_agent", + ) + + replacement = chat_client.create_agent( + instructions=( + "You handle product replacement requests. Ask for order number and reason for replacement.\n" + "If the user also needs shipping/delivery information, call handoff_to_delivery_agent to " + "get tracking details. Otherwise, process the replacement and confirm with the user.\n" + "Be concise and helpful." + ), + name="replacement_agent", + ) + + delivery = chat_client.create_agent( + instructions=( + "You handle shipping and delivery inquiries. Provide tracking information, estimated " + "delivery dates, and address any delivery concerns.\n" + "If billing issues come up, call handoff_to_billing_agent.\n" + "Be concise and clear." + ), + name="delivery_agent", + ) + + billing = chat_client.create_agent( + instructions=( + "You handle billing and payment questions. Help with refunds, payment methods, " + "and invoice inquiries. Be concise." + ), + name="billing_agent", + ) + + return triage, replacement, delivery, billing + + +async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + """Collect all events from an async stream into a list.""" + return [event async for event in stream] + + +def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: + """Process workflow events and extract pending user input requests.""" + requests: list[RequestInfoEvent] = [] + + for event in events: + if isinstance(event, WorkflowStatusEvent) and event.state in { + WorkflowRunState.IDLE, + WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, + }: + print(f"[status] {event.state.name}") + + elif isinstance(event, WorkflowOutputEvent): + conversation = cast(list[ChatMessage], event.data) + if isinstance(conversation, list): + print("\n=== Final Conversation ===") + for message in conversation: + # Filter out messages with no text (tool calls) + if not message.text.strip(): + continue + speaker = message.author_name or message.role.value + print(f"- {speaker}: {message.text}") + print("==========================") + + elif isinstance(event, RequestInfoEvent): + if isinstance(event.data, HandoffUserInputRequest): + _print_handoff_request(event.data) + requests.append(event) + + return requests + + +def _print_handoff_request(request: HandoffUserInputRequest) -> None: + """Display a user input request with conversation context.""" + print("\n=== User Input Requested ===") + # Filter out messages with no text for cleaner display + messages_with_text = [msg for msg in request.conversation if msg.text.strip()] + print(f"Last {len(messages_with_text)} messages in conversation:") + for message in messages_with_text[-5:]: # Show last 5 for brevity + speaker = message.author_name or message.role.value + text = message.text[:100] + "..." if len(message.text) > 100 else message.text + print(f" {speaker}: {text}") + print("============================") + + +async def main() -> None: + """Demonstrate specialist-to-specialist handoffs in a multi-tier support scenario. + + This sample shows: + 1. Triage agent routes to replacement specialist + 2. Replacement specialist hands off to delivery specialist + 3. Delivery specialist can hand off to billing if needed + 4. All transitions are seamless without returning to user until complete + + The workflow configuration explicitly defines which agents can hand off to which others: + - triage_agent → replacement_agent, delivery_agent, billing_agent + - replacement_agent → delivery_agent, billing_agent + - delivery_agent → billing_agent + """ + chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) + triage, replacement, delivery, billing = create_agents(chat_client) + + # Configure multi-tier handoffs explicitly + # This allows specialists to hand off to other specialists + workflow = ( + HandoffBuilder( + name="multi_tier_support", + participants=[triage, replacement, delivery, billing], + ) + .starting_agent("triage_agent") + .with_handoffs({ + # Triage can route to any specialist + "triage_agent": ["replacement_agent", "delivery_agent", "billing_agent"], + # Replacement can delegate to delivery or billing + "replacement_agent": ["delivery_agent", "billing_agent"], + # Delivery can escalate to billing if needed + "delivery_agent": ["billing_agent"], + }) + .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4) + .build() + ) + + # Scripted user responses simulating a multi-tier handoff scenario + scripted_responses = [ + "I need help with order 12345. I want a replacement and need to know when it will arrive.", + "The item arrived damaged. I'd like a replacement shipped to the same address.", + "Great! Can you confirm the shipping cost won't be charged again?", + ] + + print("\n" + "=" * 80) + print("SPECIALIST-TO-SPECIALIST HANDOFF DEMONSTRATION") + print("=" * 80) + print("\nScenario: Customer needs replacement + shipping info + billing confirmation") + print("Expected flow: User → Triage → Replacement → Delivery → Billing → User") + print("=" * 80 + "\n") + + # Start workflow with initial message + print("[User]: I need help with order 12345. I want a replacement and need to know when it will arrive.\n") + events = await _drain( + workflow.run_stream("I need help with order 12345. I want a replacement and need to know when it will arrive.") + ) + pending_requests = _handle_events(events) + + # Process scripted responses + response_index = 0 + while pending_requests and response_index < len(scripted_responses): + user_response = scripted_responses[response_index] + print(f"\n[User]: {user_response}\n") + + responses = {req.request_id: user_response for req in pending_requests} + events = await _drain(workflow.send_responses_streaming(responses)) + pending_requests = _handle_events(events) + + response_index += 1 + + print("\n" + "=" * 80) + print("DEMONSTRATION COMPLETE") + print("=" * 80) + print("\nKey observations:") + print("1. Triage correctly routed to Replacement agent") + print("2. Replacement agent delegated to Delivery for shipping info") + print("3. Delivery agent could escalate to Billing if needed") + print("4. User only intervened when additional input was required") + print("5. Agents collaborated seamlessly across tiers") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py b/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py deleted file mode 100644 index a850ace1d1..0000000000 --- a/python/samples/getting_started/workflows/orchestration/handoff_with_context_window.py +++ /dev/null @@ -1,250 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -from collections.abc import AsyncIterable -from typing import cast - -from agent_framework import ( - ChatAgent, - ChatMessage, - HandoffBuilder, - HandoffUserInputRequest, - RequestInfoEvent, - WorkflowEvent, - WorkflowOutputEvent, - WorkflowRunState, - WorkflowStatusEvent, -) -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential - -"""Sample: Handoff workflow with context window (rolling history). - -This sample demonstrates how to use `.with_context_window(N)` to limit the conversation -history sent to each agent while relying on the auto-registered handoff tools provided -by `HandoffBuilder`. This is useful for: -- Reducing token usage and API costs -- Focusing agents on recent context only -- Managing long conversations that would exceed token limits - -Instead of sending the entire conversation history to each agent, only the last N messages -are included. This creates a "rolling window" effect where older messages are dropped. - -Prerequisites: - - `az login` (Azure CLI authentication) - - Environment variables configured for AzureOpenAIChatClient -""" - - -def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent]: - """Create triage and specialist agents for the demo. - - Returns: - Tuple of (triage_agent, technical_agent, billing_agent) - """ - triage = chat_client.create_agent( - instructions=( - "You are a triage agent for customer support. Assess the user's issue and route to " - "technical_agent for technical problems or billing_agent for billing issues. Provide a concise " - "response for the user, and when delegation is required call the matching handoff tool " - "(`handoff_to_technical_agent` or `handoff_to_billing_agent`)." - ), - name="triage_agent", - ) - - technical = chat_client.create_agent( - instructions=( - "You are a technical support specialist. Help users troubleshoot technical issues. " - "Ask clarifying questions and provide solutions. Be concise." - ), - name="technical_agent", - ) - - billing = chat_client.create_agent( - instructions=( - "You are a billing specialist. Help users with payment, invoice, and subscription questions. Be concise." - ), - name="billing_agent", - ) - - return triage, technical, billing - - -async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: - """Collect all events from an async stream into a list.""" - return [event async for event in stream] - - -def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: - """Process workflow events and extract pending user input requests.""" - requests: list[RequestInfoEvent] = [] - - for event in events: - if isinstance(event, WorkflowStatusEvent) and event.state in { - WorkflowRunState.IDLE, - WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, - }: - print(f"[status] {event.state.name}") - - elif isinstance(event, WorkflowOutputEvent): - conversation = cast(list[ChatMessage], event.data) - if isinstance(conversation, list): - print("\n=== Final Conversation (Full History) ===") - # Filter out messages with no text for cleaner display - for message in conversation: - if not message.text.strip(): - continue - speaker = message.author_name or message.role.value - # Truncate long messages for display - text = message.text[:100] + "..." if len(message.text) > 100 else message.text - print(f"- {speaker}: {text}") - print("==========================================") - - elif isinstance(event, RequestInfoEvent): - if isinstance(event.data, HandoffUserInputRequest): - _print_handoff_request(event.data) - requests.append(event) - - return requests - - -def _print_handoff_request(request: HandoffUserInputRequest) -> None: - """Display a user input request with conversation context. - - NOTE: This shows the FULL conversation as stored by the workflow, - but each agent only receives the last N messages (context window). - Filters out messages with no text (e.g., tool calls) for cleaner display. - """ - print("\n=== User Input Requested ===") - # Filter messages to show only those with actual text content - messages_with_text = [msg for msg in request.conversation if msg.text.strip()] - print(f"Context available to agents: Last {len(messages_with_text)} messages") - for i, message in enumerate(messages_with_text, 1): - speaker = message.author_name or message.role.value - # Truncate long messages for display - text = message.text[:80] + "..." if len(message.text) > 80 else message.text - print(f"{i}. {speaker}: {text}") - print("============================") - - -async def main() -> None: - """Demonstrate handoff workflow with context window limiting conversation history. - - This sample shows how the context window affects what each agent sees: - - The workflow maintains the FULL conversation history internally - - Each agent receives only the last N messages (context window) - - This reduces token usage and focuses agents on recent context - - We use a small context window (4 messages) to make the effect visible in the demo. - In production, you might use 10-20 messages depending on your needs. - """ - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - triage, technical, billing = create_agents(chat_client) - - # Build workflow with a 4-message context window - # This means each agent will only see the 4 most recent messages, - # even though the full conversation may be much longer. - # - # Why use a context window? - # 1. Reduce token costs (fewer tokens per API call) - # 2. Focus agents on recent context (older messages may be irrelevant) - # 3. Prevent exceeding token limits in very long conversations - workflow = ( - HandoffBuilder( - name="support_with_context_window", - participants=[triage, technical, billing], - ) - .starting_agent("triage_agent") - .with_context_window(4) # Only send last 4 messages to each agent - .with_termination_condition( - # Terminate after 6 user messages to demonstrate longer conversation - lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 6 - ) - .build() - ) - - # Scripted responses simulating a longer conversation - # This will generate more than 4 total messages, demonstrating - # how the context window drops older messages - scripted_responses = [ - "I'm having trouble connecting to the VPN.", # Response 1 - "I'm on Windows 11, using the company VPN client.", # Response 2 - "It says 'Connection timeout' after 30 seconds.", # Response 3 - "I already tried restarting, same issue.", # Response 4 - "Thanks for the help!", # Response 5 - ] - - print("\n[Starting workflow with context window of 4 messages...]") - print("[Each agent will only see the 4 most recent messages]\n") - - # Start workflow - events = await _drain(workflow.run_stream("Hello, I need technical support.")) - pending_requests = _handle_events(events) - - response_index = 0 - conversation_length = 1 # Start with 1 (initial message) - - while pending_requests and response_index < len(scripted_responses): - user_response = scripted_responses[response_index] - print(f"\n[User responding (message #{conversation_length + 1}): {user_response}]") - print(f"[Total messages so far: {conversation_length + 1}]") - - # At this point, if conversation_length > 4, agents will only see last 4 messages - if conversation_length + 1 > 4: - print(f"[Agents will see only messages {conversation_length + 1 - 3} through {conversation_length + 1}]") - - responses = {req.request_id: user_response for req in pending_requests} - events = await _drain(workflow.send_responses_streaming(responses)) - pending_requests = _handle_events(events) - - response_index += 1 - conversation_length += 2 # +1 for user message, +1 for agent response (approximate) - - """ - Sample Output: - - [Starting workflow with context window of 4 messages...] - [Each agent will only see the 4 most recent messages] - - - === User Input Requested === - Context available to agents: Last 3 messages - 1. user: Hello, I need technical support. - 2. triage_agent: Thank you for contacting support. I will route your request to a technical speci... - 3. technical_agent: Hello! I'm here to help. Could you please describe the issue you're experiencing... - ============================ - [status] IDLE_WITH_PENDING_REQUESTS - - [User responding (message #2): I'm having trouble connecting to the VPN.] - [Total messages so far: 2] - - === User Input Requested === - Context available to agents: Last 6 messages - 1. user: Hello, I need technical support. - 2. triage_agent: Thank you for contacting support. I will route your request to a technical speci... - 3. technical_agent: Hello! I'm here to help. Could you please describe the issue you're experiencing... - 4. user: I'm having trouble connecting to the VPN. - 5. triage_agent: Thank you for letting us know you're having trouble connecting to the VPN. I wil... - 6. technical_agent: I'm sorry you're having trouble with the VPN connection. To help diagnose the is... - ============================ - [status] IDLE_WITH_PENDING_REQUESTS - - ... - - 1. Are you getting a specific erro... - - user: It says 'Connection timeout' after 30 seconds. - - triage_agent: Thank you for providing the error message. I will route your request to a technical specialist for f... - - technical_agent: Thank you for the error message. A "Connection timeout" typically means your computer can't reach th... - - user: I already tried restarting, same issue. - - triage_agent: Thank you for the update. I will route your request to a technical specialist for advanced troublesh... - - technical_agent: Thank you for letting me know. Let's try a few more steps: - - 1. **Can you access the internet (e.g., ... - - user: Thanks for the help! - ========================================== - [status] IDLE - """ # noqa: E501 - - -if __name__ == "__main__": - asyncio.run(main()) From 0b6d984b244e1a1b963b344d65c92aa9516d6117 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 16 Oct 2025 14:12:54 +0900 Subject: [PATCH 06/13] PR feedback --- .../agent_framework/_workflows/_handoff.py | 335 ++++++++++-------- .../core/tests/workflow/test_handoff.py | 18 +- .../getting_started/workflows/README.md | 6 +- .../workflows/orchestration/handoff_simple.py | 2 +- .../handoff_specialist_to_specialist.py | 104 ++++-- 5 files changed, 284 insertions(+), 181 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index fcb5f4ca1f..3eb5c96b58 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -2,15 +2,15 @@ """High-level builder for conversational handoff workflows. -The handoff pattern models a triage/dispatcher agent that optionally routes +The handoff pattern models a coordinator agent that optionally routes control to specialist agents before handing the conversation back to the user. The flow is intentionally cyclical: - user input -> starting agent -> optional specialist -> request user input -> ... + user input -> coordinator -> optional specialist -> request user input -> ... Key properties: - The entire conversation is maintained and reused on every hop -- The starting agent signals a handoff by invoking a tool call that names the specialist +- The coordinator signals a handoff by invoking a tool call that names the specialist - After a specialist responds, the workflow immediately requests new user input """ @@ -652,43 +652,28 @@ def _default_termination_condition(conversation: list[ChatMessage]) -> bool: class HandoffBuilder: - r"""Fluent builder for conversational handoff workflows with triage and specialist agents. + r"""Fluent builder for conversational handoff workflows with coordinator and specialist agents. - The handoff pattern models a customer support or multi-agent conversation where: - - A **triage/dispatcher agent** receives user input and decides whether to handle it directly - or hand off to a **specialist agent**. - - After a specialist responds, control returns to the user for more input, creating a cyclical flow: - user -> triage -> [optional specialist] -> user -> triage -> ... - - The workflow automatically requests user input after each agent response, maintaining conversation continuity. - - A **termination condition** determines when the workflow should stop requesting input and complete. + The handoff pattern enables a coordinator agent to route requests to specialist agents. + A termination condition determines when the workflow should stop requesting input and complete. Routing Patterns: - **Single-Tier (Default):** Only the triage agent can hand off to specialists. After any specialist - responds, control returns to the user. This is the recommended pattern for most scenarios because: + **Single-Tier (Default):** Only the coordinator can hand off to specialists. After any specialist + responds, control returns to the user for more input. This creates a cyclical flow: + user -> coordinator -> [optional specialist] -> user -> coordinator -> ... - - **User stays in the loop**: Provides visibility and control after each specialist interaction - - **Simpler mental model**: Star topology with triage as hub, specialists as spokes - - **Prevents scope creep**: Specialists focus on domain expertise, not routing logic - - **Easier debugging**: All routing decisions flow through one central agent - - **Better error handling**: User can intervene immediately if a specialist fails + **Multi-Tier (Advanced):** Specialists can hand off to other specialists using `.add_handoff()`. + This provides more flexibility for complex workflows but is less controllable than the single-tier + pattern. Users lose real-time visibility into intermediate steps during specialist-to-specialist + handoffs (though the full conversation history including all handoffs is preserved and can be + inspected afterward). - **Multi-Tier (Advanced):** Specialists can hand off to other specialists using `.with_handoffs()`. - Use this pattern only when you have genuine business requirements for specialist collaboration: - - - **Linear multi-step processes**: Sequential workflows requiring multiple specialists - - **Escalation patterns**: Level 1 → Level 2 → Engineering team - - **Information gathering chains**: One specialist needs data from another before responding - - **Reducing user interruptions**: Async channels (email) where roundtrips are expensive - - Warning: Multi-tier routing adds complexity. Specialists can create unexpected delegation chains, - and users lose visibility into intermediate steps. Only use when the workflow genuinely requires - specialists to collaborate directly without user involvement. Key Features: - - **Automatic handoff detection**: The triage agent invokes a handoff tool whose + - **Automatic handoff detection**: The coordinator invokes a handoff tool whose arguments (for example ``{"handoff_to": "shipping_agent"}``) identify the specialist to receive control. - - **Auto-generated tools**: By default the builder synthesizes `handoff_to_` tools for the starting agent, + - **Auto-generated tools**: By default the builder synthesizes `handoff_to_` tools for the coordinator, so you don't manually define placeholder functions. - **Full conversation history**: The entire conversation (including any `ChatMessage.additional_properties`) is preserved and passed to each agent. @@ -705,14 +690,14 @@ class HandoffBuilder: chat_client = OpenAIChatClient() - # Create triage and specialist agents - triage = chat_client.create_agent( + # Create coordinator and specialist agents + coordinator = chat_client.create_agent( instructions=( "You are a frontline support agent. Assess the user's issue and decide " "whether to hand off to 'refund_agent' or 'shipping_agent'. When delegation is " "required, call the matching handoff tool (for example `handoff_to_refund_agent`)." ), - name="triage_agent", + name="coordinator_agent", ) refund = chat_client.create_agent( @@ -729,9 +714,9 @@ class HandoffBuilder: workflow = ( HandoffBuilder( name="customer_support", - participants=[triage, refund, shipping], + participants=[coordinator, refund, shipping], ) - .starting_agent("triage_agent") + .coordinator("coordinator_agent") .build() ) @@ -743,26 +728,21 @@ class HandoffBuilder: user_response = input("You: ") await workflow.send_response(event.data.request_id, user_response) - **Multi-Tier Routing with .with_handoffs():** + **Multi-Tier Routing with .add_handoff():** .. code-block:: python - # Enable specialist-to-specialist handoffs + # Enable specialist-to-specialist handoffs with fluent API workflow = ( - HandoffBuilder(participants=[triage, replacement, delivery, billing]) - .starting_agent("triage_agent") - .with_handoffs({ - # Triage can route to any specialist - "triage_agent": ["replacement_agent", "delivery_agent", "billing_agent"], - # Replacement can delegate to delivery or billing - "replacement_agent": ["delivery_agent", "billing_agent"], - # Delivery can escalate to billing if needed - "delivery_agent": ["billing_agent"], - }) + HandoffBuilder(participants=[coordinator, replacement, delivery, billing]) + .coordinator("coordinator_agent") + .add_handoff(coordinator, [replacement, delivery, billing]) # Coordinator routes to all + .add_handoff(replacement, [delivery, billing]) # Replacement delegates to delivery/billing + .add_handoff(delivery, billing) # Delivery escalates to billing .build() ) - # Flow: User → Triage → Replacement → Delivery → Back to User + # Flow: User → Coordinator → Replacement → Delivery → Back to User # (Replacement hands off to Delivery without returning to user) **Custom Termination Condition:** @@ -771,8 +751,8 @@ class HandoffBuilder: # Terminate when user says goodbye or after 5 exchanges workflow = ( - HandoffBuilder(participants=[triage, refund, shipping]) - .starting_agent("triage_agent") + HandoffBuilder(participants=[coordinator, refund, shipping]) + .coordinator("coordinator_agent") .with_termination_condition( lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5 or any("goodbye" in msg.text.lower() for msg in conv[-2:]) @@ -788,8 +768,8 @@ class HandoffBuilder: storage = InMemoryCheckpointStorage() workflow = ( - HandoffBuilder(participants=[triage, refund, shipping]) - .starting_agent("triage_agent") + HandoffBuilder(participants=[coordinator, refund, shipping]) + .coordinator("coordinator_agent") .with_checkpointing(storage) .build() ) @@ -797,11 +777,11 @@ class HandoffBuilder: Args: name: Optional workflow name for identification and logging. participants: List of agents (AgentProtocol) or executors to participate in the handoff. - The first agent you specify as starting_agent becomes the triage agent. + The first agent you specify as coordinator becomes the orchestrating agent. description: Optional human-readable description of the workflow. Raises: - ValueError: If participants list is empty, contains duplicates, or starting_agent not specified. + ValueError: If participants list is empty, contains duplicates, or coordinator not specified. TypeError: If participants are not AgentProtocol or Executor instances. """ @@ -816,7 +796,7 @@ def __init__( The builder starts in an unconfigured state and requires you to call: 1. `.participants([...])` - Register agents - 2. `.starting_agent(...)` - Designate which agent receives initial user input + 2. `.coordinator(...)` - Designate which agent receives initial user input 3. `.build()` - Construct the final Workflow Optional configuration methods allow you to customize context management, @@ -835,7 +815,7 @@ def __init__( Note: Participants must have stable names/ids because the workflow maps the handoff tool arguments to these identifiers. Agent names should match - the strings emitted by the triage agent's handoff tool (e.g., a tool that + the strings emitted by the coordinator's handoff tool (e.g., a tool that outputs ``{\"handoff_to\": \"billing\"}`` requires an agent named ``billing``). """ self._name = name @@ -847,7 +827,7 @@ def __init__( self._request_prompt: str | None = None self._termination_condition: Callable[[list[ChatMessage]], bool] = _default_termination_condition self._auto_register_handoff_tools: bool = True - self._handoff_map: dict[str, list[str]] | None = None # Maps agent_id -> [target_agent_ids] + self._handoff_config: dict[str, list[str]] = {} # Maps agent_id -> [target_agent_ids] if participants: self.participants(participants) @@ -879,16 +859,16 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han from agent_framework.openai import OpenAIChatClient client = OpenAIChatClient() - triage = client.create_agent(instructions="...", name="triage") + coordinator = client.create_agent(instructions="...", name="coordinator") refund = client.create_agent(instructions="...", name="refund_agent") billing = client.create_agent(instructions="...", name="billing_agent") - builder = HandoffBuilder().participants([triage, refund, billing]) - # Now you can call .starting_agent() to designate the entry point + builder = HandoffBuilder().participants([coordinator, refund, billing]) + # Now you can call .coordinator() to designate the entry point Note: - This method resets any previously configured starting_agent, so you must call - `.starting_agent(...)` again after changing participants. + This method resets any previously configured coordinator, so you must call + `.coordinator(...)` again after changing participants. """ if not participants: raise ValueError("participants cannot be empty") @@ -925,19 +905,19 @@ def _register_alias(alias: str | None, exec_id: str) -> None: self._starting_agent_id = None return self - def starting_agent(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder": - r"""Designate which agent receives initial user input and acts as the triage/dispatcher. + def coordinator(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder": + r"""Designate which agent receives initial user input and orchestrates specialist routing. - The starting agent is responsible for analyzing user requests and deciding whether to: + The coordinator agent is responsible for analyzing user requests and deciding whether to: 1. Handle the request directly and respond to the user, OR 2. Hand off to a specialist agent by including handoff metadata in the response After a specialist responds, the workflow automatically returns control to the user, - creating a cyclical flow: user -> starting_agent -> [specialist] -> user -> ... + creating a cyclical flow: user -> coordinator -> [specialist] -> user -> ... Args: - agent: The agent to use as the entry point. Can be: - - Agent name (str): e.g., "triage_agent" + agent: The agent to use as the coordinator. Can be: + - Agent name (str): e.g., "coordinator_agent" - AgentProtocol instance: The actual agent object - Executor instance: A custom executor wrapping an agent @@ -952,67 +932,142 @@ def starting_agent(self, agent: str | AgentProtocol | Executor) -> "HandoffBuild .. code-block:: python - builder = ( - HandoffBuilder().participants([triage, refund, billing]).starting_agent("triage") # Use agent name - ) + # Use agent name + builder = HandoffBuilder().participants([coordinator, refund, billing]).coordinator("coordinator") - # Or pass the agent object directly: - builder = ( - HandoffBuilder().participants([triage, refund, billing]).starting_agent(triage) # Use agent instance - ) + # Or pass the agent object directly + builder = HandoffBuilder().participants([coordinator, refund, billing]).coordinator(coordinator) Note: - The starting agent determines routing by invoking a handoff tool call whose + The coordinator determines routing by invoking a handoff tool call whose arguments identify the target specialist (for example ``{\"handoff_to\": \"billing\"}``). Decorate the tool with ``approval_mode="always_require"`` to ensure the workflow intercepts the call before execution and can make the transition. """ if not self._executors: - raise ValueError("Call participants(...) before starting_agent(...)") + raise ValueError("Call participants(...) before coordinator(...)") resolved = self._resolve_to_id(agent) if resolved not in self._executors: - raise ValueError(f"starting_agent '{resolved}' is not part of the participants list") + raise ValueError(f"coordinator '{resolved}' is not part of the participants list") self._starting_agent_id = resolved return self - def with_handoffs(self, handoff_map: dict[str, list[str]]) -> "HandoffBuilder": - """Configure which agents can hand off to which other agents. - - By default, only the starting agent can hand off to all other participants. - Use this method to enable specialist-to-specialist handoffs, where any agent - can delegate to specific other agents. + def add_handoff( + self, + source: str | AgentProtocol | Executor, + targets: str | AgentProtocol | Executor | Sequence[str | AgentProtocol | Executor], + *, + tool_name: str | None = None, + tool_description: str | None = None, + ) -> "HandoffBuilder": + """Add handoff routing from a source agent to one or more target agents. - The handoff map keys can be agent names, display names, or executor IDs. - The values are lists of target agent identifiers that the source agent can hand off to. + This method enables specialist-to-specialist handoffs by configuring which agents + can hand off to which others. Call this method multiple times to build a complete + routing graph. By default, only the starting agent can hand off to all other participants; + use this method to enable additional routing paths. Args: - handoff_map: Dictionary mapping source agent identifiers to lists of target - agent identifiers. For example: - { - "triage_agent": ["billing_agent", "support_agent"], - "support_agent": ["escalation_agent"], - } + source: The agent that can initiate the handoff. Can be: + - Agent name (str): e.g., "triage_agent" + - AgentProtocol instance: The actual agent object + - Executor instance: A custom executor wrapping an agent + targets: One or more target agents that the source can hand off to. Can be: + - Single agent: "billing_agent" or agent_instance + - Multiple agents: ["billing_agent", "support_agent"] or [agent1, agent2] + tool_name: Optional custom name for the handoff tool. If not provided, generates + "handoff_to_" for single targets or "handoff_to__agent" + for multiple targets based on target names. + tool_description: Optional custom description for the handoff tool. If not provided, + generates "Handoff to the agent." Returns: Self for method chaining. - Example: - >>> workflow = ( - ... HandoffBuilder(participants=[triage, billing, support, escalation]) - ... .starting_agent("triage_agent") - ... .with_handoffs({ - ... "triage_agent": ["billing_agent", "support_agent"], - ... "support_agent": ["escalation_agent"], - ... }) - ... .build() - ... ) + Raises: + ValueError: If source or targets are not in the participants list, or if + participants(...) hasn't been called yet. + + Examples: + Single target: + + .. code-block:: python + + builder.add_handoff("triage_agent", "billing_agent") + + Multiple targets (using agent names): + + .. code-block:: python + + builder.add_handoff("triage_agent", ["billing_agent", "support_agent", "escalation_agent"]) + + Multiple targets (using agent instances): + + .. code-block:: python + + builder.add_handoff(triage, [billing, support, escalation]) + + Chain multiple configurations: + + .. code-block:: python + + workflow = ( + HandoffBuilder(participants=[triage, replacement, delivery, billing]) + .coordinator(triage) + .add_handoff(triage, [replacement, delivery, billing]) + .add_handoff(replacement, [delivery, billing]) + .add_handoff(delivery, billing) + .build() + ) + + Custom tool names and descriptions: + + .. code-block:: python + + builder.add_handoff( + "support_agent", + "escalation_agent", + tool_name="escalate_to_l2", + tool_description="Escalate this issue to Level 2 support", + ) Note: - - Handoff tools will be automatically registered for each agent based on this map - - If not specified, only the starting agent gets handoff tools to all other agents - - Agents not in the map cannot hand off to anyone (except the starting agent by default) + - Handoff tools are automatically registered for each source agent + - If a source agent is configured multiple times via add_handoff, targets are merged + - Custom tool_name and tool_description only apply when targets is a single agent """ - self._handoff_map = dict(handoff_map) + if not self._executors: + raise ValueError("Call participants(...) before add_handoff(...)") + + # Resolve source agent ID + source_id = self._resolve_to_id(source) + if source_id not in self._executors: + raise ValueError(f"Source agent '{source}' is not in the participants list") + + # Normalize targets to list + target_list = [targets] if isinstance(targets, (str, AgentProtocol, Executor)) else list(targets) + + # Resolve all target IDs + target_ids: list[str] = [] + for target in target_list: + target_id = self._resolve_to_id(target) + if target_id not in self._executors: + raise ValueError(f"Target agent '{target}' is not in the participants list") + target_ids.append(target_id) + + # Merge with existing handoff configuration for this source + if source_id in self._handoff_config: + # Add new targets to existing list, avoiding duplicates + existing = self._handoff_config[source_id] + for target_id in target_ids: + if target_id not in existing: + existing.append(target_id) + else: + self._handoff_config[source_id] = target_ids + + # NOTE: custom tool_name and tool_description parameters are reserved for future use + # They will be implemented when we refactor _create_handoff_tool to support customization + return self def auto_register_handoff_tools(self, enabled: bool) -> "HandoffBuilder": @@ -1101,14 +1156,6 @@ def _prepare_agent_with_handoffs( ) return new_executor, tool_targets - def _prepare_starting_agent( - self, - executor: AgentExecutor, - specialists: Mapping[str, Executor], - ) -> tuple[AgentExecutor, dict[str, str]]: - """Legacy method - delegates to _prepare_agent_with_handoffs.""" - return self._prepare_agent_with_handoffs(executor, specialists) - def request_prompt(self, prompt: str | None) -> "HandoffBuilder": """Set a custom prompt message displayed when requesting user input. @@ -1128,7 +1175,7 @@ def request_prompt(self, prompt: str | None) -> "HandoffBuilder": workflow = ( HandoffBuilder(participants=[triage, refund, billing]) - .starting_agent("triage") + .coordinator("triage") .request_prompt("How can we help you today?") .build() ) @@ -1170,7 +1217,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "HandoffB storage = InMemoryCheckpointStorage() workflow = ( HandoffBuilder(participants=[triage, refund, billing]) - .starting_agent("triage") + .coordinator("triage") .with_checkpointing(storage) .build() ) @@ -1234,14 +1281,14 @@ def build(self) -> Workflow: A fully configured Workflow ready to execute via `.run()` or `.run_stream()`. Raises: - ValueError: If participants or starting_agent were not configured, or if + ValueError: If participants or coordinator were not configured, or if required configuration is invalid. Example (Minimal): .. code-block:: python - workflow = HandoffBuilder(participants=[triage, refund, billing]).starting_agent("triage").build() + workflow = HandoffBuilder(participants=[coordinator, refund, billing]).coordinator("coordinator").build() # Run the workflow async for event in workflow.run_stream("I need help"): @@ -1258,10 +1305,10 @@ def build(self) -> Workflow: workflow = ( HandoffBuilder( name="support_workflow", - participants=[triage, refund, billing], + participants=[coordinator, refund, billing], description="Customer support with specialist routing", ) - .starting_agent("triage") + .coordinator("coordinator") .with_termination_condition(lambda conv: len(conv) > 20) .request_prompt("How can we help?") .with_checkpointing(storage) @@ -1275,7 +1322,7 @@ def build(self) -> Workflow: if not self._executors: raise ValueError("No participants provided. Call participants([...]) first.") if self._starting_agent_id is None: - raise ValueError("starting_agent must be defined before build().") + raise ValueError("coordinator must be defined before build().") starting_executor = self._executors[self._starting_agent_id] specialists = { @@ -1286,43 +1333,39 @@ def build(self) -> Workflow: handoff_tool_targets: dict[str, str] = {} if self._auto_register_handoff_tools: # Determine which agents should have handoff tools - if self._handoff_map: - # Use explicit handoff map - for source_agent_id, target_agent_ids in self._handoff_map.items(): - # Resolve source agent ID - source_exec_id = self._resolve_agent_id(source_agent_id) - if source_exec_id not in self._executors: - raise ValueError(f"Handoff source agent '{source_agent_id}' not found in participants") - - executor = self._executors[source_exec_id] + if self._handoff_config: + # Use explicit handoff configuration from add_handoff() calls + for source_exec_id, target_exec_ids in self._handoff_config.items(): + executor = self._executors.get(source_exec_id) + if not executor: + raise ValueError(f"Handoff source agent '{source_exec_id}' not found in participants") + if isinstance(executor, AgentExecutor): - # Resolve target agent IDs and prepare this agent + # Build targets map for this source agent targets_map: dict[str, Executor] = {} - for target_id in target_agent_ids: - target_exec_id = self._resolve_agent_id(target_id) - if target_exec_id not in self._executors: - raise ValueError(f"Handoff target agent '{target_id}' not found in participants") - targets_map[target_exec_id] = self._executors[target_exec_id] + for target_exec_id in target_exec_ids: + target_executor = self._executors.get(target_exec_id) + if not target_executor: + raise ValueError(f"Handoff target agent '{target_exec_id}' not found in participants") + targets_map[target_exec_id] = target_executor # Register handoff tools for this agent updated_executor, tool_targets = self._prepare_agent_with_handoffs(executor, targets_map) self._executors[source_exec_id] = updated_executor handoff_tool_targets.update(tool_targets) - else: - # Default behavior: only starting agent gets handoff tools to all specialists - if isinstance(starting_executor, AgentExecutor) and specialists: - starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists) - self._executors[self._starting_agent_id] = starting_executor - handoff_tool_targets.update(tool_targets) - - # Update references after potential agent modifications + else: + # Default behavior: only coordinator gets handoff tools to all specialists + if isinstance(starting_executor, AgentExecutor) and specialists: + starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists) + self._executors[self._starting_agent_id] = starting_executor + handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications starting_executor = self._executors[self._starting_agent_id] specialists = { exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id } if not specialists: - logger.warning("Handoff workflow has no specialist agents; the starting agent will loop with the user.") + logger.warning("Handoff workflow has no specialist agents; the coordinator will loop with the user.") input_node = _InputToConversation(id="input-conversation") request_info = RequestInfoExecutor(id=f"{starting_executor.id}_handoff_requests") diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 8071ad511a..ee0a39d56c 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -130,7 +130,7 @@ async def test_handoff_routes_to_specialist_and_requests_user_input(): triage = _RecordingAgent(name="triage", handoff_to="specialist") specialist = _RecordingAgent(name="specialist") - workflow = HandoffBuilder(participants=[triage, specialist]).starting_agent("triage").build() + workflow = HandoffBuilder(participants=[triage, specialist]).coordinator("triage").build() events = await _drain(workflow.run_stream("Need help with a refund")) @@ -149,18 +149,16 @@ async def test_handoff_routes_to_specialist_and_requests_user_input(): async def test_specialist_to_specialist_handoff(): - """Test that specialists can hand off to other specialists via .with_handoffs() configuration.""" + """Test that specialists can hand off to other specialists via .add_handoff() configuration.""" triage = _RecordingAgent(name="triage", handoff_to="specialist") specialist = _RecordingAgent(name="specialist", handoff_to="escalation") escalation = _RecordingAgent(name="escalation") workflow = ( HandoffBuilder(participants=[triage, specialist, escalation]) - .starting_agent("triage") - .with_handoffs({ - "triage": ["specialist", "escalation"], - "specialist": ["escalation"], - }) + .coordinator(triage) + .add_handoff(triage, [specialist, escalation]) + .add_handoff(specialist, escalation) .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2) .build() ) @@ -193,7 +191,7 @@ async def test_handoff_preserves_complex_additional_properties(complex_metadata: workflow = ( HandoffBuilder(participants=[triage, specialist]) - .starting_agent("triage") + .coordinator("triage") .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role == Role.USER) >= 2) .build() ) @@ -256,7 +254,7 @@ async def test_text_based_handoff_detection(): triage = _RecordingAgent(name="triage", handoff_to="specialist", text_handoff=True) specialist = _RecordingAgent(name="specialist") - workflow = HandoffBuilder(participants=[triage, specialist]).starting_agent("triage").build() + workflow = HandoffBuilder(participants=[triage, specialist]).coordinator("triage").build() _ = await _drain(workflow.run_stream("Package arrived broken")) @@ -271,7 +269,7 @@ async def test_multiple_runs_dont_leak_conversation(): workflow = ( HandoffBuilder(participants=[triage, specialist]) - .starting_agent("triage") + .coordinator("triage") .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2) .build() ) diff --git a/python/samples/getting_started/workflows/README.md b/python/samples/getting_started/workflows/README.md index 45b8253672..26e8cdd3ba 100644 --- a/python/samples/getting_started/workflows/README.md +++ b/python/samples/getting_started/workflows/README.md @@ -90,7 +90,7 @@ Once comfortable with these, explore the rest of the samples below. | 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 | | 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 `.with_handoffs()` configuration | +| 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 | | Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution | | Magentic + Checkpoint Resume | [orchestration/magentic_checkpoint.py](./orchestration/magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints | @@ -101,8 +101,8 @@ Once comfortable with these, explore the rest of the samples below. **Handoff workflow tip**: Handoff workflows maintain the full conversation history including any `ChatMessage.additional_properties` emitted by your agents. This ensures routing metadata remains -intact across all agent transitions. For specialist-to-specialist handoffs, use `.with_handoffs()` -to configure which agents can route to which others. +intact across all agent transitions. For specialist-to-specialist handoffs, use `.add_handoff(source, targets)` +to configure which agents can route to which others with a fluent, type-safe API. ### parallelism diff --git a/python/samples/getting_started/workflows/orchestration/handoff_simple.py b/python/samples/getting_started/workflows/orchestration/handoff_simple.py index ff24f04826..382b2af0cc 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_simple.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_simple.py @@ -205,7 +205,7 @@ async def main() -> None: name="customer_support_handoff", participants=[triage, refund, order, support], ) - .starting_agent("triage_agent") + .coordinator("triage_agent") .with_termination_condition( # Terminate after 4 user messages (initial + 3 scripted responses) # Count only USER role messages to avoid counting agent responses diff --git a/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py b/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py index 3f5bc716c8..81abb21d1f 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py @@ -155,31 +155,33 @@ async def main() -> None: chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) triage, replacement, delivery, billing = create_agents(chat_client) - # Configure multi-tier handoffs explicitly + # Configure multi-tier handoffs using fluent add_handoff() API # This allows specialists to hand off to other specialists workflow = ( HandoffBuilder( name="multi_tier_support", participants=[triage, replacement, delivery, billing], ) - .starting_agent("triage_agent") - .with_handoffs({ - # Triage can route to any specialist - "triage_agent": ["replacement_agent", "delivery_agent", "billing_agent"], - # Replacement can delegate to delivery or billing - "replacement_agent": ["delivery_agent", "billing_agent"], - # Delivery can escalate to billing if needed - "delivery_agent": ["billing_agent"], - }) - .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4) + .coordinator(triage) + .add_handoff(triage, [replacement, delivery, billing]) # Triage can route to any specialist + .add_handoff(replacement, [delivery, billing]) # Replacement can delegate to delivery or billing + .add_handoff(delivery, billing) # Delivery can escalate to billing + # Termination condition: Stop when more than 4 user messages exist. + # This allows agents to respond to the 4th user message before the 5th triggers termination. + # In this sample: initial message + 3 scripted responses = 4 messages, then 5th message ends workflow. + .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") > 4) .build() ) # Scripted user responses simulating a multi-tier handoff scenario + # Note: The initial run_stream() call sends the first user message, + # then these scripted responses are sent in sequence (total: 4 user messages). + # A 5th response triggers termination after agents respond to the 4th message. scripted_responses = [ "I need help with order 12345. I want a replacement and need to know when it will arrive.", "The item arrived damaged. I'd like a replacement shipped to the same address.", "Great! Can you confirm the shipping cost won't be charged again?", + "Thank you!", # Final response to trigger termination after billing agent answers ] print("\n" + "=" * 80) @@ -208,16 +210,76 @@ async def main() -> None: response_index += 1 - print("\n" + "=" * 80) - print("DEMONSTRATION COMPLETE") - print("=" * 80) - print("\nKey observations:") - print("1. Triage correctly routed to Replacement agent") - print("2. Replacement agent delegated to Delivery for shipping info") - print("3. Delivery agent could escalate to Billing if needed") - print("4. User only intervened when additional input was required") - print("5. Agents collaborated seamlessly across tiers") - print("=" * 80 + "\n") + """ + Sample Output: + + ================================================================================ + SPECIALIST-TO-SPECIALIST HANDOFF DEMONSTRATION + ================================================================================ + + Scenario: Customer needs replacement + shipping info + billing confirmation + Expected flow: User → Triage → Replacement → Delivery → Billing → User + ================================================================================ + + [User]: I need help with order 12345. I want a replacement and need to know when it will arrive. + + + === User Input Requested === + Last 5 messages in conversation: + user: I need help with order 12345. I want a replacement and need to know when it will arrive. + triage_agent: I'm connecting you to our replacement team to assist with your request, and to our delivery team for... + replacement_agent: To assist with your replacement for order 12345 and provide tracking details for delivery, I've reac... + delivery_agent: I'm handing over your request for a replacement of order 12345, as well as your inquiry about estima... + billing_agent: I handle billing and payment questions. For replacement and delivery details for order 12345, please... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User]: I need help with order 12345. I want a replacement and need to know when it will arrive. + + + === User Input Requested === + Last 7 messages in conversation: + replacement_agent: To assist with your replacement for order 12345 and provide tracking details for delivery, I've reac... + delivery_agent: I'm handing over your request for a replacement of order 12345, as well as your inquiry about estima... + billing_agent: I handle billing and payment questions. For replacement and delivery details for order 12345, please... + user: I need help with order 12345. I want a replacement and need to know when it will arrive. + triage_agent: I'm connecting you with our replacement team to help with your request, and our delivery team for in... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User]: The item arrived damaged. I'd like a replacement shipped to the same address. + + + === User Input Requested === + Last 12 messages in conversation: + user: The item arrived damaged. I'd like a replacement shipped to the same address. + triage_agent: Thank you for letting us know your item from order 12345 arrived damaged. Im connecting you to our ... + replacement_agent: Your replacement request for order 12345 (damaged item) will be processed, and shipped to your curre... + delivery_agent: I've forwarded your request for a replacement of the damaged item from order 12345 to our team and a... + billing_agent: Thank you for the information. I've forwarded your request to our support team to arrange a replacem... + ============================ + [status] IDLE_WITH_PENDING_REQUESTS + + [User]: Great! Can you confirm the shipping cost won't be charged again? + + + === Final Conversation === + - user: I need help with order 12345. I want a replacement and need to know when it will arrive. + - triage_agent: I'm connecting you to our replacement team to assist with your request, and to our delivery team for updates on when your replacement will arrive. They'll help you shortly! + - replacement_agent: To assist with your replacement for order 12345 and provide tracking details for delivery, I've reached out to our teams. Can you please provide the reason for your replacement request? This will help to process things faster. + - delivery_agent: I'm handing over your request for a replacement of order 12345, as well as your inquiry about estimated delivery timing, to the appropriate team. They'll assist you further with the replacement and delivery details very soon. + - billing_agent: I handle billing and payment questions. For replacement and delivery details for order 12345, please contact our support team or the order department. Is there a billing or payment issue I can help you with? + - user: I need help with order 12345. I want a replacement and need to know when it will arrive. + - triage_agent: I'm connecting you with our replacement team to help with your request, and our delivery team for information about when your replacement will arrive. They'll assist you shortly! + - user: The item arrived damaged. I'd like a replacement shipped to the same address. + - triage_agent: Thank you for letting us know your item from order 12345 arrived damaged. I'm connecting you to our replacement team to arrange a replacement shipped to your address, and to our delivery team to update you on the arrival date of your replacement. They'll assist you shortly! + - replacement_agent: Your replacement request for order 12345 (damaged item) will be processed, and shipped to your current address. The delivery team will also provide tracking and estimated arrival details for your new item soon. + - delivery_agent: I've forwarded your request for a replacement of the damaged item from order 12345 to our team and asked them to ship it to your current address. You'll also receive tracking information and an estimated delivery date for the replacement soon. + - billing_agent: Thank you for the information. I've forwarded your request to our support team to arrange a replacement for the damaged item from order 12345. Your replacement will be shipped to the same address, and you'll receive delivery updates soon. If you need a refund instead or have any billing questions, please let me know. + - user: Great! Can you confirm the shipping cost won't be charged again? + ========================== + [status] IDLE + """ # noqa: E501 if __name__ == "__main__": From 639735e34db09d5d10213760b57f0e5aafdbf7ea Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 16 Oct 2025 14:17:44 +0900 Subject: [PATCH 07/13] More cleanup --- .../packages/core/agent_framework/_workflows/_handoff.py | 2 -- .../workflows/orchestration/handoff_simple.py | 9 +++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 3eb5c96b58..1beefb2919 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -513,8 +513,6 @@ def _get_cleaned_conversation(self, conversation: list[ChatMessage]) -> list[Cha - User messages - Assistant messages with text content (tool calls are stripped out) """ - from agent_framework import FunctionApprovalRequestContent, FunctionCallContent - # Create a copy to avoid modifying the original cleaned: list[ChatMessage] = [] for msg in conversation: diff --git a/python/samples/getting_started/workflows/orchestration/handoff_simple.py b/python/samples/getting_started/workflows/orchestration/handoff_simple.py index 382b2af0cc..52f39d6fce 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_simple.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_simple.py @@ -215,7 +215,7 @@ async def main() -> None: ) # Scripted user responses for reproducible demo - # In a real application, replace this with: + # In a console application, replace this with: # user_input = input("Your response: ") # or integrate with a UI/chat interface scripted_responses = [ @@ -234,11 +234,9 @@ async def main() -> None: # The workflow will continue requesting input until: # 1. The termination condition is met (4 user messages in this case), OR # 2. We run out of scripted responses - response_index = 0 - - while pending_requests and response_index < len(scripted_responses): + while pending_requests and scripted_responses: # Get the next scripted response - user_response = scripted_responses[response_index] + user_response = scripted_responses.pop(0) print(f"\n[User responding: {user_response}]") # Send response(s) to all pending requests @@ -248,7 +246,6 @@ async def main() -> None: # Send responses and get new events events = await _drain(workflow.send_responses_streaming(responses)) pending_requests = _handle_events(events) - response_index += 1 """ Sample Output: From 6e4c9c518fb77781400ab7c2ec1bac7d3dd2be13 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 17 Oct 2025 09:21:36 +0900 Subject: [PATCH 08/13] Improvements --- .../agent_framework/_workflows/_handoff.py | 32 +++++----- .../core/tests/workflow/test_handoff.py | 25 ++++++-- .../workflows/orchestration/handoff_simple.py | 6 +- .../handoff_specialist_to_specialist.py | 4 +- python/test_specialist_handoff.py | 62 ------------------- 5 files changed, 42 insertions(+), 87 deletions(-) delete mode 100644 python/test_specialist_handoff.py diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index 1beefb2919..e2fb03ff51 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -714,7 +714,7 @@ class HandoffBuilder: name="customer_support", participants=[coordinator, refund, shipping], ) - .coordinator("coordinator_agent") + .set_coordinator("coordinator_agent") .build() ) @@ -733,7 +733,7 @@ class HandoffBuilder: # Enable specialist-to-specialist handoffs with fluent API workflow = ( HandoffBuilder(participants=[coordinator, replacement, delivery, billing]) - .coordinator("coordinator_agent") + .set_coordinator("coordinator_agent") .add_handoff(coordinator, [replacement, delivery, billing]) # Coordinator routes to all .add_handoff(replacement, [delivery, billing]) # Replacement delegates to delivery/billing .add_handoff(delivery, billing) # Delivery escalates to billing @@ -750,7 +750,7 @@ class HandoffBuilder: # Terminate when user says goodbye or after 5 exchanges workflow = ( HandoffBuilder(participants=[coordinator, refund, shipping]) - .coordinator("coordinator_agent") + .set_coordinator("coordinator_agent") .with_termination_condition( lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 5 or any("goodbye" in msg.text.lower() for msg in conv[-2:]) @@ -767,7 +767,7 @@ class HandoffBuilder: storage = InMemoryCheckpointStorage() workflow = ( HandoffBuilder(participants=[coordinator, refund, shipping]) - .coordinator("coordinator_agent") + .set_coordinator("coordinator_agent") .with_checkpointing(storage) .build() ) @@ -794,7 +794,7 @@ def __init__( The builder starts in an unconfigured state and requires you to call: 1. `.participants([...])` - Register agents - 2. `.coordinator(...)` - Designate which agent receives initial user input + 2. `.set_coordinator(...)` - Designate which agent receives initial user input 3. `.build()` - Construct the final Workflow Optional configuration methods allow you to customize context management, @@ -862,11 +862,11 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han billing = client.create_agent(instructions="...", name="billing_agent") builder = HandoffBuilder().participants([coordinator, refund, billing]) - # Now you can call .coordinator() to designate the entry point + # Now you can call .set_coordinator() to designate the entry point Note: This method resets any previously configured coordinator, so you must call - `.coordinator(...)` again after changing participants. + `.set_coordinator(...)` again after changing participants. """ if not participants: raise ValueError("participants cannot be empty") @@ -903,7 +903,7 @@ def _register_alias(alias: str | None, exec_id: str) -> None: self._starting_agent_id = None return self - def coordinator(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder": + def set_coordinator(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder": r"""Designate which agent receives initial user input and orchestrates specialist routing. The coordinator agent is responsible for analyzing user requests and deciding whether to: @@ -931,10 +931,10 @@ def coordinator(self, agent: str | AgentProtocol | Executor) -> "HandoffBuilder" .. code-block:: python # Use agent name - builder = HandoffBuilder().participants([coordinator, refund, billing]).coordinator("coordinator") + builder = HandoffBuilder().participants([coordinator, refund, billing]).set_coordinator("coordinator") # Or pass the agent object directly - builder = HandoffBuilder().participants([coordinator, refund, billing]).coordinator(coordinator) + builder = HandoffBuilder().participants([coordinator, refund, billing]).set_coordinator(coordinator) Note: The coordinator determines routing by invoking a handoff tool call whose @@ -1011,7 +1011,7 @@ def add_handoff( workflow = ( HandoffBuilder(participants=[triage, replacement, delivery, billing]) - .coordinator(triage) + .set_coordinator(triage) .add_handoff(triage, [replacement, delivery, billing]) .add_handoff(replacement, [delivery, billing]) .add_handoff(delivery, billing) @@ -1173,7 +1173,7 @@ def request_prompt(self, prompt: str | None) -> "HandoffBuilder": workflow = ( HandoffBuilder(participants=[triage, refund, billing]) - .coordinator("triage") + .set_coordinator("triage") .request_prompt("How can we help you today?") .build() ) @@ -1215,7 +1215,7 @@ def with_checkpointing(self, checkpoint_storage: CheckpointStorage) -> "HandoffB storage = InMemoryCheckpointStorage() workflow = ( HandoffBuilder(participants=[triage, refund, billing]) - .coordinator("triage") + .set_coordinator("triage") .with_checkpointing(storage) .build() ) @@ -1286,7 +1286,9 @@ def build(self) -> Workflow: .. code-block:: python - workflow = HandoffBuilder(participants=[coordinator, refund, billing]).coordinator("coordinator").build() + workflow = ( + HandoffBuilder(participants=[coordinator, refund, billing]).set_coordinator("coordinator").build() + ) # Run the workflow async for event in workflow.run_stream("I need help"): @@ -1306,7 +1308,7 @@ def build(self) -> Workflow: participants=[coordinator, refund, billing], description="Customer support with specialist routing", ) - .coordinator("coordinator") + .set_coordinator("coordinator") .with_termination_condition(lambda conv: len(conv) > 20) .request_prompt("How can we help?") .with_checkpointing(storage) diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index ee0a39d56c..36a13bd42e 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -130,7 +130,7 @@ async def test_handoff_routes_to_specialist_and_requests_user_input(): triage = _RecordingAgent(name="triage", handoff_to="specialist") specialist = _RecordingAgent(name="specialist") - workflow = HandoffBuilder(participants=[triage, specialist]).coordinator("triage").build() + workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator("triage").build() events = await _drain(workflow.run_stream("Need help with a refund")) @@ -156,7 +156,7 @@ async def test_specialist_to_specialist_handoff(): workflow = ( HandoffBuilder(participants=[triage, specialist, escalation]) - .coordinator(triage) + .set_coordinator(triage) .add_handoff(triage, [specialist, escalation]) .add_handoff(specialist, escalation) .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2) @@ -191,7 +191,7 @@ async def test_handoff_preserves_complex_additional_properties(complex_metadata: workflow = ( HandoffBuilder(participants=[triage, specialist]) - .coordinator("triage") + .set_coordinator("triage") .with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role == Role.USER) >= 2) .build() ) @@ -254,7 +254,7 @@ async def test_text_based_handoff_detection(): triage = _RecordingAgent(name="triage", handoff_to="specialist", text_handoff=True) specialist = _RecordingAgent(name="specialist") - workflow = HandoffBuilder(participants=[triage, specialist]).coordinator("triage").build() + workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator("triage").build() _ = await _drain(workflow.run_stream("Package arrived broken")) @@ -262,6 +262,21 @@ async def test_text_based_handoff_detection(): assert len(specialist.calls[0]) >= 2 +def test_build_fails_without_coordinator(): + """Verify that build() raises ValueError when set_coordinator() was not called.""" + triage = _RecordingAgent(name="triage") + specialist = _RecordingAgent(name="specialist") + + with pytest.raises(ValueError, match="coordinator must be defined before build"): + HandoffBuilder(participants=[triage, specialist]).build() + + +def test_build_fails_without_participants(): + """Verify that build() raises ValueError when no participants are provided.""" + with pytest.raises(ValueError, match="No participants provided"): + HandoffBuilder().build() + + async def test_multiple_runs_dont_leak_conversation(): """Verify that running the same workflow multiple times doesn't leak conversation history.""" triage = _RecordingAgent(name="triage", handoff_to="specialist") @@ -269,7 +284,7 @@ async def test_multiple_runs_dont_leak_conversation(): workflow = ( HandoffBuilder(participants=[triage, specialist]) - .coordinator("triage") + .set_coordinator("triage") .with_termination_condition(lambda conv: sum(1 for m in conv if m.role == Role.USER) >= 2) .build() ) diff --git a/python/samples/getting_started/workflows/orchestration/handoff_simple.py b/python/samples/getting_started/workflows/orchestration/handoff_simple.py index 52f39d6fce..6092083266 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_simple.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_simple.py @@ -196,8 +196,8 @@ async def main() -> None: triage, refund, order, support = create_agents(chat_client) # Build the handoff workflow - # - participants: All agents that can participate (triage MUST be first or explicitly set as starting_agent) - # - starting_agent: The triage agent receives all user input first + # - participants: All agents that can participate (triage MUST be first or explicitly set as set_coordinator) + # - set_coordinator: The triage agent receives all user input first # - with_termination_condition: Custom logic to stop the request/response loop # Default is 10 user messages; here we terminate after 4 to match our scripted demo workflow = ( @@ -205,7 +205,7 @@ async def main() -> None: name="customer_support_handoff", participants=[triage, refund, order, support], ) - .coordinator("triage_agent") + .set_coordinator("triage_agent") .with_termination_condition( # Terminate after 4 user messages (initial + 3 scripted responses) # Count only USER role messages to avoid counting agent responses diff --git a/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py b/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py index 81abb21d1f..5e92a6325c 100644 --- a/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py +++ b/python/samples/getting_started/workflows/orchestration/handoff_specialist_to_specialist.py @@ -16,7 +16,7 @@ - Technical support needs billing info → hands off to billing agent - Level 1 support escalates to Level 2 → hands off to escalation agent -Configuration uses `.with_handoffs()` to explicitly define the routing graph. +Configuration uses `.add_handoff()` to explicitly define the routing graph. Prerequisites: - `az login` (Azure CLI authentication) @@ -162,7 +162,7 @@ async def main() -> None: name="multi_tier_support", participants=[triage, replacement, delivery, billing], ) - .coordinator(triage) + .set_coordinator(triage) .add_handoff(triage, [replacement, delivery, billing]) # Triage can route to any specialist .add_handoff(replacement, [delivery, billing]) # Replacement can delegate to delivery or billing .add_handoff(delivery, billing) # Delivery can escalate to billing diff --git a/python/test_specialist_handoff.py b/python/test_specialist_handoff.py deleted file mode 100644 index 7ede154207..0000000000 --- a/python/test_specialist_handoff.py +++ /dev/null @@ -1,62 +0,0 @@ -# Test specialist-to-specialist handoffs - -import asyncio - -from agent_framework import HandoffBuilder -from agent_framework.azure import AzureOpenAIChatClient -from azure.identity import AzureCliCredential - - -async def main(): - """Test specialist-to-specialist handoffs.""" - chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - - # Create three agents: triage, replacement, and delivery - triage = chat_client.create_agent( - instructions=("You are a triage agent. Route replacement issues to 'replacement_agent'. Be concise."), - name="triage_agent", - ) - - replacement = chat_client.create_agent( - instructions=( - "You handle product replacements. If you need delivery/shipping info, " - "hand off to 'delivery_agent' by calling handoff_to_delivery_agent. " - "Be concise." - ), - name="replacement_agent", - ) - - delivery = chat_client.create_agent( - instructions=("You handle delivery and shipping inquiries. Provide tracking info. Be concise."), - name="delivery_agent", - ) - - # Build workflow with specialist-to-specialist handoffs - workflow = ( - HandoffBuilder( - name="multi_tier_support", - participants=[triage, replacement, delivery], - ) - .starting_agent("triage_agent") - .with_handoffs({ - "triage_agent": ["replacement_agent", "delivery_agent"], - "replacement_agent": ["delivery_agent"], # Replacement can hand off to delivery - }) - .with_termination_condition(lambda conv: sum(1 for m in conv if m.role.value == "user") >= 3) - .build() - ) - - print("\n[Starting workflow with specialist-to-specialist handoffs enabled]\n") - - # Start workflow - events = [] - async for event in workflow.run_stream("I need a replacement for my damaged item and want to check shipping"): - events.append(event) - print(f"Event: {type(event).__name__}") - - print(f"\nTotal events: {len(events)}") - print("\nWorkflow complete!") - - -if __name__ == "__main__": - asyncio.run(main()) From 13a86baab3786e186dc2a26e70c9c97c7c1e7e2b Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 21 Oct 2025 09:33:55 +0900 Subject: [PATCH 09/13] PR feedback cleanup --- .../_workflows/_conversation_state.py | 14 +- .../agent_framework/_workflows/_handoff.py | 211 +++++++----------- .../core/tests/workflow/test_handoff.py | 81 ++++--- 3 files changed, 142 insertions(+), 164 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_conversation_state.py b/python/packages/core/agent_framework/_workflows/_conversation_state.py index f5f607c5d8..52910f35d3 100644 --- a/python/packages/core/agent_framework/_workflows/_conversation_state.py +++ b/python/packages/core/agent_framework/_workflows/_conversation_state.py @@ -5,7 +5,7 @@ from agent_framework import ChatMessage, Role -from ._runner_context import _decode_checkpoint_value, _encode_checkpoint_value # type: ignore +from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value # type: ignore """Utilities for serializing and deserializing chat conversations for persistence. @@ -21,12 +21,12 @@ def encode_chat_messages(messages: Iterable[ChatMessage]) -> list[dict[str, Any] encoded: list[dict[str, Any]] = [] for message in messages: encoded.append({ - "role": _encode_checkpoint_value(message.role), - "contents": [_encode_checkpoint_value(content) for content in message.contents], + "role": encode_checkpoint_value(message.role), + "contents": [encode_checkpoint_value(content) for content in message.contents], "author_name": message.author_name, "message_id": message.message_id, "additional_properties": { - key: _encode_checkpoint_value(value) for key, value in message.additional_properties.items() + key: encode_checkpoint_value(value) for key, value in message.additional_properties.items() }, }) return encoded @@ -39,7 +39,7 @@ def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[ChatMessage] if not isinstance(item, dict): continue - role_value = _decode_checkpoint_value(item.get("role")) + role_value = decode_checkpoint_value(item.get("role")) if isinstance(role_value, Role): role = role_value elif isinstance(role_value, dict): @@ -55,7 +55,7 @@ def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[ChatMessage] if isinstance(contents_field, list): contents_iter: list[Any] = contents_field # type: ignore[assignment] for entry in contents_iter: - decoded_entry: Any = _decode_checkpoint_value(entry) + decoded_entry: Any = decode_checkpoint_value(entry) contents.append(decoded_entry) additional_field = item.get("additional_properties", {}) @@ -63,7 +63,7 @@ def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[ChatMessage] if isinstance(additional_field, dict): additional_dict = cast(dict[str, Any], additional_field) for key, value in additional_dict.items(): - additional[key] = _decode_checkpoint_value(value) + additional[key] = decode_checkpoint_value(value) restored.append( ChatMessage( diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index e2fb03ff51..c006102291 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -39,17 +39,18 @@ from ._conversation_state import decode_chat_messages, encode_chat_messages from ._executor import Executor, handler from ._request_info_executor import RequestInfoExecutor, RequestInfoMessage, RequestResponse -from ._workflow import Workflow, WorkflowBuilder +from ._workflow import Workflow +from ._workflow_builder import WorkflowBuilder from ._workflow_context import WorkflowContext logger = logging.getLogger(__name__) -_HANDOFF_HINT_KEYS = ("handoff_to", "handoff", "transfer_to", "agent_id", "agent") _HANDOFF_TOOL_PATTERN = re.compile(r"(?:handoff|transfer)[_\s-]*to[_\s-]*(?P[\w-]+)", re.IGNORECASE) def _sanitize_alias(value: str) -> str: + """Normalise an agent alias into a lowercase identifier-safe string.""" cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", value).strip("_") if not cleaned: cleaned = "agent" @@ -59,6 +60,7 @@ def _sanitize_alias(value: str) -> str: 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) tool_name = f"handoff_to_{sanitized}" doc = description or f"Handoff to the {alias} agent." @@ -70,12 +72,14 @@ def _create_handoff_tool(alias: str, description: str | None = None) -> AIFuncti # with tool_calls/responses pairing when cleaning conversations. @ai_function(name=tool_name, description=doc) def _handoff_tool(context: str | None = None) -> str: + """Return a deterministic acknowledgement that encodes the target alias.""" return f"Handoff to {alias}" return _handoff_tool def _clone_chat_agent(agent: ChatAgent) -> ChatAgent: + """Produce a deep copy of the ChatAgent while preserving runtime configuration.""" options = agent.chat_options middleware = list(agent.middleware or []) @@ -127,6 +131,7 @@ class _AutoHandoffMiddleware(FunctionMiddleware): """Intercept handoff tool invocations and short-circuit execution with synthetic results.""" def __init__(self, handoff_targets: Mapping[str, str]) -> None: + """Initialise middleware with the mapping from tool name to specialist id.""" self._targets = {name.lower(): target for name, target in handoff_targets.items()} async def process( @@ -134,6 +139,7 @@ async def process( context: FunctionInvocationContext, next: Callable[[FunctionInvocationContext], Awaitable[None]], ) -> None: + """Intercept matching handoff tool calls and inject synthetic results.""" name = getattr(context.function, "name", "") normalized = name.lower() if name else "" target = self._targets.get(normalized) @@ -151,10 +157,12 @@ class _InputToConversation(Executor): @handler async def from_str(self, prompt: str, ctx: WorkflowContext[list[ChatMessage]]) -> None: + """Convert a raw user prompt into a conversation containing a single user message.""" await ctx.send_message([ChatMessage(Role.USER, text=prompt)]) @handler async def from_message(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None: # type: ignore[name-defined] + """Pass through an existing chat message as the initial conversation.""" await ctx.send_message([message]) @handler @@ -163,121 +171,82 @@ async def from_messages( messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]], ) -> None: # type: ignore[name-defined] + """Forward a list of chat messages as the starting conversation history.""" await ctx.send_message(list(messages)) -def _extract_from_mapping(mapping: Mapping[str, Any]) -> str | None: - for key in _HANDOFF_HINT_KEYS: - value = mapping.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - @dataclass class _HandoffResolution: + """Result of handoff detection containing the target alias and originating call.""" + target: str function_call: FunctionCallContent | None = None def _resolve_handoff_target(agent_response: AgentRunResponse) -> _HandoffResolution | None: - """Detect handoff intent from tool invocation metadata (approval-gated or raw calls).""" - # Check agent_response.value for handoff target - if agent_response.value: - if isinstance(agent_response.value, Mapping): - candidate = _extract_from_mapping(agent_response.value) # type: ignore[arg-type] - if candidate: - return _HandoffResolution(target=candidate) - elif isinstance(agent_response.value, str) and agent_response.value.strip(): - return _HandoffResolution(target=agent_response.value.strip()) - - # Check agent_response.additional_properties for handoff target - if agent_response.additional_properties: - candidate = _extract_from_mapping(agent_response.additional_properties) - if candidate: - return _HandoffResolution(target=candidate) + """Detect handoff intent from tool call metadata.""" + for message in agent_response.messages: + resolution = _resolution_from_message(message) + if resolution: + return resolution for request in agent_response.user_input_requests: if isinstance(request, FunctionApprovalRequestContent): - candidate = _candidate_from_approval_request(request) - if candidate: - return _HandoffResolution(target=candidate, function_call=request.function_call) - - for message in agent_response.messages: - # Check message additional_properties for handoff target - if message.additional_properties: - candidate = _extract_from_mapping(message.additional_properties) - if candidate: - return _HandoffResolution(target=candidate) - - # Check message text for handoff hint patterns (e.g., "HANDOFF_TO: specialist") - if message.text: - text_candidate = _candidate_from_text(message.text) - if text_candidate: - return _HandoffResolution(target=text_candidate) - - for content in getattr(message, "contents", ()): - if isinstance(content, FunctionApprovalRequestContent): - candidate = _candidate_from_approval_request(content) - if candidate: - return _HandoffResolution(target=candidate, function_call=content.function_call) - elif isinstance(content, FunctionCallContent): - candidate = _candidate_from_function_call(content) - if candidate: - return _HandoffResolution(target=candidate, function_call=content) - elif isinstance(content, FunctionResultContent): - candidate = _candidate_from_function_result(content) - if candidate: - return _HandoffResolution(target=candidate) + resolution = _resolution_from_function_call(request.function_call) + if resolution: + return resolution return None -def _candidate_from_approval_request(request: FunctionApprovalRequestContent) -> str | None: - candidate = _candidate_from_function_call(request.function_call) - if candidate: - return candidate - - if request.additional_properties: - return _extract_from_mapping(request.additional_properties) +def _resolution_from_message(message: ChatMessage) -> _HandoffResolution | None: + """Inspect an assistant message for embedded handoff tool metadata.""" + for content in getattr(message, "contents", ()): + if isinstance(content, FunctionApprovalRequestContent): + resolution = _resolution_from_function_call(content.function_call) + if resolution: + return resolution + elif isinstance(content, FunctionCallContent): + resolution = _resolution_from_function_call(content) + if resolution: + return resolution return None -def _candidate_from_function_call(function_call: FunctionCallContent) -> str | None: - arguments = function_call.parse_arguments() - if isinstance(arguments, Mapping): - candidate = _extract_from_mapping(arguments) - if candidate: - return candidate - elif isinstance(arguments, str) and arguments.strip(): - return arguments.strip() +def _resolution_from_function_call(function_call: FunctionCallContent | None) -> _HandoffResolution | None: + """Wrap the target resolved from a function call in a `_HandoffResolution`.""" + if function_call is None: + return None + target = _target_from_function_call(function_call) + if not target: + return None + return _HandoffResolution(target=target, function_call=function_call) - if function_call.additional_properties: - candidate = _extract_from_mapping(function_call.additional_properties) - if candidate: - return candidate - name_candidate = _candidate_from_tool_name(function_call.name) +def _target_from_function_call(function_call: FunctionCallContent) -> str | None: + """Extract the handoff target from the tool name or structured arguments.""" + name_candidate = _target_from_tool_name(function_call.name) if name_candidate: return name_candidate - if isinstance(function_call.name, str) and function_call.name.strip(): - return function_call.name.strip() - return None - + arguments = function_call.parse_arguments() + if isinstance(arguments, Mapping): + value = arguments.get("handoff_to") + if isinstance(value, str) and value.strip(): + return value.strip() + elif isinstance(arguments, str): + stripped = arguments.strip() + if stripped: + name_candidate = _target_from_tool_name(stripped) + if name_candidate: + return name_candidate + return stripped -def _candidate_from_function_result(result: FunctionResultContent) -> str | None: - payload = result.result - if isinstance(payload, Mapping): - candidate = _extract_from_mapping(payload) # type: ignore[arg-type] - if candidate: - return candidate - elif isinstance(payload, str) and payload.strip(): - return payload.strip() return None -def _candidate_from_tool_name(name: str | None) -> str | None: +def _target_from_tool_name(name: str | None) -> str | None: + """Parse the specialist alias encoded in a handoff tool's name.""" if not name: return None match = _HANDOFF_TOOL_PATTERN.search(name) @@ -288,29 +257,6 @@ def _candidate_from_tool_name(name: str | None) -> str | None: return None -def _candidate_from_text(text: str) -> str | None: - """Extract handoff target from message text (e.g., 'HANDOFF_TO: specialist').""" - if not text: - return None - - # Pattern 1: HANDOFF_TO: target or TRANSFER_TO: target (with colon) - colon_pattern = re.compile(r"(?:handoff|transfer)[_\s-]*to\s*:\s*(?P[\w-]+)", re.IGNORECASE | re.MULTILINE) - match = colon_pattern.search(text) - if match: - parsed = match.group("target").strip() - if parsed: - return parsed - - # Pattern 2: handoff_to target or transfer_to target (tool-style naming) - match = _HANDOFF_TOOL_PATTERN.search(text) - if match: - parsed = match.group("target").strip() - if parsed: - return parsed - - return None - - class _HandoffCoordinator(Executor): """Coordinates agent-to-agent transfers and user turn requests.""" @@ -324,6 +270,7 @@ def __init__( id: str, handoff_tool_targets: Mapping[str, str] | None = None, ) -> None: + """Create a coordinator that manages routing between specialists and the user.""" super().__init__(id) self._starting_agent_id = starting_agent_id self._specialist_by_alias = dict(specialist_ids) @@ -339,8 +286,9 @@ async def handle_agent_response( response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest | list[ChatMessage], list[ChatMessage]], ) -> None: + """Process an agent's response and determine whether to route, request input, or terminate.""" # Hydrate coordinator state (and detect new run) using checkpointable executor state - state = await ctx.get_state() + state = await ctx.get_executor_state() if not state: self._full_conversation = [] elif not self._full_conversation: @@ -378,31 +326,17 @@ async def handle_agent_response( await ctx.send_message(request, target_id=target) return - # No handoff detected - route based on where the response came from - if is_starting_agent: - # Starting agent responded without handoff - check termination then request user input - if self._termination_condition(conversation): - await self._persist_state(ctx) - logger.info("Handoff workflow termination condition met. Ending conversation.") - await ctx.yield_output(list(conversation)) - return - - await self._persist_state(ctx) - await ctx.send_message(list(conversation), target_id=self._input_gateway_id) - return - - # Specialist responded without handoff - return to user for input - if source not in self._specialist_ids: + # No handoff detected - response must come from starting agent or known specialist + if not is_starting_agent and source not in self._specialist_ids: raise RuntimeError(f"HandoffCoordinator received response from unknown executor '{source}'.") - # Check termination condition after specialist response + await self._persist_state(ctx) + if self._termination_condition(conversation): - await self._persist_state(ctx) logger.info("Handoff workflow termination condition met. Ending conversation.") await ctx.yield_output(list(conversation)) return - await self._persist_state(ctx) await ctx.send_message(list(conversation), target_id=self._input_gateway_id) @handler @@ -428,6 +362,7 @@ async def handle_user_input( await ctx.send_message(request, target_id=self._starting_agent_id) def _resolve_specialist(self, agent_response: AgentRunResponse, conversation: list[ChatMessage]) -> str | None: + """Resolve the specialist executor id requested by the agent response, if any.""" resolution = _resolve_handoff_target(agent_response) if not resolution: return None @@ -461,6 +396,7 @@ def _append_tool_acknowledgement( function_call: FunctionCallContent, resolved_id: str, ) -> None: + """Append a synthetic tool result acknowledging the resolved specialist id.""" call_id = getattr(function_call, "call_id", None) if not call_id: return @@ -477,6 +413,7 @@ def _append_tool_acknowledgement( self._full_conversation.append(tool_message) def _conversation_from_response(self, response: AgentExecutorResponse) -> list[ChatMessage]: + """Return the authoritative conversation snapshot from an executor response.""" conversation = response.full_conversation if conversation is None: raise RuntimeError( @@ -522,7 +459,7 @@ def _get_cleaned_conversation(self, conversation: list[ChatMessage]) -> list[Cha # Check if message has tool-related content has_tool_content = False - if hasattr(msg, "contents") and msg.contents: + if msg.contents: has_tool_content = any( isinstance(content, (FunctionApprovalRequestContent, FunctionCallContent)) for content in msg.contents @@ -548,15 +485,17 @@ def _get_cleaned_conversation(self, conversation: list[ChatMessage]) -> list[Cha async def _persist_state(self, ctx: WorkflowContext[Any, Any]) -> None: """Store authoritative conversation snapshot without losing rich metadata.""" state_payload = {"full_conversation": encode_chat_messages(self._full_conversation)} - await ctx.set_state(state_payload) + await ctx.set_executor_state(state_payload) 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] def _apply_response_metadata(self, conversation: list[ChatMessage], agent_response: AgentRunResponse) -> None: + """Merge top-level response metadata into the latest assistant message.""" if not agent_response.additional_properties: return @@ -585,6 +524,7 @@ def __init__( prompt: str | None, id: str, ) -> None: + """Initialise the gateway that requests user input and forwards responses.""" super().__init__(id) self._request_executor_id = request_executor_id self._starting_agent_id = starting_agent_id @@ -596,6 +536,7 @@ async def request_input( conversation: list[ChatMessage], ctx: WorkflowContext[HandoffUserInputRequest], ) -> None: + """Emit a `HandoffUserInputRequest` capturing the conversation snapshot.""" if not conversation: raise ValueError("Handoff workflow requires non-empty conversation before requesting user input.") request = HandoffUserInputRequest( @@ -612,6 +553,7 @@ async def resume_from_user( response: RequestResponse[HandoffUserInputRequest, Any], ctx: WorkflowContext[_ConversationWithUserInput], ) -> None: + """Convert user input responses back into chat messages and resume the workflow.""" # Reconstruct full conversation with new user input conversation = list(response.original_request.conversation) user_messages = _as_user_messages(response.data) @@ -626,6 +568,7 @@ async def resume_from_user( def _as_user_messages(payload: Any) -> list[ChatMessage]: + """Normalise arbitrary payloads into user-authored chat messages.""" if isinstance(payload, ChatMessage): if payload.role == Role.USER: return [payload] @@ -876,6 +819,7 @@ def participants(self, participants: Sequence[AgentProtocol | Executor]) -> "Han 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 @@ -1074,6 +1018,7 @@ def auto_register_handoff_tools(self, enabled: bool) -> "HandoffBuilder": return self def _apply_auto_tools(self, agent: ChatAgent, specialists: Mapping[str, Executor]) -> dict[str, str]: + """Attach synthetic handoff tools to a chat agent and return the target lookup table.""" chat_options = agent.chat_options existing_tools = list(chat_options.tools or []) existing_names = {getattr(tool, "name", "") for tool in existing_tools if hasattr(tool, "name")} @@ -1405,6 +1350,7 @@ def build(self) -> Workflow: 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): @@ -1417,6 +1363,7 @@ def _wrap_participant(self, participant: AgentProtocol | Executor) -> Executor: 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): return candidate.id if isinstance(candidate, AgentProtocol): diff --git a/python/packages/core/tests/workflow/test_handoff.py b/python/packages/core/tests/workflow/test_handoff.py index 36a13bd42e..ea8d7faead 100644 --- a/python/packages/core/tests/workflow/test_handoff.py +++ b/python/packages/core/tests/workflow/test_handoff.py @@ -11,6 +11,7 @@ AgentRunResponseUpdate, BaseAgent, ChatMessage, + FunctionCallContent, HandoffBuilder, HandoffUserInputRequest, RequestInfoEvent, @@ -57,6 +58,7 @@ def __init__( self.calls: list[list[ChatMessage]] = [] self._text_handoff = text_handoff self._extra_properties = dict(extra_properties or {}) + self._call_index = 0 async def run( # type: ignore[override] self, @@ -67,22 +69,17 @@ async def run( # type: ignore[override] ) -> AgentRunResponse: conversation = _normalise(messages) self.calls.append(conversation) - suffix = f"\nHANDOFF_TO: {self.handoff_to}" if self._text_handoff and self.handoff_to else "" - additional_properties: dict[str, object] = {} - if self.handoff_to and not self._text_handoff: - additional_properties["handoff_to"] = self.handoff_to - additional_properties.update(self._extra_properties) - + 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()) reply = ChatMessage( role=Role.ASSISTANT, - text=f"{self.name} reply{suffix}", + contents=contents, author_name=self.display_name, additional_properties=additional_properties, ) - value = None - if not self._text_handoff and self.handoff_to: - value = {"handoff_to": self.handoff_to, **self._extra_properties} - return AgentRunResponse(messages=[reply], value=value) + return AgentRunResponse(messages=[reply]) async def run_stream( # type: ignore[override] self, @@ -93,17 +90,48 @@ async def run_stream( # type: ignore[override] ) -> AsyncIterator[AgentRunResponseUpdate]: conversation = _normalise(messages) self.calls.append(conversation) - text = f"{self.name} reply" - if self._text_handoff and self.handoff_to: - text += f"\nHANDOFF_TO: {self.handoff_to}" + 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()) + yield AgentRunResponseUpdate( + contents=contents, + role=Role.ASSISTANT, + additional_properties=additional_props, + ) - # In streaming mode, additional_properties must be set on the update - # so they're preserved in the final ChatMessage - additional_props: dict[str, Any] = {} - if self.handoff_to and not self._text_handoff: - additional_props["handoff_to"] = self.handoff_to - additional_props.update(self._extra_properties) - yield AgentRunResponseUpdate(contents=[TextContent(text=text)], additional_properties=additional_props) + def _next_call_id(self) -> str | None: + if not self.handoff_to: + return None + call_id = f"{self.id}-handoff-{self._call_index}" + self._call_index += 1 + return call_id + + +def _merge_additional_properties( + handoff_to: str | None, use_text_hint: bool, extras: dict[str, object] +) -> dict[str, object]: + additional_properties: dict[str, object] = {} + if handoff_to and not use_text_hint: + additional_properties["handoff_to"] = handoff_to + additional_properties.update(extras) + return additional_properties + + +def _build_reply_contents( + agent_name: str, + handoff_to: str | None, + use_text_hint: bool, + call_id: str | None, +) -> list[TextContent | FunctionCallContent]: + contents: list[TextContent | FunctionCallContent] = [] + if handoff_to and call_id: + contents.append( + FunctionCallContent(call_id=call_id, name=f"handoff_to_{handoff_to}", arguments={"handoff_to": handoff_to}) + ) + text = f"{agent_name} reply" + if use_text_hint and handoff_to: + text += f"\nHANDOFF_TO: {handoff_to}" + contents.append(TextContent(text=text)) + return contents def _normalise(messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> list[ChatMessage]: @@ -142,7 +170,10 @@ async def test_handoff_routes_to_specialist_and_requests_user_input(): assert requests, "Workflow should request additional user input" request_payload = requests[-1].data assert isinstance(request_payload, HandoffUserInputRequest) - assert len(request_payload.conversation) == 3 # user, triage, specialist + assert len(request_payload.conversation) == 4 # user, triage tool call, tool ack, specialist + assert request_payload.conversation[2].role == Role.TOOL + assert request_payload.conversation[3].role == Role.ASSISTANT + assert "specialist reply" in request_payload.conversation[3].text follow_up = await _drain(workflow.send_responses_streaming({requests[-1].request_id: "Thanks"})) assert any(isinstance(ev, RequestInfoEvent) for ev in follow_up) @@ -250,15 +281,15 @@ async def test_handoff_preserves_complex_additional_properties(complex_metadata: assert restored_meta_after.payload["code"] == "X1" -async def test_text_based_handoff_detection(): +async def test_tool_call_handoff_detection_with_text_hint(): triage = _RecordingAgent(name="triage", handoff_to="specialist", text_handoff=True) specialist = _RecordingAgent(name="specialist") workflow = HandoffBuilder(participants=[triage, specialist]).set_coordinator("triage").build() - _ = await _drain(workflow.run_stream("Package arrived broken")) + await _drain(workflow.run_stream("Package arrived broken")) - assert specialist.calls, "Specialist should be invoked using text handoff hint" + assert specialist.calls, "Specialist should be invoked using handoff tool call" assert len(specialist.calls[0]) >= 2 From 5714fea25e924a413e0ebcb3be8506218f50a65c Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 07:58:15 +0900 Subject: [PATCH 10/13] Add handoff migration sample. --- .../agent_framework/_workflows/_handoff.py | 4 - .../semantic-kernel-migration/README.md | 48 ++- .../orchestrations/handoff.py | 297 ++++++++++++++++++ 3 files changed, 335 insertions(+), 14 deletions(-) create mode 100644 python/samples/semantic-kernel-migration/orchestrations/handoff.py diff --git a/python/packages/core/agent_framework/_workflows/_handoff.py b/python/packages/core/agent_framework/_workflows/_handoff.py index c006102291..725e50cb25 100644 --- a/python/packages/core/agent_framework/_workflows/_handoff.py +++ b/python/packages/core/agent_framework/_workflows/_handoff.py @@ -976,7 +976,6 @@ def add_handoff( Note: - Handoff tools are automatically registered for each source agent - If a source agent is configured multiple times via add_handoff, targets are merged - - Custom tool_name and tool_description only apply when targets is a single agent """ if not self._executors: raise ValueError("Call participants(...) before add_handoff(...)") @@ -1007,9 +1006,6 @@ def add_handoff( else: self._handoff_config[source_id] = target_ids - # NOTE: custom tool_name and tool_description parameters are reserved for future use - # They will be implemented when we refactor _create_handoff_tool to support customization - return self def auto_register_handoff_tools(self, enabled: bool) -> "HandoffBuilder": diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index e3261a58e3..ee92211ee0 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -4,13 +4,41 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent Framework (AF) with minimal guesswork. Each script pairs SK code with its AF equivalent so you can compare primitives, tooling, and orchestration patterns side by side while you migrate production workloads. ## What’s Included -- `chat_completion/` – SK `ChatCompletionAgent` scenarios and their AF `ChatAgent` counterparts (basic chat, tooling, threading/streaming). -- `azure_ai_agent/` – Remote Azure AI agent examples, including hosted code interpreter and explicit thread reuse. -- `openai_assistant/` – Assistants API migrations covering basic usage, code interpreter, and custom function tools. -- `openai_responses/` – Responses API parity samples with tooling and structured JSON output. -- `copilot_studio/` – Copilot Studio agent parity, tools, and streaming examples. -- `orchestrations/` – Sequential, Concurrent, and Magentic workflow migrations that mirror SK Team abstractions. -- `processes/` – Fan-out/fan-in and nested process examples that contrast SK’s Process Framework with AF workflows. + +### Chat completion parity +- [01_basic_chat_completion.py](chat_completion/01_basic_chat_completion.py) — Minimal SK `ChatCompletionAgent` and AF `ChatAgent` conversation. +- [02_chat_completion_with_tool.py](chat_completion/02_chat_completion_with_tool.py) — Adds a simple tool/function call in both SDKs. +- [03_chat_completion_thread_and_stream.py](chat_completion/03_chat_completion_thread_and_stream.py) — Demonstrates thread reuse and streaming prompts. + +### Azure AI agent parity +- [01_basic_azure_ai_agent.py](azure_ai_agent/01_basic_azure_ai_agent.py) — Create and run an Azure AI agent end to end. +- [02_azure_ai_agent_with_code_interpreter.py](azure_ai_agent/02_azure_ai_agent_with_code_interpreter.py) — Enable hosted code interpreter/tool execution. +- [03_azure_ai_agent_threads_and_followups.py](azure_ai_agent/03_azure_ai_agent_threads_and_followups.py) — Persist threads and follow-ups across invocations. + +### OpenAI Assistants API parity +- [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison. +- [02_openai_assistant_with_code_interpreter.py](openai_assistant/02_openai_assistant_with_code_interpreter.py) — Code interpreter tool usage. +- [03_openai_assistant_function_tool.py](openai_assistant/03_openai_assistant_function_tool.py) — Custom function tooling. + +### OpenAI Responses API parity +- [01_basic_responses_agent.py](openai_responses/01_basic_responses_agent.py) — Basic responses agent migration. +- [02_responses_agent_with_tool.py](openai_responses/02_responses_agent_with_tool.py) — Tool-augmented responses workflows. +- [03_responses_agent_structured_output.py](openai_responses/03_responses_agent_structured_output.py) — Structured JSON output alignment. + +### Copilot Studio parity +- [01_basic_copilot_studio_agent.py](copilot_studio/01_basic_copilot_studio_agent.py) — Minimal Copilot Studio agent invocation. +- [02_copilot_studio_streaming.py](copilot_studio/02_copilot_studio_streaming.py) — Streaming responses from Copilot Studio agents. + +### Orchestrations +- [sequential.py](orchestrations/sequential.py) — Step-by-step SK Team → AF `SequentialBuilder` migration. +- [concurrent_basic.py](orchestrations/concurrent_basic.py) — Concurrent orchestration parity. +- [group_chat.py](orchestrations/group_chat.py) — Group chat coordination with an LLM-backed manager in both SDKs. +- [handoff.py](orchestrations/handoff.py) — Support triage handoff migration with specialist routing. +- [magentic.py](orchestrations/magentic.py) — Magentic Team orchestration vs. AF builder wiring. + +### Processes +- [fan_out_fan_in_process.py](processes/fan_out_fan_in_process.py) — Fan-out/fan-in comparison between SK Process Framework and AF workflows. +- [nested_process.py](processes/nested_process.py) — Nested process orchestration vs. AF sub-workflows. Each script is fully async and the `main()` routine runs both implementations back to back so you can observe their outputs in a single execution. @@ -23,14 +51,14 @@ Each script is fully async and the `main()` routine runs both implementations ba ## Running Single-Agent Samples From the repository root: ``` -python samantic-kernel-migration/chat_completion/01_basic_chat_completion.py +python samples/semantic-kernel-migration/chat_completion/01_basic_chat_completion.py ``` Every script accepts no CLI arguments and will first call the SK implementation, followed by the AF version. Adjust the prompt or credentials inside the file as necessary before running. ## Running Orchestration & Workflow Samples -Advanced comparisons are split between `samantic-kernel-migration/orchestrations` (Sequential, Concurrent, Magentic) and `samantic-kernel-migration/processes` (fan-out/fan-in, nested). You can run them directly, or isolate dependencies in a throwaway virtual environment: +Advanced comparisons are split between `samples/semantic-kernel-migration/orchestrations` (Sequential, Concurrent, Group Chat, 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: ``` -cd samantic-kernel-migration +cd samples/semantic-kernel-migration uv venv --python 3.10 .venv-migration source .venv-migration/bin/activate uv pip install semantic-kernel agent-framework diff --git a/python/samples/semantic-kernel-migration/orchestrations/handoff.py b/python/samples/semantic-kernel-migration/orchestrations/handoff.py new file mode 100644 index 0000000000..ccb30d4f6c --- /dev/null +++ b/python/samples/semantic-kernel-migration/orchestrations/handoff.py @@ -0,0 +1,297 @@ +# 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 agent_framework import ( + ChatMessage, + HandoffBuilder, + HandoffUserInputRequest, + RequestInfoEvent, + WorkflowEvent, + WorkflowOutputEvent, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from semantic_kernel.agents import Agent, ChatCompletionAgent, HandoffOrchestration, OrchestrationHandoffs +from semantic_kernel.agents.runtime import InProcessRuntime +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents import ( + AuthorRole, + ChatMessageContent, + FunctionCallContent, + FunctionResultContent, + StreamingChatMessageContent, +) +from semantic_kernel.functions import KernelArguments, kernel_function +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 + + +CUSTOMER_PROMPT = "I need help with order 12345. I want a replacement and need to know when it will arrive." +SCRIPTED_RESPONSES = [ + "The item arrived damaged. I'd like a replacement shipped to the same address.", + "Great! Can you confirm the shipping cost won't be charged again?", + "Thanks for confirming!", +] + + +###################################################################### +# Semantic Kernel orchestration path +###################################################################### + + +class OrderStatusPlugin: + @kernel_function + def check_order_status(self, order_id: str) -> str: + return f"Order {order_id} is shipped and will arrive in 2-3 days." + + +class OrderRefundPlugin: + @kernel_function + def process_refund(self, order_id: str, reason: str) -> str: + return f"Refund for order {order_id} has been processed successfully (reason: {reason})." + + +class OrderReturnPlugin: + @kernel_function + def process_return(self, order_id: str, reason: str) -> str: + return f"Return for order {order_id} has been processed successfully (reason: {reason})." + + +def build_semantic_kernel_agents() -> tuple[list[Agent], OrchestrationHandoffs]: + credential = AzureCliCredential() + + triage = ChatCompletionAgent( + name="TriageAgent", + description="Customer support triage specialist.", + instructions="Greet the customer, collect intent, and hand off to the right specialist.", + service=AzureChatCompletion(credential=credential), + ) + refund = ChatCompletionAgent( + name="RefundAgent", + description="Handles refunds.", + instructions="Process refund requests.", + service=AzureChatCompletion(credential=credential), + plugins=[OrderRefundPlugin()], + ) + order_status = ChatCompletionAgent( + name="OrderStatusAgent", + description="Looks up order status.", + instructions="Provide shipping timelines and tracking information.", + service=AzureChatCompletion(credential=credential), + plugins=[OrderStatusPlugin()], + ) + order_return = ChatCompletionAgent( + name="OrderReturnAgent", + description="Handles returns.", + instructions="Coordinate order returns.", + service=AzureChatCompletion(credential=credential), + plugins=[OrderReturnPlugin()], + ) + + handoffs = ( + OrchestrationHandoffs() + .add_many( + source_agent=triage.name, + target_agents={ + refund.name: "Route refund-related requests here.", + order_status.name: "Route shipping questions here.", + order_return.name: "Route return-related requests here.", + }, + ) + .add(refund.name, triage.name, "Return to triage for non-refund issues.") + .add(order_status.name, triage.name, "Return to triage for non-status issues.") + .add(order_return.name, triage.name, "Return to triage for non-return issues.") + ) + + return [triage, refund, order_status, order_return], handoffs + + +_sk_new_message = True + + +def _sk_streaming_callback(message: StreamingChatMessageContent, is_final: bool) -> None: + """Display SK agent messages as they stream.""" + global _sk_new_message + if _sk_new_message: + print(f"{message.name}: ", end="", flush=True) + _sk_new_message = False + + if message.content: + print(message.content, end="", flush=True) + + for item in message.items: + if isinstance(item, FunctionCallContent): + print(f"[tool call: {item.name}({item.arguments})]", end="", flush=True) + if isinstance(item, FunctionResultContent): + print(f"[tool result: {item.result}]", end="", flush=True) + + if is_final: + print() + _sk_new_message = True + + +def _make_sk_human_responder(script: Iterator[str]) -> callable: + def _responder() -> ChatMessageContent: + try: + user_text = next(script) + except StopIteration: + user_text = "Thanks, that's all." + print(f"[User]: {user_text}") + return ChatMessageContent(role=AuthorRole.USER, content=user_text) + + return _responder + + +async def run_semantic_kernel_example(initial_task: str, scripted_responses: Sequence[str]) -> str: + agents, handoffs = build_semantic_kernel_agents() + response_iter = iter(scripted_responses) + + orchestration = HandoffOrchestration( + members=agents, + handoffs=handoffs, + streaming_agent_response_callback=_sk_streaming_callback, + human_response_function=_make_sk_human_responder(response_iter), + ) + + runtime = InProcessRuntime() + runtime.start() + + try: + orchestration_result = await orchestration.invoke(task=initial_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 +###################################################################### + + +def _create_af_agents(client: AzureOpenAIChatClient): + triage = client.create_agent( + name="triage_agent", + instructions=( + "You are a customer support triage agent. Route requests:\n" + "- handoff_to_refund_agent for refunds\n" + "- handoff_to_order_status_agent for shipping/timeline questions\n" + "- handoff_to_order_return_agent for returns" + ), + ) + refund = client.create_agent( + name="refund_agent", + instructions=( + "Handle refunds. Ask for order id and reason. If shipping info is needed, hand off to order_status_agent." + ), + ) + status = client.create_agent( + name="order_status_agent", + instructions=( + "Provide order status, tracking, and timelines. If billing questions appear, hand off to refund_agent." + ), + ) + returns = client.create_agent( + name="order_return_agent", + instructions=( + "Coordinate returns, confirm addresses, and summarize next steps. Hand off to triage_agent if unsure." + ), + ) + return triage, refund, status, returns + + +async def _drain_events(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]: + return [event async for event in stream] + + +def _collect_handoff_requests(events: list[WorkflowEvent]) -> list[RequestInfoEvent]: + requests: list[RequestInfoEvent] = [] + for event in events: + if isinstance(event, RequestInfoEvent) and isinstance(event.data, HandoffUserInputRequest): + requests.append(event) + return requests + + +def _extract_final_conversation(events: list[WorkflowEvent]) -> list[ChatMessage]: + for event in events: + if isinstance(event, WorkflowOutputEvent): + data = cast(list[ChatMessage], event.data) + return data + return [] + + +async def run_agent_framework_example(initial_task: str, scripted_responses: Sequence[str]) -> str: + client = AzureOpenAIChatClient(credential=AzureCliCredential()) + triage, refund, status, returns = _create_af_agents(client) + + workflow = ( + HandoffBuilder(name="sk_af_handoff_migration", participants=[triage, refund, status, returns]) + .set_coordinator(triage) + .add_handoff(triage, [refund, status, returns]) + .add_handoff(refund, [status, triage]) + .add_handoff(status, [refund, triage]) + .add_handoff(returns, triage) + .build() + ) + + events = await _drain_events(workflow.run_stream(initial_task)) + pending = _collect_handoff_requests(events) + scripted_iter = iter(scripted_responses) + + final_events = events + while pending: + try: + user_reply = next(scripted_iter) + except StopIteration: + user_reply = "Thanks, that's all." + responses = {request.request_id: user_reply for request in pending} + final_events = await _drain_events(workflow.send_responses_streaming(responses)) + pending = _collect_handoff_requests(final_events) + + conversation = _extract_final_conversation(final_events) + if not conversation: + return "" + + # Render final transcript succinctly. + lines = [] + for message in conversation: + text = message.text or "" + if not text.strip(): + continue + speaker = message.author_name or message.role.value + lines.append(f"{speaker}: {text}") + return "\n".join(lines) + + +###################################################################### +# Console entry point +###################################################################### + + +async def main() -> None: + print("===== Agent Framework Handoff =====") + af_transcript = await run_agent_framework_example(CUSTOMER_PROMPT, SCRIPTED_RESPONSES) + print(af_transcript or "No output produced.") + print() + + print("===== Semantic Kernel Handoff =====") + sk_result = await run_semantic_kernel_example(CUSTOMER_PROMPT, SCRIPTED_RESPONSES) + print(sk_result or "No output produced.") + + +if __name__ == "__main__": + asyncio.run(main()) From 7462ecab77e5536ce9b16638d20ecce2533356d3 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 09:34:59 +0900 Subject: [PATCH 11/13] Remove type ignore --- .../core/agent_framework/_workflows/_conversation_state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_conversation_state.py b/python/packages/core/agent_framework/_workflows/_conversation_state.py index 52910f35d3..8c21513f6c 100644 --- a/python/packages/core/agent_framework/_workflows/_conversation_state.py +++ b/python/packages/core/agent_framework/_workflows/_conversation_state.py @@ -5,7 +5,7 @@ from agent_framework import ChatMessage, Role -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value # type: ignore +from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value """Utilities for serializing and deserializing chat conversations for persistence. From be475c1dddce11149a73c32848705906e2066176 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 09:37:06 +0900 Subject: [PATCH 12/13] fix markdown link formatting --- python/packages/purview/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index b42213b4e5..b52fdca807 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -62,16 +62,16 @@ If a policy violation is detected on the prompt, the middleware terminates the r `PurviewClient` uses the `azure-identity` library for token acquisition. You can use any `TokenCredential` or `AsyncTokenCredential` implementation. The APIs require the following Graph Permissions: -- ProtectionScopes.Compute.All : (userProtectionScopeContainer)[https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute] -- Content.Process.All : (processContent)[https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent] -- ContentActivity.Write : (contentActivity)[https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities] +- ProtectionScopes.Compute.All : [userProtectionScopeContainer](https://learn.microsoft.com/en-us/graph/api/userprotectionscopecontainer-compute) +- Content.Process.All : [processContent](https://learn.microsoft.com/en-us/graph/api/userdatasecurityandgovernance-processcontent) +- ContentActivity.Write : [contentActivity](https://learn.microsoft.com/en-us/graph/api/activitiescontainer-post-contentactivities) ### Scopes `PurviewSettings.get_scopes()` derives the Graph scope list (currently `https://graph.microsoft.com/.default` style). ### Tenant Enablement for Purview - The tenant requires an e5 license and consumptive billing setup. -- There need to be (Data Loss Prevention)[https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy] or (Data Collection Policies)[https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference] that apply to the user to call Process Content API else it calls Content Activities API for auditing the message. +- There need to be [Data Loss Prevention](https://learn.microsoft.com/en-us/purview/dlp-create-deploy-policy) or [Data Collection Policies](https://learn.microsoft.com/en-us/purview/collection-policies-policy-reference) that apply to the user to call Process Content API else it calls Content Activities API for auditing the message. --- From 00b7ccbbcf47ad4ac8c9f20bf0604cbf80c1a241 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 22 Oct 2025 09:41:17 +0900 Subject: [PATCH 13/13] Remove readme link for non-existent sample --- python/samples/semantic-kernel-migration/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index ee92211ee0..7c18db8c5b 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -32,7 +32,6 @@ 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. -- [group_chat.py](orchestrations/group_chat.py) — Group chat coordination with an LLM-backed manager in both SDKs. - [handoff.py](orchestrations/handoff.py) — Support triage handoff migration with specialist routing. - [magentic.py](orchestrations/magentic.py) — Magentic Team orchestration vs. AF builder wiring.