From cb77ea3f651f12679aa35620eb50556c00789ef0 Mon Sep 17 00:00:00 2001 From: jhills20 Date: Tue, 25 Mar 2025 10:45:34 -0700 Subject: [PATCH 1/3] triage agent tool choice requried + respond to user --- docs/examples.md | 9 ++++--- examples/customer_service/main.py | 43 +++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 1d3ebde7..16ee9f50 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -7,14 +7,14 @@ Check out a variety of sample implementations of the SDK in the examples section - **agent_patterns:** Examples in this category illustrate common agent design patterns, such as - + - Deterministic workflows - Agents as tools - Parallel agent execution - **basic:** These examples showcase foundational capabilities of the SDK, such as - + - Dynamic system prompts - Streaming outputs - Lifecycle events @@ -31,6 +31,7 @@ Check out a variety of sample implementations of the SDK in the examples section - **customer_service** and **research_bot:** Two more built-out examples that illustrate real-world applications - - - **customer_service**: Example customer service system for an airline. + + - **customer_service**: Example customer service system for an airline, with + `tool_choice` required for the triage agent. - **research_bot**: Simple deep research clone. diff --git a/examples/customer_service/main.py b/examples/customer_service/main.py index bd802e22..4f6d0e4d 100644 --- a/examples/customer_service/main.py +++ b/examples/customer_service/main.py @@ -1,4 +1,4 @@ -from __future__ import annotations as _annotations +from __future__ import annotations import asyncio import random @@ -6,12 +6,14 @@ from pydantic import BaseModel -from agents import ( +from src.agents import ( Agent, HandoffOutputItem, ItemHelpers, MessageOutputItem, + ModelSettings, RunContextWrapper, + RunConfig, Runner, ToolCallItem, ToolCallOutputItem, @@ -20,7 +22,7 @@ handoff, trace, ) -from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX +from src.agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX ### CONTEXT @@ -74,9 +76,13 @@ async def update_seat( assert context.context.flight_number is not None, "Flight number is required" return f"Updated seat to {new_seat} for confirmation number {confirmation_number}" - -### HOOKS - +@function_tool +async def respond_to_user(response: str) -> str: + """ + Use this function to send a message back to the end user. The agent should call this whenever + you want to produce a user-facing response. + """ + return response async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext]) -> None: flight_number = f"FLT-{random.randint(100, 999)}" @@ -95,7 +101,7 @@ async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext 1. Identify the last question asked by the customer. 2. Use the faq lookup tool to answer the question. Do not rely on your own knowledge. 3. If you cannot answer the question, transfer back to the triage agent.""", - tools=[faq_lookup_tool], + tools=[faq_lookup_tool, respond_to_user], ) seat_booking_agent = Agent[AirlineAgentContext]( @@ -109,7 +115,7 @@ async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext 2. Ask the customer what their desired seat number is. 3. Use the update seat tool to update the seat on the flight. If the customer asks a question that is not related to the routine, transfer back to the triage agent. """, - tools=[update_seat], + tools=[update_seat, respond_to_user], ) triage_agent = Agent[AirlineAgentContext]( @@ -122,7 +128,12 @@ async def on_seat_booking_handoff(context: RunContextWrapper[AirlineAgentContext handoffs=[ faq_agent, handoff(agent=seat_booking_agent, on_handoff=on_seat_booking_handoff), + respond_to_user ], + tools=[respond_to_user], + model_settings=ModelSettings(tool_choice="required"), + tool_use_behavior={"stop_at_tool_names": ["respond_to_user"]}, + ) faq_agent.handoffs.append(triage_agent) @@ -145,20 +156,30 @@ async def main(): user_input = input("Enter your message: ") with trace("Customer service", group_id=conversation_id): input_items.append({"content": user_input, "role": "user"}) - result = await Runner.run(current_agent, input_items, context=context) + result = await Runner.run(current_agent, input_items, context=context,run_config=RunConfig(tracing_disabled=True)) + last_tool_name: str | None = None for new_item in result.new_items: agent_name = new_item.agent.name if isinstance(new_item, MessageOutputItem): + # In tool_choice="required" scenarios, the agent won't produce bare messages; + # instead it will call `respond_to_user`. But if the example is run without + # requiring tool_choice, this branch will handle direct messages. print(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}") elif isinstance(new_item, HandoffOutputItem): print( f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}" ) elif isinstance(new_item, ToolCallItem): - print(f"{agent_name}: Calling a tool") + # Stash the name of the tool call so we can treat respond_to_user specially + last_tool_name = getattr(new_item.raw_item, "name", None) + print(f"{agent_name} called tool:{f' {last_tool_name}' if last_tool_name else ''}") elif isinstance(new_item, ToolCallOutputItem): - print(f"{agent_name}: Tool call output: {new_item.output}") + # If the tool call was respond_to_user, treat its output as the message to display. + if last_tool_name == "respond_to_user": + print(f"{agent_name}: {new_item.output}") + else: + print(f"{agent_name}: Tool call output: {new_item.output}") else: print(f"{agent_name}: Skipping item: {new_item.__class__.__name__}") input_items = result.to_input_list() From 9402b82b3b4e0adf3dbc61fe24ed1d0cde0a5289 Mon Sep 17 00:00:00 2001 From: jhills20 Date: Tue, 25 Mar 2025 10:49:11 -0700 Subject: [PATCH 2/3] remove comments --- examples/customer_service/main.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/examples/customer_service/main.py b/examples/customer_service/main.py index 4f6d0e4d..0201380f 100644 --- a/examples/customer_service/main.py +++ b/examples/customer_service/main.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations as _annotations import asyncio import random @@ -6,7 +6,7 @@ from pydantic import BaseModel -from src.agents import ( +from agents import ( Agent, HandoffOutputItem, ItemHelpers, @@ -22,7 +22,7 @@ handoff, trace, ) -from src.agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX +from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX ### CONTEXT @@ -162,20 +162,15 @@ async def main(): for new_item in result.new_items: agent_name = new_item.agent.name if isinstance(new_item, MessageOutputItem): - # In tool_choice="required" scenarios, the agent won't produce bare messages; - # instead it will call `respond_to_user`. But if the example is run without - # requiring tool_choice, this branch will handle direct messages. print(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}") elif isinstance(new_item, HandoffOutputItem): print( f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}" ) elif isinstance(new_item, ToolCallItem): - # Stash the name of the tool call so we can treat respond_to_user specially last_tool_name = getattr(new_item.raw_item, "name", None) print(f"{agent_name} called tool:{f' {last_tool_name}' if last_tool_name else ''}") elif isinstance(new_item, ToolCallOutputItem): - # If the tool call was respond_to_user, treat its output as the message to display. if last_tool_name == "respond_to_user": print(f"{agent_name}: {new_item.output}") else: From 84b190e9c23eb5f1d3d0d5962b84a76426f5c802 Mon Sep 17 00:00:00 2001 From: jhills20 Date: Mon, 31 Mar 2025 21:45:46 -0700 Subject: [PATCH 3/3] uber demo working --- examples/uber_agents/__init__.py | 0 examples/uber_agents/frontend/index.html | 410 ++++++++++++++++++ .../frontend/index.html (lines 45-60) | 22 + .../frontend/index.html (lines 75-95) | 16 + examples/uber_agents/main.py | 201 +++++++++ examples/uber_agents/server.py | 143 ++++++ examples/uber_agents/test.json | 0 7 files changed, 792 insertions(+) create mode 100644 examples/uber_agents/__init__.py create mode 100644 examples/uber_agents/frontend/index.html create mode 100644 examples/uber_agents/frontend/index.html (lines 45-60) create mode 100644 examples/uber_agents/frontend/index.html (lines 75-95) create mode 100644 examples/uber_agents/main.py create mode 100644 examples/uber_agents/server.py create mode 100644 examples/uber_agents/test.json diff --git a/examples/uber_agents/__init__.py b/examples/uber_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/uber_agents/frontend/index.html b/examples/uber_agents/frontend/index.html new file mode 100644 index 00000000..4278623a --- /dev/null +++ b/examples/uber_agents/frontend/index.html @@ -0,0 +1,410 @@ + + + + + + Uber Customer Service Chat + + + + + + + + + +
+ + + + diff --git a/examples/uber_agents/frontend/index.html (lines 45-60) b/examples/uber_agents/frontend/index.html (lines 45-60) new file mode 100644 index 00000000..2971bc9e --- /dev/null +++ b/examples/uber_agents/frontend/index.html (lines 45-60) @@ -0,0 +1,22 @@ + /* Add styling for context card for elegant context rendering */ + .context-card { + padding: 15px; + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + } + .context-card dl { + margin: 0; + } + .context-card dt { + font-weight: 600; + color: #555; + margin-top: 10px; + } + .context-card dd { + margin: 0; + padding-left: 10px; + color: #333; + } diff --git a/examples/uber_agents/frontend/index.html (lines 75-95) b/examples/uber_agents/frontend/index.html (lines 75-95) new file mode 100644 index 00000000..1606dc4b --- /dev/null +++ b/examples/uber_agents/frontend/index.html (lines 75-95) @@ -0,0 +1,16 @@ + {/* Context */} +
+

Context

+
+
+
Ride ID
+
{context.ride_id || "N/A"}
+
Ride Info
+
{context.ride_info || "N/A"}
+
Uber One
+
{context.uberone ? "Yes" : "No"}
+
Language
+
{context.language || "N/A"}
+
+
+
diff --git a/examples/uber_agents/main.py b/examples/uber_agents/main.py new file mode 100644 index 00000000..cb2ab7c3 --- /dev/null +++ b/examples/uber_agents/main.py @@ -0,0 +1,201 @@ +from __future__ import annotations as _annotations + +import asyncio +import random +import uuid + +from pydantic import BaseModel + +from agents import ( + Agent, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + RunContextWrapper, + Runner, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, + function_tool, + handoff, + trace, +) +from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX + +### CONTEXT + + +class UberAgentContext(BaseModel): + """Shared context for our Uber agents.""" + ride_id: str | None = None + ride_info: str | None = None + uberone: bool | None = None + language: str = "English" # New field for language preference + uberone = True + + +### TOOLS + +async def _fetch_rider_information( + context: RunContextWrapper[UberAgentContext], ride_id: str +) -> str: + """ + Fetch details about the ride for a given ride ID. In a real application this would look up + ride details from a database or service. Here we just synthesize some data and store it in + context for other tools/agents to use. + + Args: + context: run context containing shared state + ride_id: the ride identifier + """ + context.context.ride_id = ride_id + # Fake ride info + info = ( + f"Ride {ride_id}: pickup at 123 Main St, dropoff at 456 Park Ave, " + f"charged $20 cleaning fee." + ) + context.context.ride_info = info + return info + +@function_tool +async def grant_concession(context: RunContextWrapper[UberAgentContext]) -> str: + """ + Mocked function to grant a $20 concession to the user. + """ + return "A $20 concession has been applied to your account" + +fetch_rider_information = function_tool(_fetch_rider_information) + +### HOOKS / HANDOFFS +async def on_cleaning_fee_handoff(context: RunContextWrapper[UberAgentContext]) -> list[dict[str, str]]: + """ + Lifecycle hook invoked when triage hands off to the cleaning fee agent. + We immediately fetch ride info so the cleaning_fee_instructions can include it. + Now, we also return a log event for the tool call. + """ + # Generate a fake ride ID if none set + ride_id = context.context.ride_id or f"RIDE-{random.randint(1000, 9999)}" + # Call the tool to fetch ride info + result = await _fetch_rider_information(context, ride_id) + return result + + +### AGENTS +def cleaning_fee_instructions(context: RunContextWrapper[UberAgentContext], _agent: Agent[UberAgentContext]) -> str: + # Dynamically include any fetched ride info in the instructions so the agent can see it in the system prompt. + ride_info = context.context.ride_info or "(ride info not available)" + uberone = context.context.uberone + language = context.context.language + + # Base instructions + instructions = ( + f"{RECOMMENDED_PROMPT_PREFIX}\n" + f"Ride information: {ride_info}\n" + ) + + # Add language-specific instructions + if language.lower() != "english": + instructions += "Speak only to the user in Spanish.\n" + + # Add premium-specific instructions + if uberone: + instructions += ( + "You are an agent that helps Uber One riders who have been charged a cleaning fee on their trip.\n" + "# Routine\n" + "1. Call the grant concession tool to issue a refund.\n" + "2. Call end_conversation to end the conversation if user is happy.\n" + ) + else: + instructions += ( + "You are an agent that helps riders who have been charged a cleaning fee on their trip.\n" + "# Routine\n" + "1. Confirm the ride details (date, pickup/dropoff) with the user.\n" + "2. Explain why the cleaning fee was assessed.\n" + "3. If the rider disputes the fee, put in a ticket of their complaint.\n" + "4. Call end_conversation to end the conversation.\n" + ) + + return instructions + +@function_tool +async def end_conversation(context: RunContextWrapper[UberAgentContext]) -> str: + """ + Function to end the conversation. + """ + return "Conversation ended" + + +rider_cleaning_fee_agent = Agent[UberAgentContext]( + name="Rider Cleaning Fee Agent", + handoff_description="Specialist agent for rider cleaning fee disputes.", + instructions=cleaning_fee_instructions, + tools=[grant_concession, end_conversation], +) + +bill_shock_agent = Agent[UberAgentContext]( + name="Bill Shock Agent", + handoff_description="Specialist agent for billing surprises.", + instructions=f"""{RECOMMENDED_PROMPT_PREFIX} + You are an agent that helps riders who are surprised by a large bill. + # Routine + 1. Ask clarifying questions to understand which charge is unexpected. + 2. Check ride details if needed using available tools. + 3. Explain the charges to the rider or escalate to support. + 4. If the issue is unrelated, transfer back to triage.""", + tools=[fetch_rider_information], +) + +triage_agent = Agent[UberAgentContext]( + name="Triage Agent", + handoff_description="Routes incoming rider issues to appropriate specialist agents.", + instructions=( + f"{RECOMMENDED_PROMPT_PREFIX} " + "You are a helpful triage agent. You can use your tools to delegate questions to other appropriate agents." + ), + handoffs=[ + handoff(agent=rider_cleaning_fee_agent, on_handoff=on_cleaning_fee_handoff), + bill_shock_agent, + ], +) + +rider_cleaning_fee_agent.handoffs.append(triage_agent) +bill_shock_agent.handoffs.append(triage_agent) + + +### RUN + + +async def main(): + current_agent: Agent[UberAgentContext] = triage_agent + input_items: list[TResponseInputItem] = [] + context = UberAgentContext() + + # Each interaction could be an API request; here we just simulate a chat loop. + conversation_id = uuid.uuid4().hex[:16] + + while True: + user_input = input("Enter your message: ") + with trace("Uber agents", group_id=conversation_id): + input_items.append({"content": user_input, "role": "user"}) + result = await Runner.run(current_agent, input_items, context=context) + + for new_item in result.new_items: + agent_name = new_item.agent.name + if isinstance(new_item, MessageOutputItem): + print(f"{agent_name}: {ItemHelpers.text_message_output(new_item)}") + elif isinstance(new_item, HandoffOutputItem): + print( + f"Handed off from {new_item.source_agent.name} to {new_item.target_agent.name}" + ) + elif isinstance(new_item, ToolCallItem): + print(f"{agent_name}: Calling a tool") + elif isinstance(new_item, ToolCallOutputItem): + print(f"{agent_name}: Tool call output: {new_item.output}") + else: + print(f"{agent_name}: Skipping item: {new_item.__class__.__name__}") + input_items = result.to_input_list() + current_agent = result.last_agent + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/uber_agents/server.py b/examples/uber_agents/server.py new file mode 100644 index 00000000..8366a787 --- /dev/null +++ b/examples/uber_agents/server.py @@ -0,0 +1,143 @@ +# A simple FastAPI server exposing the uber_agents example as a REST API, +# plus serving a minimal React frontend. +from __future__ import annotations + +import uuid +from pathlib import Path + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse +from starlette.routing import Route + +from agents import ( + Agent, + HandoffOutputItem, + ItemHelpers, + MessageOutputItem, + RunContextWrapper, + Runner, + ToolCallItem, + ToolCallOutputItem, + TResponseInputItem, +) + +from examples.uber_agents.main import UberAgentContext, triage_agent + + +class ConversationState: + """Tracks conversation state for a session.""" + def __init__(self) -> None: + self.current_agent: Agent[UberAgentContext] = triage_agent + self.input_items: list[TResponseInputItem] = [] + self.context: UberAgentContext = UberAgentContext() + + +app = Starlette() + +# In-memory conversation store. +conversations: dict[str, ConversationState] = {} + + +async def init_conversation(request: Request) -> JSONResponse: + """Create a new conversation and return its id.""" + conv_id = uuid.uuid4().hex[:16] + conversations[conv_id] = ConversationState() + return JSONResponse({"conversation_id": conv_id}) + + +async def chat(request: Request) -> JSONResponse: + data = await request.json() + conv_id = data.get("conversation_id") + message = data.get("message") + if not conv_id or conv_id not in conversations: + return JSONResponse({"detail": "Conversation not found"}, status_code=404) + conv = conversations[conv_id] + conv.input_items.append({"content": message, "role": "user"}) + + result = await Runner.run(conv.current_agent, conv.input_items, context=conv.context) + + agent_messages: list[dict[str, str]] = [] + logs: list[dict[str, str]] = [] + # Capture logs returned by on_handoff hooks, if any + + for new_item in result.new_items: + agent_name = new_item.agent.name + if isinstance(new_item, MessageOutputItem): + agent_messages.append({ + "role": agent_name, + "content": ItemHelpers.text_message_output(new_item) + }) + if isinstance(new_item, HandoffOutputItem): + print(new_item) + logs.append({ + "type": "handoff", + "description": f"handed off to {new_item.target_agent.name}", + }) + elif isinstance(new_item, ToolCallItem): + tool_name = getattr(new_item.raw_item, "name", None) + logs.append({"type": "toolcall", "description": f"calling tool {tool_name}"}) + elif isinstance(new_item, ToolCallOutputItem): + logs.append({"type": "toolresult", "description": f"tool result: {new_item.output}"}) + + conv.input_items = result.to_input_list() + conv.current_agent = result.last_agent + + # --- Include the current agent's name and instructions (system prompt) --- + # Some instructions are a callable (like cleaning_fee_instructions). If so, call it: + if callable(conv.current_agent.instructions): + system_prompt = conv.current_agent.instructions(RunContextWrapper(conv.context), conv.current_agent) + else: + system_prompt = conv.current_agent.instructions + + return JSONResponse({ + "agent_messages": agent_messages, + "logs": logs, + "context": conv.context.dict(), + "current_agent_name": conv.current_agent.name, + "system_prompt": system_prompt, + }) + +# Serve the static frontend. We keep everything in a single HTML file for simplicity. +FRONTEND_PATH = Path(__file__).parent / "frontend" / "index.html" + + +async def index(request: Request) -> HTMLResponse: + """Return the frontend HTML page.""" + if not FRONTEND_PATH.exists(): + return HTMLResponse("

Frontend not found

", status_code=404) + return HTMLResponse(FRONTEND_PATH.read_text(encoding="utf-8")) + + + +async def update_uberone(request: Request) -> JSONResponse: + """ + Update the 'uberone' status in the conversation context. + """ + data = await request.json() + conv_id = data.get("conversation_id") + new_uberone_value = data.get("uberone") + + if not conv_id or conv_id not in conversations: + return JSONResponse({"detail": "Conversation not found"}, status_code=404) + + conv = conversations[conv_id] + + # new_premium_value expected to be a boolean (true/false) + conv.context.uberone = bool(new_uberone_value) + + return JSONResponse({ + "context": conv.context.dict(), + "uberone": conv.context.uberone, + }) + + +# Build the app routes. +app.routes.extend( + [ + Route("/", index), + Route("/init", init_conversation, methods=["POST"]), + Route("/chat", chat, methods=["POST"]), + Route("/update_uberone", update_uberone, methods=["POST"]), + ] +) diff --git a/examples/uber_agents/test.json b/examples/uber_agents/test.json new file mode 100644 index 00000000..e69de29b