Skip to content

Commit 3371f45

Browse files
committed
Nicer agentic example
1 parent 55d3614 commit 3371f45

4 files changed

Lines changed: 108 additions & 14 deletions

File tree

examples/agentic_example.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
- Tracks LLM inference automatically via the OpenAI integration
1515
1616
Run with: uv run agentic_example.py
17-
Requires: OPENAI_API_KEY environment variable. Set WILDEDGE_DSN to send events.
17+
Requires: OPENROUTER_API_KEY environment variable. Set WILDEDGE_DSN to send events.
1818
"""
1919

2020
import json
21+
import os
22+
import time
23+
import uuid
2124

2225
from openai import OpenAI
2326

@@ -28,7 +31,10 @@
2831
integrations="openai",
2932
)
3033

31-
openai_client = OpenAI()
34+
openai_client = OpenAI(
35+
base_url="https://openrouter.ai/api/v1",
36+
api_key=os.getenv("OPENROUTER_API_KEY"),
37+
)
3238

3339
# --- Tools -------------------------------------------------------------------
3440

@@ -65,11 +71,14 @@
6571

6672

6773
def get_weather(city: str) -> str:
68-
# Stub: replace with a real weather API call.
74+
# ~150ms to simulate a real weather API call.
75+
time.sleep(0.15)
6976
return json.dumps({"city": city, "temperature_c": 18, "condition": "partly cloudy"})
7077

7178

7279
def calculator(expression: str) -> str:
80+
# ~60ms to simulate a remote computation call.
81+
time.sleep(0.06)
7382
try:
7483
result = eval(expression, {"__builtins__": {}}) # noqa: S307
7584
return json.dumps({"expression": expression, "result": result})
@@ -97,8 +106,23 @@ def call_tool(name: str, arguments: dict) -> str:
97106
return result
98107

99108

109+
def retrieve_context(query: str) -> str:
110+
"""Fetch relevant context from the vector store (~120ms)."""
111+
with we.span(
112+
kind="retrieval",
113+
name="vector_search",
114+
input_summary=query[:200],
115+
) as span:
116+
time.sleep(0.12)
117+
result = f"[context: background knowledge relevant to '{query[:40]}']"
118+
span.output_summary = result
119+
return result
120+
121+
100122
def run_agent(task: str, step_index: int, messages: list) -> str:
101-
messages.append({"role": "user", "content": task})
123+
# Fetch context before the first reasoning step, include it in the user turn.
124+
context = retrieve_context(task)
125+
messages.append({"role": "user", "content": f"{task}\n\nContext: {context}"})
102126

103127
while True:
104128
with we.span(
@@ -108,15 +132,16 @@ def run_agent(task: str, step_index: int, messages: list) -> str:
108132
input_summary=task[:200],
109133
) as span:
110134
response = openai_client.chat.completions.create(
111-
model="gpt-4o",
135+
model="qwen/qwen3.5-flash-02-23",
112136
messages=messages,
113137
tools=TOOLS,
114138
tool_choice="auto",
139+
max_tokens=512,
115140
)
116141
choice = response.choices[0]
117142
span.output_summary = choice.finish_reason
118143

119-
messages.append(choice.message)
144+
messages.append(choice.message.model_dump(exclude_none=True))
120145

121146
if choice.finish_reason == "tool_calls":
122147
step_index += 1
@@ -130,8 +155,11 @@ def run_agent(task: str, step_index: int, messages: list) -> str:
130155
"content": result,
131156
}
132157
)
158+
# Not instrumented: context window update between tool calls (~80ms).
159+
# Shows up as a gap stripe in the trace view.
160+
time.sleep(0.08)
133161
else:
134-
return choice.message.content
162+
return choice.message.content or ""
135163

136164

137165
# --- Main --------------------------------------------------------------------
@@ -144,11 +172,10 @@ def run_agent(task: str, step_index: int, messages: list) -> str:
144172
system_prompt = "You are a helpful assistant. Use tools when needed."
145173
messages = [{"role": "system", "content": system_prompt}]
146174

147-
with we.trace(agent_id="demo-agent", run_id="example-run-001"):
175+
with we.trace(agent_id="demo-agent", run_id=str(uuid.uuid4())):
148176
for i, task in enumerate(TASKS, start=1):
149177
print(f"\nTask {i}: {task}")
150178
reply = run_agent(task, step_index=i, messages=messages)
151179
print(f"Reply: {reply}")
152180

153181
we.flush()
154-
print("\nDone. Events flushed to WildEdge.")

tests/test_tracing.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22

3+
from wildedge.client import SpanContextManager
34
from wildedge.model import ModelHandle, ModelInfo
4-
from wildedge.tracing import span_context, trace_context
5+
from wildedge.tracing import get_span_context, span_context, trace_context
56

67

78
def test_track_inference_uses_trace_context():
@@ -36,3 +37,57 @@ def publish(event: dict) -> None:
3637
assert events[0]["agent_id"] == "agent-1"
3738
assert events[0]["step_index"] == 2
3839
assert events[0]["attributes"] == {"trace_key": "trace_val", "span_key": 2}
40+
41+
42+
class _FakeClient:
43+
def __init__(self, events: list[dict]) -> None:
44+
self._events = events
45+
46+
def track_span(self, **kwargs) -> str:
47+
self._events.append(kwargs)
48+
return kwargs.get("span_id", "")
49+
50+
51+
def test_span_root_has_no_parent():
52+
"""A root span must not reference itself as its own parent."""
53+
events: list[dict] = []
54+
client = _FakeClient(events)
55+
56+
with SpanContextManager(client, kind="agent_step", name="root"):
57+
pass
58+
59+
assert len(events) == 1
60+
assert events[0]["parent_span_id"] is None
61+
62+
63+
def test_span_context_restored_after_exit():
64+
"""The active span context must revert to the parent after a span exits."""
65+
events: list[dict] = []
66+
client = _FakeClient(events)
67+
68+
with span_context(span_id="parent-span"):
69+
with SpanContextManager(client, kind="agent_step", name="child"):
70+
inner_id = get_span_context().span_id
71+
72+
assert get_span_context().span_id == "parent-span"
73+
74+
assert inner_id != "parent-span"
75+
assert events[0]["parent_span_id"] == "parent-span"
76+
assert events[0]["span_id"] != "parent-span"
77+
78+
79+
def test_nested_spans_correct_parent_chain():
80+
"""Nested spans must each point to their direct parent, not themselves."""
81+
events: list[dict] = []
82+
client = _FakeClient(events)
83+
84+
with SpanContextManager(client, kind="agent_step", name="outer") as outer:
85+
with SpanContextManager(client, kind="tool", name="inner") as inner:
86+
pass
87+
88+
assert len(events) == 2
89+
inner_ev, outer_ev = events[0], events[1]
90+
assert inner_ev["span_id"] == inner.span_id
91+
assert inner_ev["parent_span_id"] == outer.span_id
92+
assert outer_ev["span_id"] == outer.span_id
93+
assert outer_ev["parent_span_id"] is None

wildedge/client.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ def __exit__(self, exc_type, exc_val, exc_tb):
175175
return False
176176
duration_ms = elapsed_ms(self._t0)
177177
status = "error" if exc_type else self.status
178+
# Restore parent span context before emitting, so _merge_correlation_fields
179+
# sees the parent context rather than this span (which would make the span
180+
# appear as its own parent).
181+
if self._span_token is not None:
182+
_reset_span_context(self._span_token)
183+
self._span_token = None
178184
self._client.track_span(
179185
kind=self.kind,
180186
name=self.name,
@@ -193,8 +199,6 @@ def __exit__(self, exc_type, exc_val, exc_tb):
193199
conversation_id=self.conversation_id,
194200
context=self.context,
195201
)
196-
if self._span_token is not None:
197-
_reset_span_context(self._span_token)
198202
return False
199203

200204
async def __aenter__(self):

wildedge/integrations/openai.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,21 @@ def source_from_base_url(base_url: str | None) -> str:
4141
return SOURCE_BY_HOSTNAME.get(hostname or "", hostname or "openai")
4242

4343

44+
def _msg_role(m) -> str | None:
45+
return m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
46+
47+
48+
def _msg_content(m) -> str | None:
49+
return m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
50+
51+
4452
def build_input_meta(messages: list, tokens_in: int | None) -> TextInputMeta | None:
4553
if not messages:
4654
return None
47-
last_user = next((m for m in reversed(messages) if m.get("role") == "user"), None)
55+
last_user = next((m for m in reversed(messages) if _msg_role(m) == "user"), None)
4856
if not last_user:
4957
return None
50-
content = last_user.get("content", "")
58+
content = _msg_content(last_user) or ""
5159
if not isinstance(content, str) or not content:
5260
return None
5361
return TextInputMeta(

0 commit comments

Comments
 (0)