{ "cells": [ { "cell_type": "markdown", "id": "c3858df8", "metadata": {}, "source": [ "# Travel Customer Support with Handoff Orchestration\n", "\n", "This notebook demonstrates **handoff orchestration** using the Microsoft Agent Framework. We'll build a travel customer support system where agents can transfer control to specialists based on the customer's needs.\n", "\n", "## What You'll Learn:\n", "1. **Handoff Orchestration**: Dynamic agent routing based on context and expertise\n", "2. **HandoffBuilder**: High-level API for building handoff workflows\n", "3. **Specialist Routing**: Agents can hand off to other agents dynamically\n", "4. **Multi-turn Conversations**: Seamless context preservation across handoffs\n", "5. **Customer Support Flow**: Real-world application of agent handoffs" ] }, { "cell_type": "markdown", "id": "90d3eeb1", "metadata": {}, "source": [ "## Prerequisites:\n", "- Microsoft Agent Framework installed\n", "- GitHub token or OpenAI API key configured\n", "- Understanding of basic agent concepts" ] }, { "cell_type": "code", "execution_count": null, "id": "8360426f", "metadata": {}, "outputs": [], "source": [ "import asyncio\n", "import json\n", "import os\n", "from collections.abc import AsyncIterable\n", "from typing import Any, cast\n", "\n", "from agent_framework import (\n", " ChatMessage,\n", " HandoffBuilder,\n", " HandoffUserInputRequest,\n", " RequestInfoEvent,\n", " WorkflowEvent,\n", " WorkflowOutputEvent,\n", " WorkflowRunState,\n", " WorkflowStatusEvent,\n", ")\n", "\n", "# GitHub Models or OpenAI client integration\n", "from agent_framework.openai import OpenAIChatClient\n", "from dotenv import load_dotenv\n", "from IPython.display import HTML, display\n", "from pydantic import BaseModel" ] }, { "cell_type": "markdown", "id": "33e3e754", "metadata": {}, "source": [ "## Step 1: Define Pydantic Models for Structured Outputs\n", "\n", "These models define the schema that each specialized agent will return. This ensures consistent and parseable responses from all agents." ] }, { "cell_type": "code", "execution_count": null, "id": "3c36ef1d", "metadata": {}, "outputs": [], "source": [ "class FlightBookingResult(BaseModel):\n", " \"\"\"Flight booking confirmation from the booking agent.\"\"\"\n", "\n", " destination: str\n", " departure_date: str\n", " return_date: str\n", " booking_reference: str\n", " passenger_name: str\n", " flight_details: str\n", " total_cost: str\n", " status: str\n", "\n", "\n", "class DisputeResult(BaseModel):\n", " \"\"\"Dispute resolution result from the disputes agent.\"\"\"\n", "\n", " dispute_type: str\n", " original_booking: str\n", " refund_amount: str\n", " refund_method: str\n", " processing_time: str\n", " reference_number: str\n", " status: str\n", "\n", "\n", "class TripCheckResult(BaseModel):\n", " \"\"\"Trip confirmation result from the trip check agent.\"\"\"\n", "\n", " trip_reference: str\n", " destination: str\n", " travel_dates: str\n", " confirmation_status: str\n", " special_notes: str\n", " contact_info: str" ] }, { "cell_type": "markdown", "id": "668bb379", "metadata": {}, "source": [ "## Step 2: Load Environment Variables\n" ] }, { "cell_type": "code", "execution_count": null, "id": "bfe15197", "metadata": {}, "outputs": [], "source": [ "# Load environment variables\n", "load_dotenv()\n", "\n", "from azure.identity import AzureCliCredential\n", "\n", "# Azure OpenAI via the Responses API. Sign in with `az login` for keyless Entra ID auth.\n", "# GitHub Models is deprecated (retiring July 2026) and does not support the Responses API,\n", "# so this sample calls Azure OpenAI directly. OpenAIChatClient uses the Responses API.\n", "chat_client = OpenAIChatClient(\n", " azure_endpoint=os.environ[\"AZURE_OPENAI_ENDPOINT\"],\n", " credential=AzureCliCredential(),\n", " model_id=os.environ.get(\"AZURE_OPENAI_DEPLOYMENT\", \"gpt-4o-mini\"),\n", ")\n" ] }, { "cell_type": "markdown", "id": "8cd52bf8", "metadata": {}, "source": [ "## Step 3: Create Four Specialized Travel Support Agents\n", "\n", "Each agent has specific expertise and can hand off to appropriate specialists based on customer needs." ] }, { "cell_type": "code", "execution_count": null, "id": "32f31143", "metadata": {}, "outputs": [], "source": [ "# Agent 1: Customer Support Agent (Main triage agent)\n", "customer_support_agent = chat_client.as_agent(\n", " instructions=(\n", " \"You are a friendly customer support agent for a travel company. \"\n", " \"Assess customer requests and route them to the appropriate specialist: \"\n", " \"- For flight bookings or reservations: call handoff_to_booking_agent \"\n", " \"- For refunds, disputes, or billing issues: call handoff_to_disputes_agent \"\n", " \"- For trip confirmations or travel plan checks: call handoff_to_trip_check_agent \"\n", " \"Be welcoming and ensure customers feel heard before routing them.\"\n", " ),\n", " name=\"customer_support_agent\",\n", ")\n", "\n", "\n", "# Agent 2: Booking Agent (Flight booking specialist)\n", "booking_agent = chat_client.as_agent(\n", " instructions=(\n", " \"You are a flight booking specialist. Handle all flight reservations and bookings. \"\n", " \"When a customer wants to book a flight, collect their destination, travel dates, \"\n", " \"and confirm the booking. Always provide a booking reference number. \"\n", " \"Return structured JSON with booking details. \"\n", " \"The flight is always confirmed as booked regardless of destination.\"\n", " ),\n", " name=\"booking_agent\",\n", " response_format=FlightBookingResult,\n", ")\n", "\n", "# Agent 3: Disputes Agent (Refund and billing specialist)\n", "disputes_agent = chat_client.as_agent(\n", " instructions=(\n", " \"You are a disputes and refunds specialist. Handle customer complaints, \"\n", " \"refund requests, and billing disputes. Always approve refunds and provide \"\n", " \"a reference number. Process refunds back to the original payment method. \"\n", " \"Return structured JSON with refund details. \"\n", " \"All refund requests are approved and processed immediately.\"\n", " ),\n", " name=\"disputes_agent\",\n", " response_format=DisputeResult,\n", ")\n", "# Agent 4: Trip Check Agent (Travel confirmation specialist)\n", "trip_check_agent = chat_client.as_agent(\n", " instructions=(\n", " \"You are a travel confirmation specialist. Verify and confirm customer \"\n", " \"travel plans, check itineraries, and provide travel status updates. \"\n", " \"Always confirm that travel plans are in order and provide reassurance. \"\n", " \"Return structured JSON with confirmation details. \"\n", " \"All travel plans are confirmed as valid and ready.\"\n", " ),\n", " name=\"trip_check_agent\",\n", " response_format=TripCheckResult,\n", ")\n", "\n", "\n", "\n" ] }, { "cell_type": "markdown", "id": "c67e2486", "metadata": {}, "source": [ "## Step 4: Build the Handoff Workflow\n", "\n", "The HandoffBuilder creates a workflow where the customer support agent can dynamically hand off to specialists based on customer needs.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "111b6a7c", "metadata": {}, "outputs": [], "source": [ "workflow = (\n", " HandoffBuilder(\n", " name=\"travel_support_handoff\",\n", " participants=[customer_support_agent, booking_agent, disputes_agent, trip_check_agent],\n", " )\n", " .set_coordinator(customer_support_agent) # Main agent that receives initial requests\n", " .add_handoff(customer_support_agent, [booking_agent, disputes_agent, trip_check_agent])\n", " .with_termination_condition(\n", " lambda conv: sum(1 for msg in conv if msg.role.value == \"user\") > \n", " ) # Stop after 3 user messages\n", " .build()\n", ")\n", "\n", "display(HTML(\"\"\"\n", "
\n", "

Handoff Workflow Built Successfully!

\n", "

\n", " Handoff Flow:
\n", " • User Request → Customer Support Agent (triage)
\n", " • Support Agent → Specialist Agent (dynamic handoff)
\n", " • Specialist → Resolution (expert handling)
\n", " • System → User Response (final result)\n", "

\n", "
\n", "\"\"\"))\n", "\n" ] }, { "cell_type": "markdown", "id": "0380843e", "metadata": {}, "source": [ "## Step 5: Helper Functions for Event Processing\n", "\n", "These functions help us process workflow events and handle user input requests during the handoff process." ] }, { "cell_type": "code", "execution_count": null, "id": "66a9dbe0", "metadata": {}, "outputs": [], "source": [ "async def drain_events(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:\n", " \"\"\"Collect all events from an async stream into a list.\"\"\"\n", " return [event async for event in stream]\n", "\n", "\n", "def handle_workflow_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:\n", " \"\"\"Process workflow events and extract pending user input requests.\"\"\"\n", " requests: list[RequestInfoEvent] = []\n", " \n", " for event in events:\n", " if isinstance(event, WorkflowStatusEvent) and event.state in {\n", " WorkflowRunState.IDLE,\n", " WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,\n", " }:\n", " print(f\"[Workflow Status] {event.state.name}\")\n", "\n", " elif isinstance(event, WorkflowOutputEvent):\n", " conversation = cast(list[ChatMessage], event.data)\n", " if isinstance(conversation, list):\n", " print(\"\\n=== Final Conversation ===\")\n", " for message in conversation:\n", " # Filter out messages with no text (tool calls)\n", " if not message.text.strip():\n", " continue\n", " speaker = message.author_name or message.role.value\n", " print(f\"- {speaker}: {message.text}\")\n", " print(\"==========================\")\n", "\n", " elif isinstance(event, RequestInfoEvent):\n", " if isinstance(event.data, HandoffUserInputRequest):\n", " print_handoff_request(event.data)\n", " requests.append(event)\n", "\n", " return requests\n", "\n", "\n", "def print_handoff_request(request: HandoffUserInputRequest) -> None:\n", " \"\"\"Display a user input request with conversation context.\"\"\"\n", " print(\"\\n=== User Input Requested ===\")\n", " # Filter out messages with no text for cleaner display\n", " messages_with_text = [\n", " msg for msg in request.conversation if msg.text.strip()]\n", " print(f\"Last {len(messages_with_text)} messages in conversation:\")\n", " for message in messages_with_text[-3:]: # Show last 3 for brevity\n", " speaker = message.author_name or message.role.value\n", " text = message.text[:100] + \\\n", " \"...\" if len(message.text) > 100 else message.text\n", " print(f\" {speaker}: {text}\")\n", " print(\"============================\")\n", "\n", "\n", "print(\"Helper functions defined for event processing\")" ] }, { "cell_type": "markdown", "id": "ff51e63c", "metadata": {}, "source": [ "## Step 6: Test Case 1 - Flight Booking Request\n", "\n", "Let's test our handoff workflow with a flight booking request. The customer support agent should hand off to the booking agent.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "558a73d4", "metadata": {}, "outputs": [], "source": [ "async def test_booking_handoff():\n", " \"\"\"Test handoff workflow for flight booking requests.\"\"\"\n", "\n", " display(HTML(\"\"\"\n", "
\n", "

Test Case 1: Flight Booking Request

\n", "

Expected Flow: Customer Support → Booking Agent

\n", "
\n", " \"\"\"))\n", "\n", " # Start the workflow\n", " print(\"[User]: I want to book a flight to Paris for next month\")\n", " events = await drain_events(\n", " workflow.run_stream(\"I want to book a flight to Paris for next month\")\n", " )\n", " pending_requests = handle_workflow_events(events)\n", "\n", " # Handle any additional user input requests\n", " scripted_responses = [\n", " \"I'd like to travel from New York to Paris on December 15th and return on December 22nd.\",\n", " \"Yes, please confirm the booking under the name John Smith.\"\n", " ]\n", "\n", " response_index = 0\n", " while pending_requests and response_index < len(scripted_responses):\n", " user_response = scripted_responses[response_index]\n", " print(f\"\\n[User]: {user_response}\")\n", "\n", " responses = {req.request_id: user_response for req in pending_requests}\n", " events = await drain_events(workflow.send_responses_streaming(responses))\n", " pending_requests = handle_workflow_events(events)\n", "\n", " response_index += 1\n", "\n", " # Extract and display the final booking result\n", " if events:\n", " for event in events:\n", " if isinstance(event, WorkflowOutputEvent):\n", " conversation = cast(list[ChatMessage], event.data)\n", " for message in conversation:\n", " if message.author_name == \"booking_agent\" and message.text.strip():\n", " try:\n", " booking_data = FlightBookingResult.model_validate_json(\n", " message.text)\n", " display_booking_result(booking_data)\n", " except Exception as e:\n", " print(f\"Could not parse booking result: {e}\")\n", "\n", "\n", "def display_booking_result(booking: FlightBookingResult):\n", " \"\"\"Display flight booking result in a formatted section.\"\"\"\n", "\n", " display(HTML(f\"\"\"\n", "
\n", "

✈️ Flight Booking Confirmed

\n", "
\n", "
\n", " Booking Reference: {booking.booking_reference}
\n", " Passenger: {booking.passenger_name}
\n", " Status: {booking.status}\n", "
\n", "
\n", " Destination: {booking.destination}
\n", " Total Cost: {booking.total_cost}
\n", " Departure: {booking.departure_date}\n", "
\n", "
\n", "
\n", " Flight Details: {booking.flight_details}\n", "
\n", "
\n", " ✅ Success: Flight booking completed through handoff to booking specialist\n", "
\n", "
\n", " \"\"\"))\n", "\n", "\n", "# Run the booking test\n", "await test_booking_handoff()" ] }, { "cell_type": "markdown", "id": "794c1e25", "metadata": {}, "source": [ "## Step 7: Test Case 2 - Dispute/Refund Request\n", "\n", "Let's test our handoff workflow with a refund request. The customer support agent should hand off to the disputes agent." ] }, { "cell_type": "code", "execution_count": null, "id": "d61756c0", "metadata": {}, "outputs": [], "source": [ "async def test_dispute_handoff():\n", " \"\"\"Test handoff workflow for dispute/refund requests.\"\"\"\n", "\n", " display(HTML(\"\"\"\n", "
\n", "

Test Case 2: Refund Request

\n", "

Expected Flow: Customer Support → Disputes Agent

\n", "
\n", " \"\"\"))\n", "\n", " # Start the workflow\n", " print(\"[User]: I need to cancel my flight and get a refund\")\n", " events = await drain_events(\n", " workflow.run_stream(\"I need to cancel my flight and get a refund\")\n", " )\n", " pending_requests = handle_workflow_events(events)\n", "\n", " # Handle any additional user input requests\n", " scripted_responses = [\n", " \"My booking reference is FL12345. I can't travel due to a family emergency.\",\n", " \"Yes, please process the full refund back to my credit card.\"\n", " ]\n", "\n", " response_index = 0\n", " while pending_requests and response_index < len(scripted_responses):\n", " user_response = scripted_responses[response_index]\n", " print(f\"\\n[User]: {user_response}\")\n", "\n", " responses = {req.request_id: user_response for req in pending_requests}\n", " events = await drain_events(workflow.send_responses_streaming(responses))\n", " pending_requests = handle_workflow_events(events)\n", "\n", " response_index += 1\n", "\n", " # Extract and display the final dispute result\n", " if events:\n", " for event in events:\n", " if isinstance(event, WorkflowOutputEvent):\n", " conversation = cast(list[ChatMessage], event.data)\n", " for message in conversation:\n", " if message.author_name == \"disputes_agent\" and message.text.strip():\n", " try:\n", " dispute_data = DisputeResult.model_validate_json(\n", " message.text)\n", " display_dispute_result(dispute_data)\n", " except Exception as e:\n", " print(f\"Could not parse dispute result: {e}\")\n", "\n", "\n", "def display_dispute_result(dispute: DisputeResult):\n", " \"\"\"Display dispute resolution result in a formatted section.\"\"\"\n", "\n", " display(HTML(f\"\"\"\n", "
\n", "

💰 Refund Processed

\n", "
\n", "
\n", " Reference Number: {dispute.reference_number}
\n", " Dispute Type: {dispute.dispute_type}
\n", " Status: {dispute.status}\n", "
\n", "
\n", " Refund Amount: {dispute.refund_amount}
\n", " Refund Method: {dispute.refund_method}
\n", " Processing Time: {dispute.processing_time}\n", "
\n", "
\n", "
\n", " Original Booking: {dispute.original_booking}\n", "
\n", "
\n", " ✅ Success: Refund processed through handoff to disputes specialist\n", "
\n", "
\n", " \"\"\"))\n", "\n", " # Run the dispute test\n", "await test_dispute_handoff()" ] }, { "cell_type": "markdown", "id": "9a855d8d", "metadata": {}, "source": [ "## Step 8: Test Case 3 - Trip Confirmation Request\n", "\n", "Let's test our handoff workflow with a trip confirmation request. The customer support agent should hand off to the trip check agent." ] }, { "cell_type": "code", "execution_count": null, "id": "34ea038c", "metadata": {}, "outputs": [], "source": [ "async def test_trip_check_handoff():\n", " \"\"\"Test handoff workflow for trip confirmation requests.\"\"\"\n", "\n", " display(HTML(\"\"\"\n", "
\n", "

Test Case 3: Trip Confirmation

\n", "

Expected Flow: Customer Support → Trip Check Agent

\n", "
\n", " \"\"\"))\n", "\n", " # Start the workflow\n", " print(\"[User]: Can you confirm my travel plans are all set?\")\n", " events = await drain_events(\n", " workflow.run_stream(\"Can you confirm my travel plans are all set?\")\n", " )\n", " pending_requests = handle_workflow_events(events)\n", "\n", " # Handle any additional user input requests\n", " scripted_responses = [\n", " \"I'm traveling to London next week. My confirmation number is TR98765.\",\n", " \"Perfect, thank you for checking everything is ready!\"\n", " ]\n", "\n", " response_index = 0\n", " while pending_requests and response_index < len(scripted_responses):\n", " user_response = scripted_responses[response_index]\n", " print(f\"\\n[User]: {user_response}\")\n", " \n", " responses = {req.request_id: user_response for req in pending_requests}\n", " events = await drain_events(workflow.send_responses_streaming(responses))\n", " pending_requests = handle_workflow_events(events)\n", " \n", " response_index += 1\n", " # Extract and display the final trip check result\n", " if events:\n", " for event in events:\n", " if isinstance(event, WorkflowOutputEvent):\n", " conversation = cast(list[ChatMessage], event.data)\n", " for message in conversation:\n", " if message.author_name == \"trip_check_agent\" and message.text.strip():\n", " try:\n", " trip_data = TripCheckResult.model_validate_json(\n", " message.text)\n", " display_trip_check_result(trip_data)\n", " except Exception as e:\n", " print(f\"Could not parse trip check result: {e}\")\n", "\n", "\n", "def display_trip_check_result(trip: TripCheckResult):\n", " \"\"\"Display trip confirmation result in a formatted section.\"\"\"\n", "\n", " display(HTML(f\"\"\"\n", "
\n", "

🎯 Trip Confirmed

\n", "
\n", "
\n", " Trip Reference: {trip.trip_reference}
\n", " Destination: {trip.destination}
\n", " Status: {trip.confirmation_status}\n", "
\n", "
\n", " Travel Dates: {trip.travel_dates}
\n", " Contact Info: {trip.contact_info}\n", "
\n", "
\n", "
\n", " Special Notes: {trip.special_notes}\n", "
\n", "
\n", " ✅ Success: Trip confirmed through handoff to trip check specialist\n", "
\n", "
\n", " \"\"\"))\n", "\n", "\n", "# Run the trip check test\n", "await test_trip_check_handoff()\n", "\n", " " ] }, { "cell_type": "markdown", "id": "ae3cb474", "metadata": {}, "source": [ "## Step 9: Workflow Analysis - Understanding Handoff Flow\n" ] }, { "cell_type": "code", "execution_count": null, "id": "0b21ce2b", "metadata": {}, "outputs": [], "source": [ "async def analyze_handoff_patterns():\n", " \"\"\"Analyze different handoff patterns and routing decisions.\"\"\"\n", "\n", " display(HTML(\"\"\"\n", "
\n", "

Handoff Pattern Analysis

\n", "

Testing different request types to show routing decisions...

\n", "
\n", " \"\"\"))\n", "\n", " test_requests = [\n", " \"I want to book a round-trip flight to Tokyo\",\n", " \"I need a refund for my cancelled flight\",\n", " \"Please check if my travel itinerary is confirmed\",\n", " \"Can you help me with a billing dispute?\"\n", " ]\n", "\n", " for i, request in enumerate(test_requests, 1):\n", " print(f\"\\n--- Test Request {i} ---\")\n", " print(f\"User: {request}\")\n", "\n", " # Run workflow and capture routing decision\n", " events = await drain_events(workflow.run_stream(request))\n", "\n", " # Analyze which agent was activated\n", " for event in events:\n", " if isinstance(event, WorkflowOutputEvent):\n", " conversation = cast(list[ChatMessage], event.data)\n", " for message in conversation:\n", " if message.author_name == \"customer_support_agent\":\n", " print(f\"Support Agent: {message.text[:100]}...\")\n", " elif message.author_name in [\"booking_agent\", \"disputes_agent\", \"trip_check_agent\"]:\n", " agent_type = {\n", " \"booking_agent\": \"🛫 BOOKING SPECIALIST\",\n", " \"disputes_agent\": \"💰 DISPUTES SPECIALIST\",\n", " \"trip_check_agent\": \"🎯 TRIP CHECK SPECIALIST\"\n", " }[message.author_name]\n", " print(f\"Routed to: {agent_type}\")\n", " break\n", " break\n", " display(HTML(\"\"\"\n", "
\n", "

Handoff Analysis Results

\n", "
\n", "

Key Observations

\n", " \n", "
\n", "
\n", " \"\"\"))\n", "\n", " # Run the analysis\n", "await analyze_handoff_patterns()" ] } ], "metadata": { "kernelspec": { "display_name": ".venv (3.12.12)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.12" } }, "nbformat": 4, "nbformat_minor": 5 }