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.
trodo/client.py CHANGED
@@ -2,24 +2,37 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Dict, List, Optional, Union
5
+ import functools
6
+ from typing import Any, Callable, Dict, List, Optional, Union
6
7
 
7
8
  from .api.http_client import HttpClient
8
9
  from .session.session_manager import SessionManager
9
- from .managers.people_manager import PeopleManager
10
- from .managers.group_manager import GroupManager, GroupProfile
10
+ from .managers.group_manager import GroupProfile
11
11
  from .queue.event_queue import EventQueue
12
12
  from .queue.batch_flusher import BatchFlusher
13
13
  from .auto.auto_event_manager import AutoEventManager
14
14
  from .user_context import UserContext
15
+ from .otel.processor import TrodoSpanProcessor
16
+ from .otel.wrap_agent import (
17
+ wrap_agent as wrap_agent_ctx,
18
+ span as span_ctx,
19
+ join_run as join_run_ctx,
20
+ start_run as start_run_fn,
21
+ end_run as end_run_fn,
22
+ current_run_id as _current_run_id,
23
+ current_span_id as _current_span_id,
24
+ )
25
+ from .otel.auto_instrument import enable_auto_instrument
26
+ from .otel.helpers import (
27
+ tool as tool_decorator,
28
+ track_llm_call as track_llm_call_fn,
29
+ fastapi_middleware as fastapi_middleware_fn,
30
+ propagation_headers as propagation_headers_fn,
31
+ )
15
32
  from .types import (
16
33
  ApiResult,
17
34
  ResetResult,
18
35
  WalletAddressResult,
19
- AgentCallProps,
20
- ToolUseProps,
21
- AgentResponseProps,
22
- AgentErrorProps,
23
36
  FeedbackProps,
24
37
  )
25
38
 
@@ -37,6 +50,7 @@ class TrodoClient:
37
50
  auto_events: bool = False,
38
51
  on_error: Optional[Any] = None,
39
52
  debug: bool = False,
53
+ auto_instrument: bool = True,
40
54
  ) -> None:
41
55
  if not site_id:
42
56
  raise ValueError("trodo-python: site_id is required")
@@ -73,6 +87,10 @@ class TrodoClient:
73
87
 
74
88
  self._user_context_cache: Dict[str, UserContext] = {}
75
89
 
90
+ self._span_processor = TrodoSpanProcessor(http_client=self._http)
91
+ if auto_instrument:
92
+ enable_auto_instrument(self._span_processor)
93
+
76
94
  # --------------------------------------------------------------------------
77
95
  # Primary pattern: for_user()
78
96
  # --------------------------------------------------------------------------
@@ -195,124 +213,234 @@ class TrodoClient:
195
213
  def flush(self) -> None:
196
214
  if self._batch_flusher:
197
215
  self._batch_flusher.flush()
216
+ self._span_processor.force_flush()
198
217
 
199
218
  def shutdown(self) -> None:
200
219
  self._auto_event_manager.disable()
201
220
  if self._batch_flusher:
202
221
  self._batch_flusher.stop()
203
222
  self._batch_flusher.flush()
223
+ self._span_processor.shutdown()
204
224
 
205
225
  # --------------------------------------------------------------------------
206
- # Agent Analytics
226
+ # Agent Runs — Lemma-style seamless wrapping
207
227
  # --------------------------------------------------------------------------
208
228
 
