|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Recipe Planner Handoff Demo for Traceloop. |
| 4 | +
|
| 5 | +Two specialized OpenAI Agents connected via a handoff: |
| 6 | +
|
| 7 | + Recipe Researcher → searches recipes, checks pantry, estimates cost, |
| 8 | + then hands off to the composer. |
| 9 | + Meal Plan Composer → receives the researcher's findings and composes the |
| 10 | + final 3-meal plan with the compose_meal_plan tool. |
| 11 | +
|
| 12 | +This exercises the handoff span path so you can verify the |
| 13 | +``Recipe Researcher → Meal Plan Composer.handoff`` span (and the |
| 14 | +``gen_ai.handoff.to_agent`` attribute) in Traceloop. |
| 15 | +""" |
| 16 | + |
| 17 | +import asyncio |
| 18 | +import os |
| 19 | +from typing import List |
| 20 | + |
| 21 | +from agents import Agent, Runner, function_tool |
| 22 | +from dotenv import load_dotenv |
| 23 | +from pydantic import BaseModel |
| 24 | +from traceloop.sdk import Traceloop |
| 25 | +from traceloop.sdk.instruments import Instruments |
| 26 | + |
| 27 | +load_dotenv() |
| 28 | + |
| 29 | +Traceloop.init( |
| 30 | + app_name="recipe-planner-handoff-demo", |
| 31 | + disable_batch=True, |
| 32 | + instruments={Instruments.OPENAI, Instruments.OPENAI_AGENTS}, |
| 33 | +) |
| 34 | + |
| 35 | + |
| 36 | +RECIPE_DATABASE = { |
| 37 | + "R-001": { |
| 38 | + "name": "Chickpea curry with brown rice", |
| 39 | + "tags": ["vegetarian", "gluten-free"], |
| 40 | + "pantry_pct": 0.7, |
| 41 | + "cost_usd": 4.50, |
| 42 | + }, |
| 43 | + "R-002": { |
| 44 | + "name": "Sheet-pan roasted vegetables", |
| 45 | + "tags": ["vegetarian", "vegan", "gluten-free"], |
| 46 | + "pantry_pct": 0.85, |
| 47 | + "cost_usd": 3.20, |
| 48 | + }, |
| 49 | + "R-003": { |
| 50 | + "name": "Lentil soup with crusty bread", |
| 51 | + "tags": ["vegetarian", "vegan"], |
| 52 | + "pantry_pct": 0.6, |
| 53 | + "cost_usd": 2.80, |
| 54 | + }, |
| 55 | + "R-004": { |
| 56 | + "name": "Pasta primavera", |
| 57 | + "tags": ["vegetarian"], |
| 58 | + "pantry_pct": 0.5, |
| 59 | + "cost_usd": 5.10, |
| 60 | + }, |
| 61 | + "R-005": { |
| 62 | + "name": "Black bean tacos", |
| 63 | + "tags": ["vegetarian", "vegan"], |
| 64 | + "pantry_pct": 0.65, |
| 65 | + "cost_usd": 4.10, |
| 66 | + }, |
| 67 | +} |
| 68 | + |
| 69 | + |
| 70 | +class Recipe(BaseModel): |
| 71 | + recipe_id: str |
| 72 | + name: str |
| 73 | + tags: List[str] |
| 74 | + |
| 75 | + |
| 76 | +class RecipeSearchResponse(BaseModel): |
| 77 | + status: str |
| 78 | + message: str |
| 79 | + recipes: List[Recipe] = [] |
| 80 | + |
| 81 | + |
| 82 | +class PantryCheck(BaseModel): |
| 83 | + recipe_id: str |
| 84 | + pantry_pct: float |
| 85 | + |
| 86 | + |
| 87 | +class CostEstimate(BaseModel): |
| 88 | + recipe_id: str |
| 89 | + cost_usd: float |
| 90 | + |
| 91 | + |
| 92 | +class MealPlan(BaseModel): |
| 93 | + picks: List[str] |
| 94 | + total_cost_usd: float |
| 95 | + notes: str |
| 96 | + |
| 97 | + |
| 98 | +@function_tool |
| 99 | +async def search_recipes(constraints: List[str]) -> RecipeSearchResponse: |
| 100 | + """Return up to 5 recipes whose tags satisfy every constraint.""" |
| 101 | + await asyncio.sleep(0.1) |
| 102 | + wanted = {c.lower() for c in constraints} |
| 103 | + matches = [ |
| 104 | + Recipe(recipe_id=rid, name=r["name"], tags=r["tags"]) |
| 105 | + for rid, r in RECIPE_DATABASE.items() |
| 106 | + if wanted.issubset({t.lower() for t in r["tags"]}) |
| 107 | + ][:5] |
| 108 | + return RecipeSearchResponse( |
| 109 | + status="success", |
| 110 | + message=f"Found {len(matches)} recipes for {constraints}", |
| 111 | + recipes=matches, |
| 112 | + ) |
| 113 | + |
| 114 | + |
| 115 | +@function_tool |
| 116 | +async def check_pantry(recipe_id: str) -> PantryCheck: |
| 117 | + """Return the fraction of a recipe's ingredients already on hand.""" |
| 118 | + await asyncio.sleep(0.1) |
| 119 | + r = RECIPE_DATABASE[recipe_id] |
| 120 | + return PantryCheck(recipe_id=recipe_id, pantry_pct=r["pantry_pct"]) |
| 121 | + |
| 122 | + |
| 123 | +@function_tool |
| 124 | +async def estimate_cost(recipe_id: str) -> CostEstimate: |
| 125 | + """Estimate the dollar cost to cook a recipe given current pantry levels.""" |
| 126 | + await asyncio.sleep(0.1) |
| 127 | + r = RECIPE_DATABASE[recipe_id] |
| 128 | + return CostEstimate(recipe_id=recipe_id, cost_usd=r["cost_usd"]) |
| 129 | + |
| 130 | + |
| 131 | +@function_tool |
| 132 | +async def compose_meal_plan(picks: List[str], notes: str = "") -> MealPlan: |
| 133 | + """Compose a final meal plan from selected recipe IDs.""" |
| 134 | + await asyncio.sleep(0.1) |
| 135 | + total = sum(RECIPE_DATABASE[rid]["cost_usd"] for rid in picks if rid in RECIPE_DATABASE) |
| 136 | + return MealPlan(picks=picks, total_cost_usd=round(total, 2), notes=notes) |
| 137 | + |
| 138 | + |
| 139 | +def build_agents() -> Agent: |
| 140 | + composer = Agent( |
| 141 | + name="Meal Plan Composer", |
| 142 | + handoff_description="Composes the final 3-meal plan from researched recipes.", |
| 143 | + instructions=( |
| 144 | + "You are the Meal Plan Composer. The Recipe Researcher has handed off to " |
| 145 | + "you with candidate recipes and their pantry/cost data. Pick exactly 3 " |
| 146 | + "that fit the user's constraints and budget, call compose_meal_plan once, " |
| 147 | + "then summarize the plan and confirm it fits the budget. Do NOT call any " |
| 148 | + "research tools — that work is already done." |
| 149 | + ), |
| 150 | + model="gpt-4o", |
| 151 | + tools=[compose_meal_plan], |
| 152 | + ) |
| 153 | + |
| 154 | + researcher = Agent( |
| 155 | + name="Recipe Researcher", |
| 156 | + handoff_description="Searches recipes and gathers pantry + cost data.", |
| 157 | + instructions=( |
| 158 | + "You are the Recipe Researcher. Build the data the Meal Plan Composer " |
| 159 | + "needs to assemble a 3-meal plan within the user's dietary constraints " |
| 160 | + "and budget. Steps, in order: (1) search_recipes; (2) check_pantry for " |
| 161 | + "at least 3 candidates; (3) estimate_cost for those same candidates; " |
| 162 | + "(4) hand off to the Meal Plan Composer with a brief summary. Do NOT " |
| 163 | + "call compose_meal_plan yourself — that is the composer's job." |
| 164 | + ), |
| 165 | + model="gpt-4o", |
| 166 | + tools=[search_recipes, check_pantry, estimate_cost], |
| 167 | + handoffs=[composer], |
| 168 | + ) |
| 169 | + |
| 170 | + return researcher |
| 171 | + |
| 172 | + |
| 173 | +async def demo() -> None: |
| 174 | + researcher = build_agents() |
| 175 | + |
| 176 | + print("\n🚀 Starting recipe planner handoff workflow...") |
| 177 | + print("📊 Check Traceloop for the trace hierarchy.") |
| 178 | + |
| 179 | + query = "Build me a 3-meal plan that is vegetarian and fits a $20 budget." |
| 180 | + messages = [{"role": "user", "content": query}] |
| 181 | + runner = Runner().run_streamed(starting_agent=researcher, input=messages) |
| 182 | + |
| 183 | + async for event in runner.stream_events(): |
| 184 | + if event.type == "agent_updated_stream_event": |
| 185 | + print(f"🔁 Active agent → {event.new_agent.name}") |
| 186 | + elif event.type == "run_item_stream_event": |
| 187 | + if "handoff" in event.name.lower(): |
| 188 | + print(f"🔄 {event.name}") |
| 189 | + elif "tool" in event.name.lower(): |
| 190 | + print(f"🔧 {event.name}") |
| 191 | + |
| 192 | + print("\n✅ Demo complete!") |
| 193 | + print("📊 Expected trace hierarchy in Traceloop:") |
| 194 | + print(" 🌐 Agent Workflow") |
| 195 | + print(" ├─ 🤖 Recipe Researcher.agent") |
| 196 | + print(" │ ├─ 🔧 search_recipes.tool") |
| 197 | + print(" │ ├─ 🔧 check_pantry.tool (×N)") |
| 198 | + print(" │ └─ 🔧 estimate_cost.tool (×N)") |
| 199 | + print(" ├─ 🔄 Recipe Researcher → Meal Plan Composer.handoff") |
| 200 | + print(" └─ 🤖 Meal Plan Composer.agent") |
| 201 | + print(" └─ 🔧 compose_meal_plan.tool") |
| 202 | + |
| 203 | + |
| 204 | +if __name__ == "__main__": |
| 205 | + if not os.getenv("OPENAI_API_KEY"): |
| 206 | + print("❌ Set OPENAI_API_KEY environment variable") |
| 207 | + raise SystemExit(1) |
| 208 | + |
| 209 | + print("🎯 OpenAI Agents Handoff Demo — Recipe Planner") |
| 210 | + print("=" * 48) |
| 211 | + asyncio.run(demo()) |
0 commit comments