Skip to content

Commit 391e5b0

Browse files
committed
add handoff
1 parent 3efa0eb commit 391e5b0

3 files changed

Lines changed: 247 additions & 1 deletion

File tree

packages/opentelemetry-instrumentation-openai-agents/opentelemetry/instrumentation/openai_agents/_hooks.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ def on_span_start(self, span):
607607
@dont_throw
608608
def on_span_end(self, span):
609609
"""Called when a span ends - finish OpenTelemetry span."""
610-
from agents import GenerationSpanData
610+
from agents import GenerationSpanData, HandoffSpanData
611611

612612
if not span or not hasattr(span, "span_data"):
613613
return
@@ -616,6 +616,25 @@ def on_span_end(self, span):
616616
otel_span = self._otel_spans[span]
617617
span_data = getattr(span, "span_data", None)
618618
trace_content = should_send_prompts()
619+
620+
# The OpenAI Agents SDK only assigns ``to_agent`` on a HandoffSpanData
621+
# *after* the handoff has been invoked, which happens between
622+
# ``on_span_start`` and ``on_span_end``. Re-read the field here so
623+
# the OTel span name and the ``gen_ai.handoff.to_agent`` attribute
624+
# both reflect the resolved target agent.
625+
if isinstance(span_data, HandoffSpanData):
626+
from_agent = getattr(span_data, "from_agent", None) or "unknown"
627+
to_agent = getattr(span_data, "to_agent", None)
628+
if to_agent:
629+
otel_span.update_name(f"{from_agent}{to_agent}.handoff")
630+
otel_span.set_attribute(GEN_AI_HANDOFF_TO_AGENT, to_agent)
631+
trace_id = getattr(span, "trace_id", None)
632+
if trace_id and from_agent != "unknown":
633+
handoff_key = f"{to_agent}:{trace_id}"
634+
self._reverse_handoffs_dict[handoff_key] = from_agent
635+
if len(self._reverse_handoffs_dict) > 1000:
636+
self._reverse_handoffs_dict.popitem(last=False)
637+
619638
if span_data and (
620639
type(span_data).__name__ == "ResponseSpanData"
621640
or isinstance(span_data, GenerationSpanData)

packages/opentelemetry-instrumentation-openai-agents/tests/test_complete_handoff_with_tools.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,22 @@ async def test_router_analytics_complete_workflow(exporter, workflow_agents):
107107
workflow_span = workflow_spans[0]
108108
handoff_span = handoff_spans[0]
109109

110+
# The handoff span must reflect the resolved target agent — both the span
111+
# name and the gen_ai.handoff.to_agent attribute. Regression guard: the SDK
112+
# only assigns ``to_agent`` between ``on_span_start`` and ``on_span_end``,
113+
# so the instrumentation has to re-read it at end-of-span.
114+
assert "unknown" not in handoff_span.name, (
115+
f"Handoff span name should not contain 'unknown', got {handoff_span.name!r}"
116+
)
117+
assert handoff_span.name == "Data Router Agent → Analytics Agent.handoff", (
118+
f"Unexpected handoff span name: {handoff_span.name!r}"
119+
)
120+
assert handoff_span.attributes.get("gen_ai.handoff.from_agent") == "Data Router Agent"
121+
assert handoff_span.attributes.get("gen_ai.handoff.to_agent") == "Analytics Agent", (
122+
"Handoff span should expose gen_ai.handoff.to_agent for the resolved target agent, "
123+
f"got {dict(handoff_span.attributes)!r}"
124+
)
125+
110126
assert workflow_span.parent is None, "Agent Workflow should be root"
111127
assert analytics_span.parent is not None, "Analytics Agent should have parent"
112128
assert analytics_span.parent.span_id == workflow_span.context.span_id, "Analytics should be child of Workflow"
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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

Comments
 (0)