209
- def _build_agent_base(
229
+ def wrap_agent(
210
230
  self,
211
- agent_id: str,
212
- conversation_id: str,
213
- message_id: str,
214
- event_type: str,
231
+ agent_name: str,
215
232
  distinct_id: Optional[str] = None,
216
- timestamp: Optional[str] = None,
217
- ) -> Dict[str, Any]:
218
- payload: Dict[str, Any] = {
219
- "event_type": event_type,
220
- "agent_id": agent_id,
221
- "conversation_id": conversation_id,
222
- "message_id": message_id,
223
- }
224
- if distinct_id is not None:
225
- payload["distinct_id"] = distinct_id
226
- if timestamp is not None:
227
- payload["timestamp"] = timestamp
228
- return payload
229
-
230
- def track_agent_call(self, props: AgentCallProps) -> None:
231
- """Track an LLM invocation / inbound message."""
232
- payload = self._build_agent_base(
233
- props.agent_id, props.conversation_id, props.message_id,
234
- "agent_call", props.distinct_id, props.timestamp,
233
+ conversation_id: Optional[str] = None,
234
+ parent_run_id: Optional[str] = None,
235
+ metadata: Optional[Dict[str, Any]] = None,
236
+ ) -> wrap_agent_ctx:
237
+ """Context manager that captures the wrapped block as an agent run.
238
+
239
+ Every OTel-instrumented call (Anthropic, OpenAI, LangChain…) made
240
+ inside the ``with`` is auto-captured as a nested span.
241
+ """
242
+ return wrap_agent_ctx(
243
+ processor=self._span_processor,
244
+ team_site_id=self.site_id,
245
+ agent_name=agent_name,
246
+ distinct_id=distinct_id,
247
+ conversation_id=conversation_id,
248
+ parent_run_id=parent_run_id,
249
+ metadata=metadata,
235
250
  )
236
- if props.prompt is not None:
237
- payload["prompt"] = props.prompt
238
- if props.model is not None:
239
- payload["model"] = props.model
240
- if props.temperature is not None:
241
- payload["temperature"] = props.temperature
242
- if props.system_prompt_version is not None:
243
- payload["system_prompt_version"] = props.system_prompt_version
244
- if props.provider is not None:
245
- payload["provider"] = props.provider
246
- if props.properties:
247
- payload.update(props.properties)
248
- self._http.post_agent_event(payload)
249
-
250
- def track_tool_use(self, props: ToolUseProps) -> None:
251
- """Track a tool invocation within an agent turn."""
252
- payload = self._build_agent_base(
253
- props.agent_id, props.conversation_id, props.message_id,
254
- "tool_use", props.distinct_id, props.timestamp,
251
+
252
+ def agent(
253
+ self,
254
+ agent_name: str,
255
+ *,
256
+ distinct_id: Optional[str] = None,
257
+ conversation_id: Optional[str] = None,
258
+ metadata: Optional[Dict[str, Any]] = None,
259
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
260
+ """Decorator form of wrap_agent."""
261
+
262
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
263
+ @functools.wraps(fn)
264
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
265
+ with self.wrap_agent(
266
+ agent_name,
267
+ distinct_id=distinct_id,
268
+ conversation_id=conversation_id,
269
+ metadata=metadata,
270
+ ) as run:
271
+ result = fn(*args, **kwargs)
272
+ run.set_output(result)
273
+ return result
274
+ return wrapper
275
+
276
+ return decorator
277
+
278
+ def span(
279
+ self,
280
+ name: str,
281
+ *,
282
+ kind: str = "generic",
283
+ input: Any = None,
284
+ attributes: Optional[Dict[str, Any]] = None,
285
+ ) -> span_ctx:
286
+ """Create a nested span inside the current run."""
287
+ return span_ctx(name=name, kind=kind, input=input, attributes=attributes)
288
+
289
+ def start_run(
290
+ self,
291
+ agent_name: str,
292
+ *,
293
+ run_id: Optional[str] = None,
294
+ distinct_id: Optional[str] = None,
295
+ conversation_id: Optional[str] = None,
296
+ parent_run_id: Optional[str] = None,
297
+ metadata: Optional[Dict[str, Any]] = None,
298
+ input: Any = None,
299
+ ) -> str:
300
+ """Open a Run record outside a context manager. Returns the run_id.
301
+
302
+ Use ``end_run`` to finalise, ``join_run`` from any process to add spans.
303
+ """
304
+ return start_run_fn(
305
+ processor=self._span_processor,
306
+ agent_name=agent_name,
307
+ run_id=run_id,
308
+ distinct_id=distinct_id,
309
+ conversation_id=conversation_id,
310
+ parent_run_id=parent_run_id,
311
+ metadata=metadata,
312
+ input=input,
313
+ )
314
+
315
+ def end_run(
316
+ self,
317
+ run_id: str,
318
+ *,
319
+ output: Any = None,
320
+ status: str = "ok",
321
+ error_summary: Optional[str] = None,
322
+ metadata: Optional[Dict[str, Any]] = None,
323
+ ) -> None:
324
+ """Finalise a Run previously opened by :meth:`start_run`."""
325
+ end_run_fn(
326
+ run_id,
327
+ processor=self._span_processor,
328
+ output=output,
329
+ status=status,
330
+ error_summary=error_summary,
331
+ metadata=metadata,
255
332
  )
256
- payload["tool_name"] = props.tool_name
257
- if props.input is not None:
258
- payload["input"] = props.input
259
- if props.output is not None:
260
- payload["output"] = props.output
261
- if props.latency_ms is not None:
262
- payload["latency_ms"] = props.latency_ms
263
- if props.status is not None:
264
- payload["status"] = props.status
265
- if props.properties:
266
- payload.update(props.properties)
267
- self._http.post_agent_event(payload)
268
-
269
- def track_agent_response(self, props: AgentResponseProps) -> None:
270
- """Track an LLM response / completion."""
271
- payload = self._build_agent_base(
272
- props.agent_id, props.conversation_id, props.message_id,
273
- "agent_response", props.distinct_id, props.timestamp,
333
+
334
+ def join_run(
335
+ self,
336
+ run_id: str,
337
+ parent_span_id: Optional[str] = None,
338
+ *,
339
+ name: str = "remote.handler",
340
+ kind: str = "agent",
341
+ input: Any = None,
342
+ attributes: Optional[Dict[str, Any]] = None,
343
+ ) -> join_run_ctx:
344
+ """Open a span on an existing run owned by a remote service.
345
+
346
+ Use this in downstream microservices that receive an inbound
347
+ request carrying ``X-Trodo-Run-Id`` / ``X-Trodo-Parent-Span-Id``.
348
+ Prefer :meth:`fastapi_middleware` to automate this.
349
+ """
350
+ return join_run_ctx(
351
+ processor=self._span_processor,
352
+ team_site_id=self.site_id,
353
+ run_id=run_id,
354
+ parent_span_id=parent_span_id,
355
+ name=name,
356
+ kind=kind,
357
+ input=input,
358
+ attributes=attributes,
274
359
  )
275
- if props.output is not None:
276
- payload["output"] = props.output
277
- if props.model is not None:
278
- payload["model"] = props.model
279
- if props.completion_tokens is not None:
280
- payload["completion_tokens"] = props.completion_tokens
281
- if props.prompt_tokens is not None:
282
- payload["prompt_tokens"] = props.prompt_tokens
283
- if props.total_tokens is not None:
284
- payload["total_tokens"] = props.total_tokens
285
- if props.finish_reason is not None:
286
- payload["finish_reason"] = props.finish_reason
287
- if props.properties:
288
- payload.update(props.properties)
289
- self._http.post_agent_event(payload)
290
-
291
- def track_agent_error(self, props: AgentErrorProps) -> None:
292
- """Track an error during an agent turn."""
293
- payload = self._build_agent_base(
294
- props.agent_id, props.conversation_id, props.message_id,
295
- "agent_error", props.distinct_id, props.timestamp,
360
+
361
+ def tool(
362
+ self,
363
+ name: Optional[str] = None,
364
+ *,
365
+ kind: str = "tool",
366
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
367
+ """Decorator that wraps a function call as a tool span."""
368
+ return tool_decorator(name=name, kind=kind)
369
+
370
+ def track_llm_call(
371
+ self,
372
+ *,
373
+ model: Optional[str] = None,
374
+ provider: Optional[str] = None,
375
+ input_tokens: Optional[int] = None,
376
+ output_tokens: Optional[int] = None,
377
+ prompt: Any = None,
378
+ completion: Any = None,
379
+ temperature: Optional[float] = None,
380
+ cost: Optional[float] = None,
381
+ name: Optional[str] = None,
382
+ metadata: Optional[Dict[str, Any]] = None,
383
+ ) -> None:
384
+ """Record an LLM span for a raw-HTTP caller (no OTel adapter)."""
385
+ track_llm_call_fn(
386
+ model=model,
387
+ provider=provider,
388
+ input_tokens=input_tokens,
389
+ output_tokens=output_tokens,
390
+ prompt=prompt,
391
+ completion=completion,
392
+ temperature=temperature,
393
+ cost=cost,
394
+ name=name,
395
+ metadata=metadata,
296
396
  )
297
- if props.error_type is not None:
298
- payload["error_type"] = props.error_type
299
- if props.error_message is not None:
300
- payload["error_message"] = props.error_message
301
- if props.failed_tool is not None:
302
- payload["failed_tool"] = props.failed_tool
303
- if props.traceback is not None:
304
- payload["traceback"] = props.traceback
305
- if props.properties:
306
- payload.update(props.properties)
307
- self._http.post_agent_event(payload)
308
-
309
- def track_feedback(self, props: FeedbackProps) -> None:
310
- """Track a user feedback reaction on an agent response."""
311
- payload = self._build_agent_base(
312
- props.agent_id, props.conversation_id, props.message_id,
313
- "feedback", props.distinct_id, props.timestamp,
397
+
398
+ def fastapi_middleware(self) -> Callable:
399
+ """Return a FastAPI/Starlette middleware that auto-joins runs."""
400
+ return fastapi_middleware_fn(self)
401
+
402
+ def propagation_headers(self) -> Dict[str, str]:
403
+ """Return outbound HTTP headers carrying the current run/span id."""
404
+ return propagation_headers_fn()
405
+
406
+ def current_run_id(self) -> Optional[str]:
407
+ return _current_run_id()
408
+
409
+ def current_span_id(self) -> Optional[str]:
410
+ return _current_span_id()
411
+
412
+ def feedback(
413
+ self,
414
+ run_id: str,
415
+ satisfaction: Optional[str] = None,
416
+ rating: Optional[float] = None,
417
+ comment: Optional[str] = None,
418
+ feedback: Optional[str] = None,
419
+ distinct_id: Optional[str] = None,
420
+ metadata: Optional[Dict[str, Any]] = None,
421
+ ) -> ApiResult:
422
+ """Attach feedback to a completed run."""
423
+ if satisfaction is None and rating is None and not (comment or feedback):
424
+ raise ValueError(
425
+ "At least one of 'satisfaction', 'rating', or 'comment' is required"
426
+ )
427
+ payload: Dict[str, Any] = {
428
+ "satisfaction": satisfaction,
429
+ "rating": rating,
430
+ "comment": comment if comment is not None else feedback,
431
+ "distinct_id": distinct_id,
432
+ "attributes": metadata or {},
433
+ }
434
+ return self._http.post_run_feedback(run_id, payload)
435
+
436
+ def feedback_props(self, run_id: str, props: FeedbackProps) -> ApiResult:
437
+ """Convenience — pass a FeedbackProps dataclass."""
438
+ return self.feedback(
439
+ run_id,
440
+ satisfaction=props.satisfaction,
441
+ rating=props.rating,
442
+ comment=props.comment,
443
+ feedback=props.feedback,
444
+ distinct_id=props.distinct_id,
445
+ metadata=props.metadata,
314
446
  )
315
- payload["feedback"] = props.feedback
316
- if props.properties:
317
- payload.update(props.properties)
318
- self._http.post_agent_event(payload)
trodo/otel/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """OpenTelemetry-compatible run/span pipeline for trodo-python."""
2
+
3
+ from .context import ActiveSpanContext, get_active_context, run_with_context
4
+ from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
5
+ from .wrap_agent import wrap_agent, span, SpanHandle, RunHandle
6
+ from .auto_instrument import enable_auto_instrument, otel_span_to_trodo_span
7
+
8
+ __all__ = [
9
+ "ActiveSpanContext",
10
+ "get_active_context",
11
+ "run_with_context",
12
+ "TrodoSpanProcessor",
13
+ "TrodoRun",
14
+ "TrodoSpan",
15
+ "wrap_agent",
16
+ "span",
17
+ "SpanHandle",
18
+ "RunHandle",
19
+ "enable_auto_instrument",
20
+ "otel_span_to_trodo_span",
21
+ ]