Skip to content

Commit c351490

Browse files
authored
Merge pull request #33 from wild-edge/release/0.1.3
Release 0.1.3
2 parents 5029893 + fced74b commit c351490

27 files changed

Lines changed: 1607 additions & 59 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,5 @@ marimo/_lsp/
213213
__marimo__/
214214

215215
# Streamlit
216-
.streamlit/secrets.toml
216+
.streamlit/secrets.toml
217+
internal/

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ client = wildedge.init(
5959
If no DSN is configured, the client becomes a no-op and logs a warning.
6060

6161
`init(...)` is a convenience wrapper for `WildEdge(...)` + `instrument(...)`.
62-
6362
## Supported integrations
6463

6564
**On-device**
@@ -105,6 +104,15 @@ For unsupported frameworks, see [Manual tracking](https://github.com/wild-edge/w
105104

106105
For advanced options (batching, queue tuning, dead-letter storage), see [Configuration](https://github.com/wild-edge/wildedge-python/blob/main/docs/configuration.md).
107106

107+
## Projects using this SDK
108+
109+
| Name | Link |
110+
|---|---|
111+
| agntr | [github.com/pmaciolek/agntr](https://github.com/pmaciolek/agntr) |
112+
| *(your project here)* | - |
113+
114+
Using WildEdge in your project? Open a PR to add it to the list.
115+
108116
## Privacy
109117

110118
Report security & priact issues to: wildedge@googlegroups.com.

docs/manual-tracking.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,58 @@ handle.feedback(FeedbackType.THUMBS_DOWN)
215215
```
216216

217217
`FeedbackType` values: `THUMBS_UP`, `THUMBS_DOWN`.
218+
219+
## Track spans for agentic workflows
220+
221+
Use span events to track non-inference steps like planning, tool calls, retrieval, or memory updates.
222+
223+
```python
224+
from wildedge.timing import Timer
225+
226+
with Timer() as t:
227+
tool_result = call_tool()
228+
229+
client.track_span(
230+
kind="tool",
231+
name="call_tool",
232+
duration_ms=t.elapsed_ms,
233+
status="ok",
234+
attributes={"tool": "search"},
235+
)
236+
```
237+
238+
You can also attach optional correlation fields (`trace_id`, `span_id`,
239+
`parent_span_id`, `run_id`, `agent_id`, `step_index`, `conversation_id`) to any
240+
event by passing them into `track_inference`, `track_error`, `track_feedback`,
241+
or `track_span`. Use `context=` for correlation attributes shared across events.
242+
243+
### Trace context helpers
244+
245+
Use `client.trace()` and `client.span()` to auto-populate correlation fields for
246+
all events emitted inside the block. `client.span()` times the block and emits a
247+
span event on exit:
248+
249+
```python
250+
import wildedge
251+
from wildedge.timing import Timer
252+
253+
client = wildedge.init()
254+
handle = client.register_model(my_model, model_id="my-org/my-model")
255+
256+
with client.trace(run_id="run-123", agent_id="agent-1"):
257+
with client.span(kind="agent_step", name="plan", step_index=1):
258+
with Timer() as t:
259+
result = my_model(prompt)
260+
handle.track_inference(duration_ms=t.elapsed_ms, input_modality="text", output_modality="generation")
261+
```
262+
263+
If you need to set correlation fields without emitting a span event, use the
264+
lower-level `span_context()` directly:
265+
266+
```python
267+
with client.trace(run_id="run-123", agent_id="agent-1"):
268+
with wildedge.span_context(step_index=1):
269+
with Timer() as t:
270+
result = my_model(prompt)
271+
handle.track_inference(duration_ms=t.elapsed_ms, input_modality="text", output_modality="generation")
272+
```

examples/agentic_example.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# /// script
2+
# requires-python = ">=3.10"
3+
# dependencies = ["wildedge-sdk", "openai"]
4+
#
5+
# [tool.uv.sources]
6+
# wildedge-sdk = { path = "..", editable = true }
7+
# ///
8+
"""Agentic workflow example with tool use.
9+
10+
Demonstrates WildEdge tracing for a simple agent that:
11+
- Runs within a trace (one per agent session)
12+
- Wraps each reasoning step in an agent_step span
13+
- Wraps each tool call in a tool span
14+
- Tracks LLM inference automatically via the OpenAI integration
15+
16+
Run with: uv run agentic_example.py
17+
Requires: OPENROUTER_API_KEY environment variable. Set WILDEDGE_DSN to send events.
18+
"""
19+
20+
import json
21+
import os
22+
import time
23+
import uuid
24+
25+
from openai import OpenAI
26+
27+
import wildedge
28+
29+
we = wildedge.init(
30+
app_version="1.0.0",
31+
integrations="openai",
32+
)
33+
34+
openai_client = OpenAI(
35+
base_url="https://openrouter.ai/api/v1",
36+
api_key=os.getenv("OPENROUTER_API_KEY"),
37+
)
38+
39+
# --- Tools -------------------------------------------------------------------
40+
41+
TOOLS = [
42+
{
43+
"type": "function",
44+
"function": {
45+
"name": "get_weather",
46+
"description": "Return current weather for a city.",
47+
"parameters": {
48+
"type": "object",
49+
"properties": {
50+
"city": {"type": "string"},
51+
},
52+
"required": ["city"],
53+
},
54+
},
55+
},
56+
{
57+
"type": "function",
58+
"function": {
59+
"name": "calculator",
60+
"description": "Evaluate a simple arithmetic expression.",
61+
"parameters": {
62+
"type": "object",
63+
"properties": {
64+
"expression": {"type": "string"},
65+
},
66+
"required": ["expression"],
67+
},
68+
},
69+
},
70+
]
71+
72+
73+
def get_weather(city: str) -> str:
74+
# ~150ms to simulate a real weather API call.
75+
time.sleep(0.15)
76+
return json.dumps({"city": city, "temperature_c": 18, "condition": "partly cloudy"})
77+
78+
79+
def calculator(expression: str) -> str:
80+
# ~60ms to simulate a remote computation call.
81+
time.sleep(0.06)
82+
try:
83+
result = eval(expression, {"__builtins__": {}}) # noqa: S307
84+
return json.dumps({"expression": expression, "result": result})
85+
except Exception as e:
86+
return json.dumps({"error": str(e)})
87+
88+
89+
TOOL_HANDLERS = {
90+
"get_weather": get_weather,
91+
"calculator": calculator,
92+
}
93+
94+
95+
# --- Agent loop --------------------------------------------------------------
96+
97+
98+
def call_tool(name: str, arguments: dict) -> str:
99+
with we.span(
100+
kind="tool",
101+
name=name,
102+
input_summary=json.dumps(arguments)[:200],
103+
) as span:
104+
result = TOOL_HANDLERS[name](**arguments)
105+
span.output_summary = result[:200]
106+
return result
107+
108+
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+
122+
def run_agent(task: str, step_index: int, messages: list) -> str:
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}"})
126+
127+
while True:
128+
with we.span(
129+
kind="agent_step",
130+
name="reason",
131+
step_index=step_index,
132+
input_summary=task[:200],
133+
) as span:
134+
response = openai_client.chat.completions.create(
135+
model="qwen/qwen3.5-flash-02-23",
136+
messages=messages,
137+
tools=TOOLS,
138+
tool_choice="auto",
139+
max_tokens=512,
140+
)
141+
choice = response.choices[0]
142+
span.output_summary = choice.finish_reason
143+
144+
messages.append(choice.message.model_dump(exclude_none=True))
145+
146+
if choice.finish_reason == "tool_calls":
147+
step_index += 1
148+
for tool_call in choice.message.tool_calls:
149+
arguments = json.loads(tool_call.function.arguments)
150+
result = call_tool(tool_call.function.name, arguments)
151+
messages.append(
152+
{
153+
"role": "tool",
154+
"tool_call_id": tool_call.id,
155+
"content": result,
156+
}
157+
)
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)
161+
else:
162+
return choice.message.content or ""
163+
164+
165+
# --- Main --------------------------------------------------------------------
166+
167+
TASKS = [
168+
"What's the weather like in Tokyo, and what is 42 * 18?",
169+
"Is it warmer in Paris or Berlin right now?",
170+
]
171+
172+
system_prompt = "You are a helpful assistant. Use tools when needed."
173+
messages = [{"role": "system", "content": system_prompt}]
174+
175+
with we.trace(agent_id="demo-agent", run_id=str(uuid.uuid4())):
176+
for i, task in enumerate(TASKS, start=1):
177+
print(f"\nTask {i}: {task}")
178+
reply = run_agent(task, step_index=i, messages=messages)
179+
print(f"Reply: {reply}")
180+
181+
we.flush()

examples/gguf_example.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
]
3232

3333
for prompt in prompts:
34-
result = llm(prompt, max_tokens=128, temperature=0.7)
35-
text = result["choices"][0]["text"].strip()
36-
print(f"Q: {prompt}\nA: {text}\n")
34+
stream = llm(prompt, max_tokens=128, temperature=0.7, stream=True)
35+
print(f"Q: {prompt}\nA: ", end="", flush=True)
36+
for chunk in stream:
37+
token = chunk["choices"][0].get("text", "")
38+
print(token, end="", flush=True)
39+
print("\n")

examples/openai_example.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,19 @@
3232
]
3333

3434
for prompt in prompts:
35-
response = openai_client.chat.completions.create(
35+
stream = openai_client.chat.completions.create(
3636
model="gpt-4o",
3737
messages=[{"role": "user", "content": prompt}],
3838
temperature=0.7,
3939
max_tokens=256,
40+
stream=True,
41+
stream_options={"include_usage": True},
4042
)
41-
print(f"Q: {prompt}\nA: {response.choices[0].message.content}\n")
43+
print(f"Q: {prompt}\nA: ", end="", flush=True)
44+
for chunk in stream:
45+
if chunk.choices and chunk.choices[0].delta.content:
46+
print(chunk.choices[0].delta.content, end="", flush=True)
47+
print("\n")
4248

4349
client.flush()
4450
print("Done. Events flushed to WildEdge.")

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "wildedge-sdk"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
description = "On-device ML inference monitoring for Python"
55
readme = "README.md"
66
requires-python = ">=3.10"
@@ -42,6 +42,7 @@ build-backend = "hatchling.build"
4242
[tool.hatch.build]
4343
exclude = [
4444
"/scripts",
45+
"/examples",
4546
]
4647

4748
[tool.hatch.build.targets.wheel]

tests/test_event_serialization.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from wildedge.events.inference import InferenceEvent, TextInputMeta
55
from wildedge.events.model_download import AdapterDownload, ModelDownloadEvent
66
from wildedge.events.model_load import AdapterLoad, ModelLoadEvent
7+
from wildedge.events.span import SpanEvent
78

89

910
def test_inference_event_to_dict_omits_none_fields():
@@ -72,3 +73,44 @@ def test_feedback_event_enum_and_string_forms():
7273
)
7374
assert enum_event.to_dict()["feedback"]["feedback_type"] == "accept"
7475
assert string_event.to_dict()["feedback"]["feedback_type"] == "reject"
76+
77+
78+
def test_span_event_to_dict_includes_required_fields():
79+
event = SpanEvent(
80+
kind="tool",
81+
name="search",
82+
duration_ms=250,
83+
status="ok",
84+
attributes={"provider": "custom"},
85+
)
86+
data = event.to_dict()
87+
assert data["event_type"] == "span"
88+
assert data["span"]["kind"] == "tool"
89+
assert data["span"]["attributes"]["provider"] == "custom"
90+
91+
92+
def test_span_event_context_serializes_under_context_key():
93+
event = SpanEvent(
94+
kind="agent_step",
95+
name="plan",
96+
duration_ms=10,
97+
status="ok",
98+
context={"user_id": "u1"},
99+
)
100+
data = event.to_dict()
101+
assert data["context"] == {"user_id": "u1"}
102+
assert "attributes" not in data
103+
104+
105+
def test_span_event_attributes_and_context_are_independent():
106+
event = SpanEvent(
107+
kind="tool",
108+
name="search",
109+
duration_ms=50,
110+
status="ok",
111+
attributes={"provider": "custom"},
112+
context={"user_id": "u1"},
113+
)
114+
data = event.to_dict()
115+
assert data["span"]["attributes"] == {"provider": "custom"}
116+
assert data["context"] == {"user_id": "u1"}

0 commit comments

Comments
 (0)