trodo-python 1.2.0__py3-none-any.whl → 2.2.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,508 @@
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
+ def start_run(
167
+ *,
168
+ processor: TrodoSpanProcessor,
169
+ agent_name: str,
170
+ run_id: Optional[str] = None,
171
+ distinct_id: Optional[str] = None,
172
+ conversation_id: Optional[str] = None,
173
+ parent_run_id: Optional[str] = None,
174
+ metadata: Optional[Dict[str, Any]] = None,
175
+ input: Any = None,
176
+ ) -> str:
177
+ """Open a Run record without holding a context manager.
178
+
179
+ Pairs with :func:`end_run` for sessions that span multiple processes or
180
+ HTTP requests (e.g. an MCP server where ``initialize`` opens a run and
181
+ later ``tools/call`` requests append spans before a final close).
182
+
183
+ Returns the ``run_id`` (caller-supplied or freshly minted UUID). Between
184
+ ``start_run`` and ``end_run`` any process can use ``join_run(run_id, ...)``
185
+ to add spans — they flush incrementally via ``append_spans``.
186
+ """
187
+ rid = run_id or str(uuid.uuid4())
188
+ run = TrodoRun(
189
+ run_id=rid,
190
+ agent_name=agent_name,
191
+ distinct_id=distinct_id,
192
+ conversation_id=conversation_id,
193
+ parent_run_id=parent_run_id,
194
+ status="running",
195
+ input=_truncate(input),
196
+ started_at=_now_iso(),
197
+ metadata=metadata,
198
+ )
199
+ processor.mark_joined(rid)
200
+ processor.start_run(run)
201
+ return rid
202
+
203
+
204
+ def end_run(
205
+ run_id: str,
206
+ *,
207
+ processor: TrodoSpanProcessor,
208
+ output: Any = None,
209
+ status: str = "ok",
210
+ error_summary: Optional[str] = None,
211
+ metadata: Optional[Dict[str, Any]] = None,
212
+ ) -> None:
213
+ """Finalise a Run opened by :func:`start_run`.
214
+
215
+ Aggregates any locally-buffered spans for ``run_id``, POSTs to the
216
+ ``/runs/{id}/end`` endpoint, and unmarks the run as joined. Idempotent
217
+ on the local-state side; the backend treats a second call as a row update.
218
+ """
219
+ pending = processor.get_pending(run_id)
220
+ agg = _aggregate(pending)
221
+ payload: Dict[str, Any] = {
222
+ "ended_at": _now_iso(),
223
+ "status": status,
224
+ **agg,
225
+ }
226
+ if output is not None:
227
+ payload["output"] = _truncate(output)
228
+ if error_summary is not None:
229
+ payload["error_summary"] = _truncate(error_summary, 4_000)
230
+ if metadata is not None:
231
+ payload["metadata"] = metadata
232
+ processor.end_run(run_id, payload)
233
+ processor.unmark_joined(run_id)
234
+
235
+
236
+ class wrap_agent:
237
+ """Context manager wrapping an agent run.
238
+
239
+ Every span created inside the ``with`` block (whether by the user's
240
+ manual ``span()`` calls or by OTel auto-instrumentation of the LLM SDK)
241
+ is automatically parented to this run via contextvars.
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ processor: TrodoSpanProcessor,
247
+ team_site_id: str,
248
+ agent_name: str,
249
+ distinct_id: Optional[str] = None,
250
+ conversation_id: Optional[str] = None,
251
+ parent_run_id: Optional[str] = None,
252
+ metadata: Optional[Dict[str, Any]] = None,
253
+ ) -> None:
254
+ self._processor = processor
255
+ self._team_site_id = team_site_id
256
+ self._agent_name = agent_name
257
+ self._distinct_id = distinct_id
258
+ self._conversation_id = conversation_id
259
+ self._parent_run_id = parent_run_id
260
+ self._metadata = metadata
261
+ self._ctx_mgr: Optional[run_with_context] = None
262
+ self._started_ms: float = 0.0
263
+ self._started_iso: str = ""
264
+ self.handle: Optional[RunHandle] = None
265
+
266
+ def __enter__(self) -> RunHandle:
267
+ run_id = str(uuid.uuid4())
268
+ root_span_id = str(uuid.uuid4())
269
+ self._started_iso = _now_iso()
270
+ self._started_ms = time.time() * 1000.0
271
+
272
+ self.handle = RunHandle(run_id, self._agent_name)
273
+ ctx = ActiveSpanContext(
274
+ run_id=run_id,
275
+ span_id=root_span_id,
276
+ parent_span_id=None,
277
+ team_site_id=self._team_site_id,
278
+ processor=self._processor,
279
+ )
280
+ self._ctx_mgr = run_with_context(ctx)
281
+ self._ctx_mgr.__enter__()
282
+ return self.handle
283
+
284
+ def __exit__(self, exc_type, exc, tb) -> None:
285
+ assert self.handle is not None
286
+ ended_iso = _now_iso()
287
+ duration_ms = int(time.time() * 1000.0 - self._started_ms)
288
+ status = "error" if exc is not None else "ok"
289
+ error_summary = None
290
+ if exc is not None:
291
+ error_summary = _truncate(str(exc), 4_000)
292
+
293
+ pending = self._processor.get_pending(self.handle.run_id)
294
+ agg = _aggregate(pending)
295
+
296
+ run = TrodoRun(
297
+ run_id=self.handle.run_id,
298
+ agent_name=self._agent_name,
299
+ distinct_id=self._distinct_id,
300
+ conversation_id=self._conversation_id,
301
+ parent_run_id=self._parent_run_id,
302
+ status=status,
303
+ input=self.handle.input,
304
+ output=self.handle.output,
305
+ started_at=self._started_iso,
306
+ ended_at=ended_iso,
307
+ duration_ms=duration_ms,
308
+ error_summary=error_summary,
309
+ metadata={**(self._metadata or {}), **self.handle.metadata} or None,
310
+ total_tokens_in=agg["total_tokens_in"],
311
+ total_tokens_out=agg["total_tokens_out"],
312
+ total_cost=agg["total_cost"],
313
+ span_count=agg["span_count"],
314
+ tool_count=agg["tool_count"],
315
+ error_count=agg["error_count"],
316
+ )
317
+ try:
318
+ self._processor.ingest_run(run)
319
+ finally:
320
+ if self._ctx_mgr is not None:
321
+ self._ctx_mgr.__exit__(exc_type, exc, tb)
322
+ return None
323
+
324
+
325
+ class join_run:
326
+ """Join an existing agent run owned by a remote service.
327
+
328
+ Opens a new SPAN (not a run) in the context of the caller's run_id.
329
+ Every span produced inside the ``with`` block — including OTel
330
+ auto-instrumented LLM spans — is parented to ``parent_span_id`` and
331
+ flushed incrementally via ``append_spans`` (the remote service owns
332
+ run lifecycle; we never call ``ingest_run`` here).
333
+ """
334
+
335
+ def __init__(
336
+ self,
337
+ processor: TrodoSpanProcessor,
338
+ team_site_id: str,
339
+ run_id: str,
340
+ parent_span_id: Optional[str] = None,
341
+ name: str = "remote.handler",
342
+ kind: str = "agent",
343
+ input: Any = None,
344
+ attributes: Optional[Dict[str, Any]] = None,
345
+ ) -> None:
346
+ self._processor = processor
347
+ self._team_site_id = team_site_id
348
+ self._run_id = run_id
349
+ self._parent_span_id = parent_span_id
350
+ self._name = name
351
+ self._kind = kind
352
+ self._input = _truncate(input) if input is not None else None
353
+ self._attributes = attributes
354
+ self._ctx_mgr: Optional[run_with_context] = None
355
+ self._started_ms: float = 0.0
356
+ self._started_iso: str = ""
357
+ self._span_id: str = ""
358
+ self.handle: Optional[SpanHandle] = None
359
+
360
+ def __enter__(self) -> SpanHandle:
361
+ self._span_id = str(uuid.uuid4())
362
+ self._started_iso = _now_iso()
363
+ self._started_ms = time.time() * 1000.0
364
+ self.handle = SpanHandle(self._span_id, self._name)
365
+ if self._input is not None:
366
+ self.handle.input = self._input
367
+ if self._attributes:
368
+ self.handle.attributes.update(self._attributes)
369
+
370
+ self._processor.mark_joined(self._run_id)
371
+ ctx = ActiveSpanContext(
372
+ run_id=self._run_id,
373
+ span_id=self._span_id,
374
+ parent_span_id=self._parent_span_id,
375
+ team_site_id=self._team_site_id,
376
+ processor=self._processor,
377
+ )
378
+ self._ctx_mgr = run_with_context(ctx)
379
+ self._ctx_mgr.__enter__()
380
+ return self.handle
381
+
382
+ def __exit__(self, exc_type, exc, tb) -> None:
383
+ if self.handle is None:
384
+ return None
385
+ ended_iso = _now_iso()
386
+ duration_ms = int(time.time() * 1000.0 - self._started_ms)
387
+ status = "error" if exc is not None else "ok"
388
+ error_type = exc_type.__name__ if exc_type else None
389
+ error_message = _truncate(str(exc), 4_000) if exc else None
390
+
391
+ trodo_span = TrodoSpan(
392
+ span_id=self._span_id,
393
+ run_id=self._run_id,
394
+ parent_span_id=self._parent_span_id,
395
+ kind=self._kind,
396
+ name=self._name,
397
+ status=status,
398
+ started_at=self._started_iso,
399
+ ended_at=ended_iso,
400
+ duration_ms=duration_ms,
401
+ input=self.handle.input,
402
+ output=self.handle.output,
403
+ error_type=error_type,
404
+ error_message=error_message,
405
+ model=self.handle.model,
406
+ provider=self.handle.provider,
407
+ input_tokens=self.handle.input_tokens,
408
+ output_tokens=self.handle.output_tokens,
409
+ cost=self.handle.cost,
410
+ temperature=self.handle.temperature,
411
+ tool_name=self.handle.tool_name,
412
+ attributes=self.handle.attributes or None,
413
+ )
414
+ try:
415
+ self._processor.append_spans(self._run_id, [trodo_span])
416
+ finally:
417
+ self._processor.unmark_joined(self._run_id)
418
+ if self._ctx_mgr is not None:
419
+ self._ctx_mgr.__exit__(exc_type, exc, tb)
420
+ return None
421
+
422
+
423
+ class span:
424
+ """Context manager for a nested span under the active run.
425
+
426
+ Outside of a wrap_agent / join_run this is a no-op that still runs the
427
+ inner code.
428
+ """
429
+
430
+ def __init__(
431
+ self,
432
+ name: str,
433
+ kind: str = "generic",
434
+ input: Any = None,
435
+ attributes: Optional[Dict[str, Any]] = None,
436
+ ) -> None:
437
+ self._name = name
438
+ self._kind = kind
439
+ self._input = _truncate(input) if input is not None else None
440
+ self._attributes = attributes
441
+ self._ctx_mgr: Optional[run_with_context] = None
442
+ self._started_ms: float = 0.0
443
+ self._started_iso: str = ""
444
+ self._active: Optional[ActiveSpanContext] = None
445
+ self._span_id: str = ""
446
+ self.handle: Optional[SpanHandle] = None
447
+
448
+ def __enter__(self) -> SpanHandle:
449
+ self._active = get_active_context()
450
+ self._span_id = str(uuid.uuid4())
451
+ self.handle = SpanHandle(self._span_id, self._name)
452
+ if self._input is not None:
453
+ self.handle.input = self._input
454
+ if self._attributes:
455
+ self.handle.attributes.update(self._attributes)
456
+ if self._active is None:
457
+ # No active run — still execute user code, just don't track.
458
+ return self.handle
459
+ child = ActiveSpanContext(
460
+ run_id=self._active.run_id,
461
+ span_id=self._span_id,
462
+ parent_span_id=self._active.span_id,
463
+ team_site_id=self._active.team_site_id,
464
+ processor=self._active.processor,
465
+ )
466
+ self._ctx_mgr = run_with_context(child)
467
+ self._ctx_mgr.__enter__()
468
+ self._started_iso = _now_iso()
469
+ self._started_ms = time.time() * 1000.0
470
+ return self.handle
471
+
472
+ def __exit__(self, exc_type, exc, tb) -> None:
473
+ if self._active is None or self.handle is None:
474
+ return None
475
+ ended_iso = _now_iso()
476
+ duration_ms = int(time.time() * 1000.0 - self._started_ms)
477
+ status = "error" if exc is not None else "ok"
478
+ error_type = exc_type.__name__ if exc_type else None
479
+ error_message = _truncate(str(exc), 4_000) if exc else None
480
+
481
+ trodo_span = TrodoSpan(
482
+ span_id=self._span_id,
483
+ run_id=self._active.run_id,
484
+ parent_span_id=self._active.span_id,
485
+ kind=self._kind,
486
+ name=self._name,
487
+ status=status,
488
+ started_at=self._started_iso,
489
+ ended_at=ended_iso,
490
+ duration_ms=duration_ms,
491
+ input=self.handle.input,
492
+ output=self.handle.output,
493
+ error_type=error_type,
494
+ error_message=error_message,
495
+ model=self.handle.model,
496
+ provider=self.handle.provider,
497
+ input_tokens=self.handle.input_tokens,
498
+ output_tokens=self.handle.output_tokens,
499
+ cost=self.handle.cost,
500
+ temperature=self.handle.temperature,
501
+ tool_name=self.handle.tool_name,
502
+ attributes=self.handle.attributes or None,
503
+ )
504
+ processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
505
+ processor.enqueue_span(trodo_span)
506
+ if self._ctx_mgr is not None:
507
+ self._ctx_mgr.__exit__(exc_type, exc, tb)
508
+ 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