trodo-python 1.2.0__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ """wrap_agent + join_run + span — user-facing surface for agent tracking.
2
+
3
+ Users never generate IDs. The SDK creates run_id on wrap_agent and threads
4
+ span_id through contextvars. Any nested ``span()`` call (or OTel
5
+ auto-instrumented span from Anthropic/OpenAI/LangChain/etc.) inherits the
6
+ current span as its parent.
7
+
8
+ Usage (root service):
9
+ with trodo.wrap_agent('customer-support',
10
+ distinct_id=uid, conversation_id=conv) as run:
11
+ result = agent.run(query)
12
+ run.set_output(result)
13
+
14
+ Usage (downstream microservice — becomes a SPAN on the caller's run):
15
+ with trodo.join_run(run_id=headers['X-Trodo-Run-Id'],
16
+ parent_span_id=headers['X-Trodo-Parent-Span-Id']):
17
+ # LLM calls here are auto-captured as spans under the caller's run
18
+ ...
19
+
20
+ Decorator form:
21
+ @trodo.agent('nightly-report')
22
+ def nightly_report(): ...
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import time
29
+ import uuid
30
+ from datetime import datetime, timezone
31
+ from typing import Any, Callable, Dict, Optional
32
+
33
+ from .context import ActiveSpanContext, get_active_context, run_with_context
34
+ from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
35
+
36
+
37
+ def _now_iso() -> str:
38
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
39
+
40
+
41
+ def _truncate(value: Any, max_len: int = 64_000) -> Optional[str]:
42
+ if value is None:
43
+ return None
44
+ if isinstance(value, str):
45
+ s = value
46
+ else:
47
+ try:
48
+ s = json.dumps(value, default=str)
49
+ except Exception:
50
+ s = str(value)
51
+ return s[:max_len] if len(s) > max_len else s
52
+
53
+
54
+ def current_run_id() -> Optional[str]:
55
+ """Return the run_id of the currently active agent run, if any."""
56
+ ctx = get_active_context()
57
+ return ctx.run_id if ctx else None
58
+
59
+
60
+ def current_span_id() -> Optional[str]:
61
+ """Return the span_id of the currently active span, if any."""
62
+ ctx = get_active_context()
63
+ return ctx.span_id if ctx else None
64
+
65
+
66
+ def _aggregate(spans: list[TrodoSpan]) -> Dict[str, Any]:
67
+ total_in = 0
68
+ total_out = 0
69
+ total_cost = 0.0
70
+ tool_count = 0
71
+ error_count = 0
72
+ for s in spans:
73
+ if s.input_tokens:
74
+ total_in += int(s.input_tokens)
75
+ if s.output_tokens:
76
+ total_out += int(s.output_tokens)
77
+ if s.cost:
78
+ total_cost += float(s.cost)
79
+ if s.kind == "tool":
80
+ tool_count += 1
81
+ if s.status == "error":
82
+ error_count += 1
83
+ return {
84
+ "total_tokens_in": total_in,
85
+ "total_tokens_out": total_out,
86
+ "total_cost": round(total_cost, 8) if total_cost else 0.0,
87
+ "span_count": len(spans),
88
+ "tool_count": tool_count,
89
+ "error_count": error_count,
90
+ }
91
+
92
+
93
+ class RunHandle:
94
+ """Handle returned by wrap_agent for setting input/output and getting run_id."""
95
+
96
+ def __init__(self, run_id: str, agent_name: str) -> None:
97
+ self.run_id = run_id
98
+ self.agent_name = agent_name
99
+ self.input: Optional[str] = None
100
+ self.output: Optional[str] = None
101
+ self.metadata: Dict[str, Any] = {}
102
+
103
+ def set_input(self, value: Any) -> None:
104
+ self.input = _truncate(value)
105
+
106
+ def set_output(self, value: Any) -> None:
107
+ self.output = _truncate(value)
108
+
109
+ def set_metadata(self, **kwargs: Any) -> None:
110
+ self.metadata.update(kwargs)
111
+
112
+
113
+ class SpanHandle:
114
+ """Handle returned by span context manager for setting output/attrs."""
115
+
116
+ def __init__(self, span_id: str, name: str) -> None:
117
+ self.span_id = span_id
118
+ self.name = name
119
+ self.input: Optional[str] = None
120
+ self.output: Optional[str] = None
121
+ self.attributes: Dict[str, Any] = {}
122
+ self.model: Optional[str] = None
123
+ self.provider: Optional[str] = None
124
+ self.input_tokens: Optional[int] = None
125
+ self.output_tokens: Optional[int] = None
126
+ self.cost: Optional[float] = None
127
+ self.temperature: Optional[float] = None
128
+ self.tool_name: Optional[str] = None
129
+
130
+ def set_input(self, value: Any) -> None:
131
+ self.input = _truncate(value)
132
+
133
+ def set_output(self, value: Any) -> None:
134
+ self.output = _truncate(value)
135
+
136
+ def set_attribute(self, key: str, value: Any) -> None:
137
+ self.attributes[key] = value
138
+
139
+ def set_llm(
140
+ self,
141
+ *,
142
+ model: Optional[str] = None,
143
+ provider: Optional[str] = None,
144
+ input_tokens: Optional[int] = None,
145
+ output_tokens: Optional[int] = None,
146
+ cost: Optional[float] = None,
147
+ temperature: Optional[float] = None,
148
+ ) -> None:
149
+ if model is not None:
150
+ self.model = model
151
+ if provider is not None:
152
+ self.provider = provider
153
+ if input_tokens is not None:
154
+ self.input_tokens = int(input_tokens)
155
+ if output_tokens is not None:
156
+ self.output_tokens = int(output_tokens)
157
+ if cost is not None:
158
+ self.cost = float(cost)
159
+ if temperature is not None:
160
+ self.temperature = float(temperature)
161
+
162
+ def set_tool(self, tool_name: str) -> None:
163
+ self.tool_name = tool_name
164
+
165
+
166
+ class wrap_agent:
167
+ """Context manager wrapping an agent run.
168
+
169
+ Every span created inside the ``with`` block (whether by the user's
170
+ manual ``span()`` calls or by OTel auto-instrumentation of the LLM SDK)
171
+ is automatically parented to this run via contextvars.
172
+ """
173
+
174
+ def __init__(
175
+ self,
176
+ processor: TrodoSpanProcessor,
177
+ team_site_id: str,
178
+ agent_name: str,
179
+ distinct_id: Optional[str] = None,
180
+ conversation_id: Optional[str] = None,
181
+ parent_run_id: Optional[str] = None,
182
+ metadata: Optional[Dict[str, Any]] = None,
183
+ ) -> None:
184
+ self._processor = processor
185
+ self._team_site_id = team_site_id
186
+ self._agent_name = agent_name
187
+ self._distinct_id = distinct_id
188
+ self._conversation_id = conversation_id
189
+ self._parent_run_id = parent_run_id
190
+ self._metadata = metadata
191
+ self._ctx_mgr: Optional[run_with_context] = None
192
+ self._started_ms: float = 0.0
193
+ self._started_iso: str = ""
194
+ self.handle: Optional[RunHandle] = None
195
+
196
+ def __enter__(self) -> RunHandle:
197
+ run_id = str(uuid.uuid4())
198
+ root_span_id = str(uuid.uuid4())
199
+ self._started_iso = _now_iso()
200
+ self._started_ms = time.time() * 1000.0
201
+
202
+ self.handle = RunHandle(run_id, self._agent_name)
203
+ ctx = ActiveSpanContext(
204
+ run_id=run_id,
205
+ span_id=root_span_id,
206
+ parent_span_id=None,
207
+ team_site_id=self._team_site_id,
208
+ processor=self._processor,
209
+ )
210
+ self._ctx_mgr = run_with_context(ctx)
211
+ self._ctx_mgr.__enter__()
212
+ return self.handle
213
+
214
+ def __exit__(self, exc_type, exc, tb) -> None:
215
+ assert self.handle is not None
216
+ ended_iso = _now_iso()
217
+ duration_ms = int(time.time() * 1000.0 - self._started_ms)
218
+ status = "error" if exc is not None else "ok"
219
+ error_summary = None
220
+ if exc is not None:
221
+ error_summary = _truncate(str(exc), 4_000)
222
+
223
+ pending = self._processor.get_pending(self.handle.run_id)
224
+ agg = _aggregate(pending)
225
+
226
+ run = TrodoRun(
227
+ run_id=self.handle.run_id,
228
+ agent_name=self._agent_name,
229
+ distinct_id=self._distinct_id,
230
+ conversation_id=self._conversation_id,
231
+ parent_run_id=self._parent_run_id,
232
+ status=status,
233
+ input=self.handle.input,
234
+ output=self.handle.output,
235
+ started_at=self._started_iso,
236
+ ended_at=ended_iso,
237
+ duration_ms=duration_ms,
238
+ error_summary=error_summary,
239
+ metadata={**(self._metadata or {}), **self.handle.metadata} or None,
240
+ total_tokens_in=agg["total_tokens_in"],
241
+ total_tokens_out=agg["total_tokens_out"],
242
+ total_cost=agg["total_cost"],
243
+ span_count=agg["span_count"],
244
+ tool_count=agg["tool_count"],
245
+ error_count=agg["error_count"],
246
+ )
247
+ try:
248
+ self._processor.ingest_run(run)
249
+ finally:
250
+ if self._ctx_mgr is not None:
251
+ self._ctx_mgr.__exit__(exc_type, exc, tb)
252
+ return None
253
+
254
+
255
+ class join_run:
256
+ """Join an existing agent run owned by a remote service.
257
+
258
+ Opens a new SPAN (not a run) in the context of the caller's run_id.
259
+ Every span produced inside the ``with`` block — including OTel
260
+ auto-instrumented LLM spans — is parented to ``parent_span_id`` and
261
+ flushed incrementally via ``append_spans`` (the remote service owns
262
+ run lifecycle; we never call ``ingest_run`` here).
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ processor: TrodoSpanProcessor,
268
+ team_site_id: str,
269
+ run_id: str,
270
+ parent_span_id: Optional[str] = None,
271
+ name: str = "remote.handler",
272
+ kind: str = "agent",
273
+ input: Any = None,
274
+ attributes: Optional[Dict[str, Any]] = None,
275
+ ) -> None:
276
+ self._processor = processor
277
+ self._team_site_id = team_site_id
278
+ self._run_id = run_id
279
+ self._parent_span_id = parent_span_id
280
+ self._name = name
281
+ self._kind = kind
282
+ self._input = _truncate(input) if input is not None else None
283
+ self._attributes = attributes
284
+ self._ctx_mgr: Optional[run_with_context] = None
285
+ self._started_ms: float = 0.0
286
+ self._started_iso: str = ""
287
+ self._span_id: str = ""
288
+ self.handle: Optional[SpanHandle] = None
289
+
290
+ def __enter__(self) -> SpanHandle:
291
+ self._span_id = str(uuid.uuid4())
292
+ self._started_iso = _now_iso()
293
+ self._started_ms = time.time() * 1000.0
294
+ self.handle = SpanHandle(self._span_id, self._name)
295
+ if self._input is not None:
296
+ self.handle.input = self._input
297
+ if self._attributes:
298
+ self.handle.attributes.update(self._attributes)
299
+
300
+ self._processor.mark_joined(self._run_id)
301
+ ctx = ActiveSpanContext(
302
+ run_id=self._run_id,
303
+ span_id=self._span_id,
304
+ parent_span_id=self._parent_span_id,
305
+ team_site_id=self._team_site_id,
306
+ processor=self._processor,
307
+ )
308
+ self._ctx_mgr = run_with_context(ctx)
309
+ self._ctx_mgr.__enter__()
310
+ return self.handle
311
+
312
+ def __exit__(self, exc_type, exc, tb) -> None:
313
+ if self.handle is None:
314
+ return None
315
+ ended_iso = _now_iso()
316
+ duration_ms = int(time.time() * 1000.0 - self._started_ms)
317
+ status = "error" if exc is not None else "ok"
318
+ error_type = exc_type.__name__ if exc_type else None
319
+ error_message = _truncate(str(exc), 4_000) if exc else None
320
+
321
+ trodo_span = TrodoSpan(
322
+ span_id=self._span_id,
323
+ run_id=self._run_id,
324
+ parent_span_id=self._parent_span_id,
325
+ kind=self._kind,
326
+ name=self._name,
327
+ status=status,
328
+ started_at=self._started_iso,
329
+ ended_at=ended_iso,
330
+ duration_ms=duration_ms,
331
+ input=self.handle.input,
332
+ output=self.handle.output,
333
+ error_type=error_type,
334
+ error_message=error_message,
335
+ model=self.handle.model,
336
+ provider=self.handle.provider,
337
+ input_tokens=self.handle.input_tokens,
338
+ output_tokens=self.handle.output_tokens,
339
+ cost=self.handle.cost,
340
+ temperature=self.handle.temperature,
341
+ tool_name=self.handle.tool_name,
342
+ attributes=self.handle.attributes or None,
343
+ )
344
+ try:
345
+ self._processor.append_spans(self._run_id, [trodo_span])
346
+ finally:
347
+ self._processor.unmark_joined(self._run_id)
348
+ if self._ctx_mgr is not None:
349
+ self._ctx_mgr.__exit__(exc_type, exc, tb)
350
+ return None
351
+
352
+
353
+ class span:
354
+ """Context manager for a nested span under the active run.
355
+
356
+ Outside of a wrap_agent / join_run this is a no-op that still runs the
357
+ inner code.
358
+ """
359
+
360
+ def __init__(
361
+ self,
362
+ name: str,
363
+ kind: str = "generic",
364
+ input: Any = None,
365
+ attributes: Optional[Dict[str, Any]] = None,
366
+ ) -> None:
367
+ self._name = name
368
+ self._kind = kind
369
+ self._input = _truncate(input) if input is not None else None
370
+ self._attributes = attributes
371
+ self._ctx_mgr: Optional[run_with_context] = None
372
+ self._started_ms: float = 0.0
373
+ self._started_iso: str = ""
374
+ self._active: Optional[ActiveSpanContext] = None
375
+ self._span_id: str = ""
376
+ self.handle: Optional[SpanHandle] = None
377
+
378
+ def __enter__(self) -> SpanHandle:
379
+ self._active = get_active_context()
380
+ self._span_id = str(uuid.uuid4())
381
+ self.handle = SpanHandle(self._span_id, self._name)
382
+ if self._input is not None:
383
+ self.handle.input = self._input
384
+ if self._attributes:
385
+ self.handle.attributes.update(self._attributes)
386
+ if self._active is None:
387
+ # No active run — still execute user code, just don't track.
388
+ return self.handle
389
+ child = ActiveSpanContext(
390
+ run_id=self._active.run_id,
391
+ span_id=self._span_id,
392
+ parent_span_id=self._active.span_id,
393
+ team_site_id=self._active.team_site_id,
394
+ processor=self._active.processor,
395
+ )
396
+ self._ctx_mgr = run_with_context(child)
397
+ self._ctx_mgr.__enter__()
398
+ self._started_iso = _now_iso()
399
+ self._started_ms = time.time() * 1000.0
400
+ return self.handle
401
+
402
+ def __exit__(self, exc_type, exc, tb) -> None:
403
+ if self._active is None or self.handle is None:
404
+ return None
405
+ ended_iso = _now_iso()
406
+ duration_ms = int(time.time() * 1000.0 - self._started_ms)
407
+ status = "error" if exc is not None else "ok"
408
+ error_type = exc_type.__name__ if exc_type else None
409
+ error_message = _truncate(str(exc), 4_000) if exc else None
410
+
411
+ trodo_span = TrodoSpan(
412
+ span_id=self._span_id,
413
+ run_id=self._active.run_id,
414
+ parent_span_id=self._active.span_id,
415
+ kind=self._kind,
416
+ name=self._name,
417
+ status=status,
418
+ started_at=self._started_iso,
419
+ ended_at=ended_iso,
420
+ duration_ms=duration_ms,
421
+ input=self.handle.input,
422
+ output=self.handle.output,
423
+ error_type=error_type,
424
+ error_message=error_message,
425
+ model=self.handle.model,
426
+ provider=self.handle.provider,
427
+ input_tokens=self.handle.input_tokens,
428
+ output_tokens=self.handle.output_tokens,
429
+ cost=self.handle.cost,
430
+ temperature=self.handle.temperature,
431
+ tool_name=self.handle.tool_name,
432
+ attributes=self.handle.attributes or None,
433
+ )
434
+ processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
435
+ processor.enqueue_span(trodo_span)
436
+ if self._ctx_mgr is not None:
437
+ self._ctx_mgr.__exit__(exc_type, exc, tb)
438
+ return None
trodo/types.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
- from typing import Any, Dict, List, Optional, Union
6
+ from typing import Any, Dict, List, Literal, Optional
7
7
 
