agent-observability-trace-cli 0.1.2__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.
Files changed (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. agent_trace/py.typed +0 -0
@@ -0,0 +1,504 @@
1
+ """
2
+ pydantic-ai integration — graph-aware span enrichment for Agent runs.
3
+
4
+ pydantic-ai does not expose a callback/hook-registration surface the way
5
+ LangChain's ``BaseCallbackHandler`` or the openai-agents SDK's ``RunHooks``
6
+ do. Its own documented instrumentation point is ``Agent.iter()``, an async
7
+ context manager that yields the underlying pydantic-graph nodes
8
+ (``UserPromptNode`` -> ``ModelRequestNode`` -> ``CallToolsNode`` -> ... ->
9
+ ``End``) one at a time as the run advances — the same object graph
10
+ ``Agent.run()``/``Agent.run_sync()`` build and drive internally.
11
+
12
+ This module wraps that iterator: it walks the node sequence, opening an
13
+ ``llm:<model>`` span for each model request/response round trip and a
14
+ ``tool:<name>`` span for each tool call, and tags a request as a retry
15
+ attempt when pydantic-ai's own ``ModelRetry``/output-validator retry
16
+ mechanism appends a ``RetryPromptPart`` to the next request — the exact
17
+ "was this exchange a retry, and of what" attribution the generic httpx
18
+ interceptor cannot supply because it sees only anonymous raw HTTP bodies.
19
+
20
+ Usage (drain to completion)::
21
+
22
+ from agent_trace import Tracer
23
+ from agent_trace.integrations.pydantic_ai import run_traced
24
+
25
+ t = Tracer()
26
+ with t.start_trace("my_agent_run", record=True) as trace:
27
+ result = await run_traced(agent, "hello", tracer=t, trace=trace)
28
+
29
+ Usage (step through nodes yourself, e.g. to inspect intermediate state)::
30
+
31
+ from agent_trace.integrations.pydantic_ai import instrument_agent_run
32
+
33
+ with t.start_trace("my_agent_run", record=True) as trace:
34
+ async with instrument_agent_run(agent, "hello", tracer=t, trace=trace) as run:
35
+ async for node in run:
36
+ ...
37
+ result = run.result
38
+
39
+ Known limitation — ``pydantic_evals.evaluators.llm_as_a_judge.judge_output()``
40
+ (and its siblings ``judge_output_expected``/``judge_input_output``/
41
+ ``judge_g_eval``) construct and run their own internal ``Agent`` instance
42
+ that is not exposed for a caller to wrap. Attributing a captured HTTP
43
+ exchange to "this was a judge call" therefore requires either running your
44
+ own judge through a manually-constructed ``Agent`` wrapped with
45
+ ``instrument_agent_run``, or monkeypatching pydantic_evals internals, which
46
+ this module deliberately does not do.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import logging
52
+ from collections.abc import AsyncIterator
53
+ from contextlib import asynccontextmanager
54
+ from typing import TYPE_CHECKING, Any
55
+
56
+ from agent_trace.core.span import Span, SpanStatus
57
+
58
+ if TYPE_CHECKING:
59
+ from agent_trace import Trace, Tracer
60
+
61
+ __all__ = [
62
+ "TracedAgentRun",
63
+ "instrument_agent_run",
64
+ "run_traced",
65
+ ]
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+ _INSTALL_HINT = (
70
+ "The pydantic-ai integration requires the pydantic-ai package.\n"
71
+ "Install it with:\n\n"
72
+ " pip install pydantic-ai\n"
73
+ )
74
+
75
+
76
+ def _require_pydantic_ai() -> Any:
77
+ """Lazy import guard — raises a clear error if pydantic-ai is absent."""
78
+ try:
79
+ import pydantic_ai
80
+
81
+ return pydantic_ai
82
+ except ImportError as exc:
83
+ raise ImportError(_INSTALL_HINT) from exc
84
+
85
+
86
+ def _model_display_name(model: Any) -> str:
87
+ """Best-effort model identifier for a pydantic-ai ``Model`` instance/string.
88
+
89
+ Used only as the *initial* span name/attribute before the real request
90
+ completes — ``ModelResponse.model_name`` (captured in
91
+ ``TracedAgentRun._close_llm_span``) is always the authoritative value
92
+ once a response has actually arrived.
93
+ """
94
+ if model is None:
95
+ return "unknown"
96
+ if isinstance(model, str):
97
+ return model
98
+ name = getattr(model, "model_name", None)
99
+ if name:
100
+ return str(name)
101
+ return type(model).__name__
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # TracedAgentRun
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ class TracedAgentRun:
110
+ """Wraps a pydantic-ai ``AgentRun`` and emits agent-trace spans as the
111
+ agent graph advances node-by-node.
112
+
113
+ Not instantiated directly — obtained from :func:`instrument_agent_run`.
114
+
115
+ Span tree produced for one ``agent.iter()`` run::
116
+
117
+ agent:<name>
118
+ llm:<model> (one per ModelRequestNode -> next-node transition)
119
+ tool:<tool_name> (one per ToolCallPart on a model response)
120
+ llm:<model> (retry / next turn, if any)
121
+ ...
122
+
123
+ A model-request span tagged ``llm.is_retry=True`` means pydantic-ai
124
+ appended a ``RetryPromptPart`` to that request — i.e. this call is a
125
+ retry produced by a raised ``ModelRetry`` (from a tool implementation or
126
+ an ``@agent.output_validator``), not a fresh turn.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ run: Any,
132
+ *,
133
+ tracer: Tracer,
134
+ trace: Trace,
135
+ root_span: Span,
136
+ agent_name: str,
137
+ default_model_name: str,
138
+ ) -> None:
139
+ self._run = run
140
+ self._tracer: Tracer = tracer
141
+ self._trace: Trace = trace
142
+ self._root_span = root_span
143
+ self._agent_name = agent_name
144
+ self._default_model_name = default_model_name
145
+ self._llm_span: Span | None = None
146
+ self._tool_spans: dict[str, Span] = {}
147
+ self._retry_count = 0
148
+
149
+ # ------------------------------------------------------------------
150
+ # Public accessors — mirror the underlying AgentRun
151
+ # ------------------------------------------------------------------
152
+
153
+ @property
154
+ def result(self) -> Any:
155
+ """The pydantic-ai ``AgentRunResult`` once the run has completed."""
156
+ return self._run.result
157
+
158
+ @property
159
+ def run_usage(self) -> Any:
160
+ """The pydantic-ai ``RunUsage`` accumulated by the run so far."""
161
+ return self._run.usage
162
+
163
+ # ------------------------------------------------------------------
164
+ # Async iteration — transparent pass-through of the wrapped AgentRun
165
+ # ------------------------------------------------------------------
166
+
167
+ def __aiter__(self) -> AsyncIterator[Any]:
168
+ return self._iterate()
169
+
170
+ async def _iterate(self) -> AsyncIterator[Any]:
171
+ pydantic_ai_mod = _require_pydantic_ai()
172
+ agent_cls = pydantic_ai_mod.Agent
173
+
174
+ async for node in self._run:
175
+ self._on_node(agent_cls, node)
176
+ yield node
177
+
178
+ self._finalize()
179
+
180
+ # ------------------------------------------------------------------
181
+ # Node-transition handling
182
+ # ------------------------------------------------------------------
183
+
184
+ def _on_node(self, agent_cls: Any, node: Any) -> None:
185
+ try:
186
+ if agent_cls.is_model_request_node(node):
187
+ request = node.request
188
+ # Resolve any tool spans opened for the previous CallToolsNode
189
+ # using this request's ToolReturnPart/RetryPromptPart entries
190
+ # before opening the next llm span.
191
+ self._close_tool_spans(request)
192
+ self._open_llm_span(request)
193
+ elif agent_cls.is_call_tools_node(node):
194
+ model_response = node.model_response
195
+ self._close_llm_span(model_response)
196
+ self._open_tool_spans(model_response)
197
+ except Exception:
198
+ logger.debug(
199
+ "agent-trace: failed to process pydantic-ai node %r",
200
+ type(node).__name__,
201
+ exc_info=True,
202
+ )
203
+
204
+ def _open_llm_span(self, request: Any) -> None:
205
+ span = self._tracer.start_span(
206
+ f"llm:{self._default_model_name}", parent_id=self._root_span.span_id
207
+ )
208
+ span.set_attribute("llm.model", self._default_model_name)
209
+ span.set_attribute("agent.name", self._agent_name)
210
+
211
+ parts = list(getattr(request, "parts", []) or [])
212
+ span.set_attribute("llm.request_message_count", len(parts))
213
+
214
+ # Which ModelRequestPart types were actually sent — specifically
215
+ # whether a SystemPromptPart was included vs. silently dropped
216
+ # (#3277: a developer had no way to tell, from a captured run,
217
+ # whether their system prompt/instructions actually made it into a
218
+ # given request without manually diffing two runs by hand).
219
+ part_kinds = sorted({type(p).__name__ for p in parts})
220
+ if part_kinds:
221
+ span.set_attribute("llm.request_part_kinds", ",".join(part_kinds))
222
+ system_prompt_parts = [
223
+ p for p in parts if type(p).__name__ == "SystemPromptPart"
224
+ ]
225
+ span.set_attribute("llm.has_system_prompt_part", bool(system_prompt_parts))
226
+ if system_prompt_parts:
227
+ resolved = getattr(system_prompt_parts[0], "content", None)
228
+ if isinstance(resolved, str) and resolved:
229
+ span.set_attribute(
230
+ "llm.system_prompt_content",
231
+ resolved[:2000] + ("...<truncated>" if len(resolved) > 2000 else ""),
232
+ )
233
+
234
+ retry_parts = [p for p in parts if type(p).__name__ == "RetryPromptPart"]
235
+ if retry_parts:
236
+ self._retry_count += 1
237
+ span.set_attribute("llm.is_retry", True)
238
+ span.set_attribute("llm.retry_index", self._retry_count)
239
+ tool_names: list[str] = sorted(
240
+ {
241
+ str(getattr(p, "tool_name", None))
242
+ for p in retry_parts
243
+ if getattr(p, "tool_name", None)
244
+ }
245
+ )
246
+ if tool_names:
247
+ span.set_attribute("llm.retry_tool_name", ",".join(tool_names))
248
+ else:
249
+ span.set_attribute("llm.retry_reason", "output_validator")
250
+
251
+ self._llm_span = span
252
+
253
+ def _close_llm_span(self, model_response: Any) -> None:
254
+ span = self._llm_span
255
+ self._llm_span = None
256
+ if span is None:
257
+ return
258
+
259
+ model_name = getattr(model_response, "model_name", None)
260
+ if model_name:
261
+ # The concrete model wasn't knowable until the response arrived
262
+ # (e.g. fallback models, per-call overrides) — correct both the
263
+ # span name and attribute now that we have ground truth.
264
+ span.name = f"llm:{model_name}"
265
+ span.set_attribute("llm.model", str(model_name))
266
+
267
+ provider_name = getattr(model_response, "provider_name", None)
268
+ if provider_name:
269
+ span.set_attribute("llm.provider", str(provider_name))
270
+
271
+ finish_reason = getattr(model_response, "finish_reason", None)
272
+ if finish_reason:
273
+ span.set_attribute("llm.finish_reason", str(finish_reason))
274
+
275
+ usage = getattr(model_response, "usage", None)
276
+ if usage is not None:
277
+ for attr_name, field_name in (
278
+ ("llm.usage.prompt_tokens", "input_tokens"),
279
+ ("llm.usage.completion_tokens", "output_tokens"),
280
+ ("llm.usage.cache_read_tokens", "cache_read_tokens"),
281
+ ("llm.usage.cache_write_tokens", "cache_write_tokens"),
282
+ ):
283
+ value = getattr(usage, field_name, None)
284
+ if value:
285
+ span.set_attribute(attr_name, int(value))
286
+
287
+ tool_call_count = sum(
288
+ 1
289
+ for part in getattr(model_response, "parts", []) or []
290
+ if type(part).__name__ == "ToolCallPart"
291
+ )
292
+ span.set_attribute("llm.tool_call_count", tool_call_count)
293
+
294
+ span.end(SpanStatus.OK)
295
+
296
+ def _open_tool_spans(self, model_response: Any) -> None:
297
+ for part in getattr(model_response, "parts", []) or []:
298
+ if type(part).__name__ != "ToolCallPart":
299
+ continue
300
+ tool_name = getattr(part, "tool_name", None) or "tool"
301
+ call_id = (
302
+ getattr(part, "tool_call_id", None)
303
+ or f"{tool_name}:{len(self._tool_spans)}"
304
+ )
305
+ span = self._tracer.start_span(
306
+ f"tool:{tool_name}", parent_id=self._root_span.span_id
307
+ )
308
+ span.set_attribute("tool.name", tool_name)
309
+ span.set_attribute("tool.call_id", str(call_id))
310
+ args = getattr(part, "args", None)
311
+ if isinstance(args, dict):
312
+ span.set_attribute("tool.arg_count", len(args))
313
+ self._tool_spans[str(call_id)] = span
314
+
315
+ def _close_tool_spans(self, request: Any) -> None:
316
+ for part in getattr(request, "parts", []) or []:
317
+ call_id = getattr(part, "tool_call_id", None)
318
+ if call_id is None:
319
+ continue
320
+ span = self._tool_spans.pop(str(call_id), None)
321
+ if span is None:
322
+ continue
323
+
324
+ part_type = type(part).__name__
325
+ if part_type == "RetryPromptPart":
326
+ # The tool implementation raised ModelRetry — pydantic-ai's
327
+ # own soft-retry control-flow signal, not an application
328
+ # failure. Mirrors agent-trace's LangGraph handling of
329
+ # Command/GraphInterrupt: close OK, tag as retried rather
330
+ # than marking the span ERROR.
331
+ span.set_attribute("tool.retried", True)
332
+ span.end(SpanStatus.OK)
333
+ else:
334
+ content = getattr(part, "content", None)
335
+ span.set_attribute(
336
+ "tool.output_length",
337
+ len(str(content)) if content is not None else 0,
338
+ )
339
+ span.end(SpanStatus.OK)
340
+
341
+ def _finalize(self) -> None:
342
+ """Defensive cleanup after a normal (non-exception) run completion.
343
+
344
+ In the ordinary case both ``_llm_span`` and ``_tool_spans`` are
345
+ already empty by the time the graph reaches its ``End`` node — this
346
+ only fires if a node shape agent-trace doesn't recognise leaves
347
+ something dangling.
348
+ """
349
+ if self._llm_span is not None and self._llm_span.end_time is None:
350
+ self._llm_span.end(SpanStatus.OK)
351
+ self._llm_span = None
352
+ for span in self._tool_spans.values():
353
+ if span.end_time is None:
354
+ span.end(SpanStatus.OK)
355
+ self._tool_spans.clear()
356
+
357
+ self._record_final_usage()
358
+
359
+ def _record_final_usage(self) -> None:
360
+ usage = self._run.usage
361
+ if usage is None:
362
+ return
363
+ requests = getattr(usage, "requests", None)
364
+ if requests is not None:
365
+ self._root_span.set_attribute("agent.usage.requests", int(requests))
366
+ for attr_name, field_name in (
367
+ ("agent.usage.input_tokens", "input_tokens"),
368
+ ("agent.usage.output_tokens", "output_tokens"),
369
+ ):
370
+ value = getattr(usage, field_name, None)
371
+ if value:
372
+ self._root_span.set_attribute(attr_name, int(value))
373
+ if self._retry_count:
374
+ self._root_span.set_attribute("agent.retry_count", self._retry_count)
375
+
376
+ def _close_dangling_spans(self, exc: BaseException) -> None:
377
+ """Close any still-open child spans as ERROR when the run raises."""
378
+ if self._llm_span is not None and self._llm_span.end_time is None:
379
+ self._llm_span.record_exception(exc)
380
+ self._llm_span.end(SpanStatus.ERROR)
381
+ self._llm_span = None
382
+ for span in self._tool_spans.values():
383
+ if span.end_time is None:
384
+ span.record_exception(exc)
385
+ span.end(SpanStatus.ERROR)
386
+ self._tool_spans.clear()
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # instrument_agent_run / run_traced — public entry points
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ @asynccontextmanager
395
+ async def instrument_agent_run(
396
+ agent: Any,
397
+ user_prompt: Any = None,
398
+ *,
399
+ tracer: Tracer,
400
+ trace: Trace,
401
+ **kwargs: Any,
402
+ ) -> AsyncIterator[TracedAgentRun]:
403
+ """Run a pydantic-ai ``Agent`` through ``Agent.iter()`` with span instrumentation.
404
+
405
+ This is the graph-aware equivalent of ``async with agent.iter(...) as run``
406
+ — every model request/response round trip and tool call along the way is
407
+ captured as an agent-trace span, parented under a root ``agent:<name>``
408
+ span, instead of relying solely on the generic httpx monkeypatch to
409
+ capture anonymous raw HTTP rows with no pydantic-ai-level context.
410
+
411
+ Parameters
412
+ ----------
413
+ agent:
414
+ A pydantic-ai ``Agent`` instance.
415
+ user_prompt:
416
+ Forwarded to ``Agent.iter()`` — the user prompt (or ``None`` when
417
+ driving the run entirely from ``message_history``).
418
+ tracer:
419
+ The active :class:`~agent_trace.Tracer`.
420
+ trace:
421
+ The current :class:`~agent_trace.Trace` that spans will be registered
422
+ on.
423
+ **kwargs:
424
+ Additional keyword arguments forwarded to ``Agent.iter()`` (e.g.
425
+ ``deps``, ``model``, ``message_history``, ``usage_limits``).
426
+
427
+ Yields
428
+ ------
429
+ TracedAgentRun
430
+ An async-iterable wrapper around the underlying ``AgentRun`` — iterate
431
+ it exactly as you would ``agent.iter()``'s own result, then read
432
+ ``.result`` for the final ``AgentRunResult``.
433
+ """
434
+ _require_pydantic_ai()
435
+ agent_name: str = getattr(agent, "name", None) or "agent"
436
+ default_model_name = _model_display_name(
437
+ kwargs.get("model") or getattr(agent, "model", None)
438
+ )
439
+
440
+ root_span = tracer.start_span(f"agent:{agent_name}")
441
+ root_span.set_attribute("agent.name", agent_name)
442
+ root_span.set_attribute("agent.model", default_model_name)
443
+
444
+ traced: TracedAgentRun | None = None
445
+ try:
446
+ async with agent.iter(user_prompt, **kwargs) as run:
447
+ traced = TracedAgentRun(
448
+ run,
449
+ tracer=tracer,
450
+ trace=trace,
451
+ root_span=root_span,
452
+ agent_name=agent_name,
453
+ default_model_name=default_model_name,
454
+ )
455
+ yield traced
456
+ root_span.end(SpanStatus.OK)
457
+ except BaseException as exc:
458
+ if traced is not None:
459
+ traced._close_dangling_spans(exc)
460
+ root_span.record_exception(exc)
461
+ if root_span.end_time is None:
462
+ root_span.end(SpanStatus.ERROR)
463
+ raise
464
+
465
+
466
+ async def run_traced(
467
+ agent: Any,
468
+ user_prompt: Any = None,
469
+ *,
470
+ tracer: Tracer,
471
+ trace: Trace,
472
+ **kwargs: Any,
473
+ ) -> Any:
474
+ """Run a pydantic-ai ``Agent`` to completion with span instrumentation.
475
+
476
+ Convenience wrapper around :func:`instrument_agent_run` that drains the
477
+ node iterator and returns the underlying ``AgentRunResult`` — equivalent
478
+ to ``await agent.run(user_prompt, **kwargs)`` but with every model
479
+ request and tool call captured as a span.
480
+
481
+ Parameters
482
+ ----------
483
+ agent:
484
+ A pydantic-ai ``Agent`` instance.
485
+ user_prompt:
486
+ Forwarded to ``Agent.iter()``.
487
+ tracer:
488
+ The active :class:`~agent_trace.Tracer`.
489
+ trace:
490
+ The current :class:`~agent_trace.Trace`.
491
+ **kwargs:
492
+ Additional keyword arguments forwarded to ``Agent.iter()``.
493
+
494
+ Returns
495
+ -------
496
+ Any
497
+ The ``AgentRunResult`` from the completed run.
498
+ """
499
+ async with instrument_agent_run(
500
+ agent, user_prompt, tracer=tracer, trace=trace, **kwargs
501
+ ) as traced_run:
502
+ async for _node in traced_run:
503
+ pass
504
+ return traced_run.result