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.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- 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
|