8
8
  # ----------------------------------------------------------------------------
9
9
  # Configuration
@@ -21,6 +21,7 @@ class TrodoConfig:
21
21
  auto_events: bool = False
22
22
  on_error: Optional[Any] = None # Callable[[Exception], None]
23
23
  debug: bool = False
24
+ auto_instrument: bool = False
24
25
 
25
26
 
26
27
  # ----------------------------------------------------------------------------
@@ -80,75 +81,18 @@ ResetResult = Dict[str, Any]
80
81
 
81
82
 
82
83
  # ----------------------------------------------------------------------------
83
- # Agent Analytics
84
+ # Agent Runs (Lemma-style)
84
85
  # ----------------------------------------------------------------------------
85
86
 
86
- @dataclass
87
- class AgentCallProps:
88
- agent_id: str
89
- conversation_id: str
90
- message_id: str
91
- distinct_id: Optional[str] = None
92
- prompt: Optional[str] = None
93
- model: Optional[str] = None
94
- temperature: Optional[float] = None
95
- system_prompt_version: Optional[str] = None
96
- provider: Optional[str] = None
97
- timestamp: Optional[str] = None
98
- properties: Dict[str, Any] = field(default_factory=dict)
99
-
100
-
101
- @dataclass
102
- class ToolUseProps:
103
- agent_id: str
104
- conversation_id: str
105
- message_id: str
106
- tool_name: str
107
- distinct_id: Optional[str] = None
108
- input: Optional[Any] = None
109
- output: Optional[Any] = None
110
- latency_ms: Optional[int] = None
111
- status: Optional[str] = None # 'success' | 'failure'
112
- timestamp: Optional[str] = None
113
- properties: Dict[str, Any] = field(default_factory=dict)
114
-
115
-
116
- @dataclass
117
- class AgentResponseProps:
118
- agent_id: str
119
- conversation_id: str
120
- message_id: str
121
- distinct_id: Optional[str] = None
122
- output: Optional[str] = None
123
- model: Optional[str] = None
124
- completion_tokens: Optional[int] = None
125
- prompt_tokens: Optional[int] = None
126
- total_tokens: Optional[int] = None
127
- finish_reason: Optional[str] = None
128
- timestamp: Optional[str] = None
129
- properties: Dict[str, Any] = field(default_factory=dict)
130
-
131
-
132
- @dataclass
133
- class AgentErrorProps:
134
- agent_id: str
135
- conversation_id: str
136
- message_id: str
137
- distinct_id: Optional[str] = None
138
- error_type: Optional[str] = None
139
- error_message: Optional[str] = None
140
- failed_tool: Optional[str] = None
141
- traceback: Optional[str] = None
142
- timestamp: Optional[str] = None
143
- properties: Dict[str, Any] = field(default_factory=dict)
144
-
145
-
146
87
  @dataclass
147
88
  class FeedbackProps:
148
- agent_id: str
149
- conversation_id: str
150
- message_id: str
151
- feedback: str # 'positive' | 'negative' | 'unreact'
89
+ """Feedback attached to a run. ``run_id`` is returned by ``wrap_agent``.
90
+
91
+ At least one of ``satisfaction`` / ``rating`` / ``comment`` must be provided.
92
+ """
93
+ satisfaction: Optional[Literal["positive", "negative"]] = None
94
+ rating: Optional[float] = None
95
+ comment: Optional[str] = None
96
+ feedback: Optional[str] = None # alias for comment
152
97
  distinct_id: Optional[str] = None
153
- timestamp: Optional[str] = None
154
- properties: Dict[str, Any] = field(default_factory=dict)
98
+ metadata: Optional[Dict[str, Any]] = None