trace-agent-pydantic-ai 0.1.0__tar.gz
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.
- trace_agent_pydantic_ai-0.1.0/PKG-INFO +91 -0
- trace_agent_pydantic_ai-0.1.0/README.md +62 -0
- trace_agent_pydantic_ai-0.1.0/pyproject.toml +53 -0
- trace_agent_pydantic_ai-0.1.0/setup.cfg +4 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai/__init__.py +5 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai/capability.py +400 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai.egg-info/PKG-INFO +91 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai.egg-info/SOURCES.txt +12 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai.egg-info/dependency_links.txt +1 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai.egg-info/requires.txt +9 -0
- trace_agent_pydantic_ai-0.1.0/src/trace_agent_pydantic_ai.egg-info/top_level.txt +1 -0
- trace_agent_pydantic_ai-0.1.0/tests/test_capability.py +340 -0
- trace_agent_pydantic_ai-0.1.0/tests/test_integration.py +110 -0
- trace_agent_pydantic_ai-0.1.0/tests/test_otel_integration.py +224 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-agent-pydantic-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pydantic AI capability integration for TraceAgent.
|
|
5
|
+
Author-email: Enrique Javier Villar Cea <villarcea.enriquejavier@gmail.com>, Javier Rodriguez Castellano <javirodriguezcastellano@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/LixusSoftware/TraceAgent
|
|
8
|
+
Project-URL: Repository, https://github.com/LixusSoftware/TraceAgent
|
|
9
|
+
Project-URL: Issues, https://github.com/LixusSoftware/TraceAgent/issues
|
|
10
|
+
Keywords: agent,observability,tracing,llm,tools,pydantic-ai
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: trace-agent-sdk
|
|
22
|
+
Requires-Dist: pydantic-ai-slim<3.0.0,>=2.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest<9.0.0,>=8.3.2; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
26
|
+
Requires-Dist: trace-agent-server; extra == "dev"
|
|
27
|
+
Requires-Dist: opentelemetry-sdk>=1.28.0; extra == "dev"
|
|
28
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.28.0; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# TraceAgent Pydantic AI Integration
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+
[](https://traceagent.vercel.app)
|
|
37
|
+
|
|
38
|
+
Pydantic AI capability that streams agent runs, model requests and tool calls into TraceAgent.
|
|
39
|
+
Requires Pydantic AI 2.x.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install trace-agent-pydantic-ai
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pydantic_ai import Agent
|
|
51
|
+
from trace_agent_sdk import TraceAgentClient
|
|
52
|
+
|
|
53
|
+
client = TraceAgentClient("http://localhost:8000")
|
|
54
|
+
run = client.start_run("my-agent", "Do something")
|
|
55
|
+
|
|
56
|
+
agent = Agent("openai:gpt-4o", capabilities=[run.as_pydantic_ai_capability()])
|
|
57
|
+
result = agent.run_sync("hello")
|
|
58
|
+
|
|
59
|
+
run.finish(result.output)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The capability never breaks or slows the agent. Events are delivered by a background thread; if the backend is unreachable or hangs, a one-line warning is printed, trace delivery pauses for a while, and the agent keeps running. Events produced while paused are dropped. The end of the outermost run waits a few seconds at most for pending events, so `run.finish()` is delivered after them.
|
|
63
|
+
|
|
64
|
+
Pydantic AI prints a startup banner suggesting Logfire while its own observability is off. It is unrelated to TraceAgent and can be silenced with `PYDANTIC_AI_NO_BANNER=1`.
|
|
65
|
+
|
|
66
|
+
## Without any code: OpenTelemetry
|
|
67
|
+
|
|
68
|
+
Pydantic AI already emits OpenTelemetry spans, and the TraceAgent server can receive them, so an agent can be traced
|
|
69
|
+
without this package at all: export its spans over OTLP to `http://<server>/v1/traces`. See the
|
|
70
|
+
[server README](../trace-agent-server/README.md#tracing-agents-through-opentelemetry) and
|
|
71
|
+
[`examples/pydantic_ai_otel_agent.py`](../../examples/pydantic_ai_otel_agent.py).
|
|
72
|
+
|
|
73
|
+
Pick one route per agent. The capability above records more detail you control (redaction, node events) and works
|
|
74
|
+
without OpenTelemetry; the OpenTelemetry route needs no change to the agent. Using both records the run twice.
|
|
75
|
+
|
|
76
|
+
## Options
|
|
77
|
+
|
|
78
|
+
`run.as_pydantic_ai_capability(...)` accepts:
|
|
79
|
+
|
|
80
|
+
| Option | Default | Description |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `record_model_events` | `True` | Record each model request as an LLM call (tokens, model, requested tools) |
|
|
83
|
+
| `record_tool_events` | `True` | Record each tool execution (args, output summary, duration, errors) |
|
|
84
|
+
| `record_node_events` | `False` | Record every agent graph node |
|
|
85
|
+
| `redact_fields` | `[]` | Argument / output keys replaced with `[redacted]` |
|
|
86
|
+
| `failure_cooldown` | `30.0` | Seconds to stop sending (and drop events) after a failed delivery. `0` retries every event |
|
|
87
|
+
| `flush_timeout` | `5.0` | Maximum seconds the end of the outermost run waits for pending events |
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# TraceAgent Pydantic AI Integration
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
[](https://traceagent.vercel.app)
|
|
8
|
+
|
|
9
|
+
Pydantic AI capability that streams agent runs, model requests and tool calls into TraceAgent.
|
|
10
|
+
Requires Pydantic AI 2.x.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install trace-agent-pydantic-ai
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pydantic_ai import Agent
|
|
22
|
+
from trace_agent_sdk import TraceAgentClient
|
|
23
|
+
|
|
24
|
+
client = TraceAgentClient("http://localhost:8000")
|
|
25
|
+
run = client.start_run("my-agent", "Do something")
|
|
26
|
+
|
|
27
|
+
agent = Agent("openai:gpt-4o", capabilities=[run.as_pydantic_ai_capability()])
|
|
28
|
+
result = agent.run_sync("hello")
|
|
29
|
+
|
|
30
|
+
run.finish(result.output)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The capability never breaks or slows the agent. Events are delivered by a background thread; if the backend is unreachable or hangs, a one-line warning is printed, trace delivery pauses for a while, and the agent keeps running. Events produced while paused are dropped. The end of the outermost run waits a few seconds at most for pending events, so `run.finish()` is delivered after them.
|
|
34
|
+
|
|
35
|
+
Pydantic AI prints a startup banner suggesting Logfire while its own observability is off. It is unrelated to TraceAgent and can be silenced with `PYDANTIC_AI_NO_BANNER=1`.
|
|
36
|
+
|
|
37
|
+
## Without any code: OpenTelemetry
|
|
38
|
+
|
|
39
|
+
Pydantic AI already emits OpenTelemetry spans, and the TraceAgent server can receive them, so an agent can be traced
|
|
40
|
+
without this package at all: export its spans over OTLP to `http://<server>/v1/traces`. See the
|
|
41
|
+
[server README](../trace-agent-server/README.md#tracing-agents-through-opentelemetry) and
|
|
42
|
+
[`examples/pydantic_ai_otel_agent.py`](../../examples/pydantic_ai_otel_agent.py).
|
|
43
|
+
|
|
44
|
+
Pick one route per agent. The capability above records more detail you control (redaction, node events) and works
|
|
45
|
+
without OpenTelemetry; the OpenTelemetry route needs no change to the agent. Using both records the run twice.
|
|
46
|
+
|
|
47
|
+
## Options
|
|
48
|
+
|
|
49
|
+
`run.as_pydantic_ai_capability(...)` accepts:
|
|
50
|
+
|
|
51
|
+
| Option | Default | Description |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `record_model_events` | `True` | Record each model request as an LLM call (tokens, model, requested tools) |
|
|
54
|
+
| `record_tool_events` | `True` | Record each tool execution (args, output summary, duration, errors) |
|
|
55
|
+
| `record_node_events` | `False` | Record every agent graph node |
|
|
56
|
+
| `redact_fields` | `[]` | Argument / output keys replaced with `[redacted]` |
|
|
57
|
+
| `failure_cooldown` | `30.0` | Seconds to stop sending (and drop events) after a failed delivery. `0` retries every event |
|
|
58
|
+
| `flush_timeout` | `5.0` | Maximum seconds the end of the outermost run waits for pending events |
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trace-agent-pydantic-ai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pydantic AI capability integration for TraceAgent."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Enrique Javier Villar Cea", email = "villarcea.enriquejavier@gmail.com" },
|
|
12
|
+
{ name = "Javier Rodriguez Castellano", email = "javirodriguezcastellano@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
license = { text = "MIT" }
|
|
15
|
+
keywords = ["agent", "observability", "tracing", "llm", "tools", "pydantic-ai"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
requires-python = ">=3.11"
|
|
28
|
+
dependencies = [
|
|
29
|
+
"trace-agent-sdk",
|
|
30
|
+
"pydantic-ai-slim>=2.0.0,<3.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/LixusSoftware/TraceAgent"
|
|
35
|
+
Repository = "https://github.com/LixusSoftware/TraceAgent"
|
|
36
|
+
Issues = "https://github.com/LixusSoftware/TraceAgent/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.3.2,<9.0.0",
|
|
41
|
+
"pytest-asyncio>=0.24.0",
|
|
42
|
+
"trace-agent-server",
|
|
43
|
+
# Only to test the OpenTelemetry route: the capability itself needs none of these.
|
|
44
|
+
"opentelemetry-sdk>=1.28.0",
|
|
45
|
+
"opentelemetry-exporter-otlp-proto-http>=1.28.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Pydantic AI capability that streams execution events into TraceAgent.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from pydantic_ai import Agent
|
|
5
|
+
from trace_agent_sdk import TraceAgentClient
|
|
6
|
+
|
|
7
|
+
client = TraceAgentClient(base_url="http://127.0.0.1:8000")
|
|
8
|
+
run = client.start_run(agent_name="my-agent", goal="Do something")
|
|
9
|
+
|
|
10
|
+
agent = Agent("openai:gpt-4o", capabilities=[run.as_pydantic_ai_capability()])
|
|
11
|
+
agent.run_sync("...")
|
|
12
|
+
run.finish("done")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import queue
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from contextvars import ContextVar
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any
|
|
25
|
+
from uuid import uuid4
|
|
26
|
+
|
|
27
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
28
|
+
from pydantic_ai.exceptions import ApprovalRequired, CallDeferred
|
|
29
|
+
from trace_agent_sdk.redaction import redact_payload, stable_hash, summarize_value
|
|
30
|
+
|
|
31
|
+
# Step id of the innermost run/node being executed. ContextVars propagate down
|
|
32
|
+
# into model requests and tool tasks (also parallel ones) without sharing state.
|
|
33
|
+
_CURRENT_STEP: ContextVar[str | None] = ContextVar("trace_agent_pydantic_ai_step", default=None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _new_step_id(prefix: str) -> str:
|
|
37
|
+
return f"{prefix}-{uuid4().hex[:12]}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _elapsed_ms(started_at: float) -> int:
|
|
41
|
+
return int((time.perf_counter() - started_at) * 1000)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _usage_metadata(usage: Any) -> dict[str, Any]:
|
|
45
|
+
"""Translate a Pydantic AI usage object into TraceAgent token metadata."""
|
|
46
|
+
if callable(usage):
|
|
47
|
+
usage = usage()
|
|
48
|
+
if usage is None:
|
|
49
|
+
return {}
|
|
50
|
+
prompt_tokens = getattr(usage, "input_tokens", None) or 0
|
|
51
|
+
completion_tokens = getattr(usage, "output_tokens", None) or 0
|
|
52
|
+
return {
|
|
53
|
+
"prompt_tokens": prompt_tokens,
|
|
54
|
+
"completion_tokens": completion_tokens,
|
|
55
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
56
|
+
"requests": getattr(usage, "requests", None),
|
|
57
|
+
"tool_calls": getattr(usage, "tool_calls", None),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class TraceAgentCapability(AbstractCapability[Any]):
|
|
63
|
+
"""Record a Pydantic AI agent run as TraceAgent observations.
|
|
64
|
+
|
|
65
|
+
The capability wraps the run, model request, tool execution and (optionally)
|
|
66
|
+
graph node lifecycles. Events are delivered by a background thread, so a slow
|
|
67
|
+
or unreachable backend never stalls or crashes the agent: after a failed
|
|
68
|
+
delivery, events are dropped for ``failure_cooldown`` seconds, and the end of
|
|
69
|
+
the outermost run waits at most ``flush_timeout`` seconds for pending events,
|
|
70
|
+
so that ``run.finish()`` arrives after them.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
run_session: Any = None
|
|
74
|
+
record_model_events: bool = True
|
|
75
|
+
record_tool_events: bool = True
|
|
76
|
+
record_node_events: bool = False
|
|
77
|
+
redact_fields: list[str] = field(default_factory=list)
|
|
78
|
+
failure_cooldown: float = 30.0
|
|
79
|
+
flush_timeout: float = 5.0
|
|
80
|
+
# One consumer keeps events in order. That also matters because the backend
|
|
81
|
+
# numbers events per run without locking, so concurrent posts would collide.
|
|
82
|
+
_queue: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=1000), init=False, repr=False, compare=False)
|
|
83
|
+
_worker: threading.Thread | None = field(default=None, init=False, repr=False, compare=False)
|
|
84
|
+
_worker_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False, compare=False)
|
|
85
|
+
_paused_until: float = field(default=0.0, init=False, repr=False, compare=False)
|
|
86
|
+
|
|
87
|
+
# ── Delivery ────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def _enqueue(self, path: str, payload: dict[str, Any]) -> None:
|
|
90
|
+
"""Queue a payload for the backend; never blocks and never raises."""
|
|
91
|
+
self._ensure_worker()
|
|
92
|
+
try:
|
|
93
|
+
self._queue.put_nowait((path, payload, None))
|
|
94
|
+
except queue.Full:
|
|
95
|
+
pass # Tracing is best effort: dropping beats stalling the agent.
|
|
96
|
+
|
|
97
|
+
def _ensure_worker(self) -> None:
|
|
98
|
+
with self._worker_lock:
|
|
99
|
+
if self._worker is None or not self._worker.is_alive():
|
|
100
|
+
self._worker = threading.Thread(target=self._deliver_forever, name="trace-agent-pydantic-ai", daemon=True)
|
|
101
|
+
self._worker.start()
|
|
102
|
+
|
|
103
|
+
def _deliver_forever(self) -> None:
|
|
104
|
+
while True:
|
|
105
|
+
path, payload, flushed = self._queue.get()
|
|
106
|
+
try:
|
|
107
|
+
if flushed is not None:
|
|
108
|
+
flushed.set()
|
|
109
|
+
elif time.monotonic() >= self._paused_until:
|
|
110
|
+
self._deliver(path, payload)
|
|
111
|
+
finally:
|
|
112
|
+
self._queue.task_done()
|
|
113
|
+
|
|
114
|
+
def _deliver(self, path: str, payload: dict[str, Any]) -> None:
|
|
115
|
+
try:
|
|
116
|
+
self.run_session.client._request("POST", f"/api/runs/{self.run_session.run_id}/{path}", payload)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
self._paused_until = time.monotonic() + self.failure_cooldown
|
|
119
|
+
print(
|
|
120
|
+
f"[trace-agent] could not deliver trace data ({type(exc).__name__}); "
|
|
121
|
+
f"pausing delivery for {self.failure_cooldown:g}s. The agent is not affected.",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def _flush(self) -> None:
|
|
126
|
+
"""Wait, for a bounded time, until everything queued so far was delivered."""
|
|
127
|
+
flushed = threading.Event()
|
|
128
|
+
try:
|
|
129
|
+
self._ensure_worker()
|
|
130
|
+
self._queue.put_nowait((None, None, flushed))
|
|
131
|
+
except queue.Full:
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
await asyncio.to_thread(flushed.wait, self.flush_timeout)
|
|
135
|
+
except BaseException: # noqa: BLE001 - never let tracing mask the run's own outcome
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def _observation(
|
|
139
|
+
self,
|
|
140
|
+
kind: str,
|
|
141
|
+
step_id: str,
|
|
142
|
+
parent_step_id: str | None,
|
|
143
|
+
status: str,
|
|
144
|
+
**fields: Any,
|
|
145
|
+
) -> dict[str, Any]:
|
|
146
|
+
return {
|
|
147
|
+
"observations": [
|
|
148
|
+
{
|
|
149
|
+
"kind": kind,
|
|
150
|
+
"step_id": step_id,
|
|
151
|
+
"parent_step_id": parent_step_id,
|
|
152
|
+
"status": status,
|
|
153
|
+
**fields,
|
|
154
|
+
}
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# ── Run lifecycle ───────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
async def wrap_run(self, ctx: Any, *, handler: Any) -> Any:
|
|
161
|
+
step_id = _new_step_id("run")
|
|
162
|
+
parent_step_id = _CURRENT_STEP.get()
|
|
163
|
+
agent_name = getattr(ctx.agent, "name", None) or "agent"
|
|
164
|
+
started_at = time.perf_counter()
|
|
165
|
+
base_metadata = {
|
|
166
|
+
"agent_name": agent_name,
|
|
167
|
+
"pydantic_run_id": getattr(ctx, "run_id", None),
|
|
168
|
+
"conversation_id": getattr(ctx, "conversation_id", None),
|
|
169
|
+
}
|
|
170
|
+
self._enqueue(
|
|
171
|
+
"observations",
|
|
172
|
+
self._observation(
|
|
173
|
+
"chain_step",
|
|
174
|
+
step_id,
|
|
175
|
+
parent_step_id,
|
|
176
|
+
"running",
|
|
177
|
+
input_summary=f"Pydantic AI run '{agent_name}' started",
|
|
178
|
+
metadata={**base_metadata, "prompt_summary": summarize_value(ctx.prompt, self.redact_fields)},
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
token = _CURRENT_STEP.set(step_id)
|
|
182
|
+
try:
|
|
183
|
+
result = await handler()
|
|
184
|
+
except BaseException as exc:
|
|
185
|
+
_CURRENT_STEP.reset(token)
|
|
186
|
+
self._enqueue(
|
|
187
|
+
"observations",
|
|
188
|
+
self._observation(
|
|
189
|
+
"chain_step",
|
|
190
|
+
step_id,
|
|
191
|
+
parent_step_id,
|
|
192
|
+
"failed",
|
|
193
|
+
duration_ms=_elapsed_ms(started_at),
|
|
194
|
+
error_code=exc.__class__.__name__,
|
|
195
|
+
error_summary=str(exc),
|
|
196
|
+
metadata=base_metadata,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
if parent_step_id is None:
|
|
200
|
+
await self._flush()
|
|
201
|
+
raise
|
|
202
|
+
_CURRENT_STEP.reset(token)
|
|
203
|
+
output = getattr(result, "output", None)
|
|
204
|
+
self._enqueue(
|
|
205
|
+
"observations",
|
|
206
|
+
self._observation(
|
|
207
|
+
"chain_step",
|
|
208
|
+
step_id,
|
|
209
|
+
parent_step_id,
|
|
210
|
+
"succeeded",
|
|
211
|
+
duration_ms=_elapsed_ms(started_at),
|
|
212
|
+
input_summary=f"Pydantic AI run '{agent_name}' completed",
|
|
213
|
+
metadata={
|
|
214
|
+
**base_metadata,
|
|
215
|
+
**_usage_metadata(getattr(result, "usage", None)),
|
|
216
|
+
"output_summary": summarize_value(output, self.redact_fields),
|
|
217
|
+
"output_hash": stable_hash(output),
|
|
218
|
+
},
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
# The outermost run waits (bounded) for its events, so that a following
|
|
222
|
+
# run.finish() reaches the backend after them.
|
|
223
|
+
if parent_step_id is None:
|
|
224
|
+
await self._flush()
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
# ── Model request lifecycle ─────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
async def wrap_model_request(self, ctx: Any, *, request_context: Any, handler: Any) -> Any:
|
|
230
|
+
if not self.record_model_events:
|
|
231
|
+
return await handler(request_context)
|
|
232
|
+
|
|
233
|
+
step_id = _new_step_id("llm")
|
|
234
|
+
parent_step_id = _CURRENT_STEP.get()
|
|
235
|
+
model_id = getattr(request_context, "model_id", None)
|
|
236
|
+
started_at = time.perf_counter()
|
|
237
|
+
metadata = {
|
|
238
|
+
"message_count": len(getattr(request_context, "messages", None) or []),
|
|
239
|
+
"run_step": getattr(ctx, "run_step", None),
|
|
240
|
+
}
|
|
241
|
+
self._enqueue(
|
|
242
|
+
"observations",
|
|
243
|
+
self._observation(
|
|
244
|
+
"llm_call",
|
|
245
|
+
step_id,
|
|
246
|
+
parent_step_id,
|
|
247
|
+
"running",
|
|
248
|
+
model=model_id,
|
|
249
|
+
metadata=metadata,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
response = await handler(request_context)
|
|
254
|
+
except BaseException as exc:
|
|
255
|
+
self._enqueue(
|
|
256
|
+
"observations",
|
|
257
|
+
self._observation(
|
|
258
|
+
"llm_call",
|
|
259
|
+
step_id,
|
|
260
|
+
parent_step_id,
|
|
261
|
+
"failed",
|
|
262
|
+
duration_ms=_elapsed_ms(started_at),
|
|
263
|
+
model=model_id,
|
|
264
|
+
error_code=exc.__class__.__name__,
|
|
265
|
+
error_summary=str(exc),
|
|
266
|
+
metadata=metadata,
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
raise
|
|
270
|
+
tool_names = [
|
|
271
|
+
part.tool_name for part in response.parts if getattr(part, "part_kind", None) == "tool-call"
|
|
272
|
+
]
|
|
273
|
+
self._enqueue(
|
|
274
|
+
"observations",
|
|
275
|
+
self._observation(
|
|
276
|
+
"llm_call",
|
|
277
|
+
step_id,
|
|
278
|
+
parent_step_id,
|
|
279
|
+
"succeeded",
|
|
280
|
+
duration_ms=_elapsed_ms(started_at),
|
|
281
|
+
provider=response.provider_name,
|
|
282
|
+
model=response.model_name or model_id,
|
|
283
|
+
metadata={
|
|
284
|
+
**metadata,
|
|
285
|
+
**_usage_metadata(response.usage),
|
|
286
|
+
"finish_reason": str(response.finish_reason) if response.finish_reason else None,
|
|
287
|
+
"requested_tools": tool_names,
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
return response
|
|
292
|
+
|
|
293
|
+
# ── Tool execution lifecycle ────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
async def wrap_tool_execute(
|
|
296
|
+
self, ctx: Any, *, call: Any, tool_def: Any, args: Any, handler: Any
|
|
297
|
+
) -> Any:
|
|
298
|
+
if not self.record_tool_events:
|
|
299
|
+
return await handler(args)
|
|
300
|
+
|
|
301
|
+
step_id = _new_step_id("tool")
|
|
302
|
+
started_at = time.perf_counter()
|
|
303
|
+
# /tool-results records both the started and completed events and keeps
|
|
304
|
+
# tool_count/error_count; an extra tool_call observation would duplicate them.
|
|
305
|
+
result: dict[str, Any] = {
|
|
306
|
+
"call_id": call.tool_call_id,
|
|
307
|
+
"name": call.tool_name,
|
|
308
|
+
"step_id": step_id,
|
|
309
|
+
"parent_step_id": _CURRENT_STEP.get(),
|
|
310
|
+
"args": redact_payload(dict(args), self.redact_fields),
|
|
311
|
+
}
|
|
312
|
+
try:
|
|
313
|
+
output = await handler(args)
|
|
314
|
+
except (CallDeferred, ApprovalRequired):
|
|
315
|
+
# Deferral is control flow, not a failure: the tool runs again later.
|
|
316
|
+
raise
|
|
317
|
+
except BaseException as exc:
|
|
318
|
+
self._enqueue(
|
|
319
|
+
"tool-results",
|
|
320
|
+
{
|
|
321
|
+
"results": [
|
|
322
|
+
{
|
|
323
|
+
**result,
|
|
324
|
+
"status": "failed",
|
|
325
|
+
"output_hash": stable_hash({"error": str(exc)}),
|
|
326
|
+
"error_code": exc.__class__.__name__,
|
|
327
|
+
"error_summary": str(exc),
|
|
328
|
+
"duration_ms": _elapsed_ms(started_at),
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
raise
|
|
334
|
+
self._enqueue(
|
|
335
|
+
"tool-results",
|
|
336
|
+
{
|
|
337
|
+
"results": [
|
|
338
|
+
{
|
|
339
|
+
**result,
|
|
340
|
+
"status": "succeeded",
|
|
341
|
+
"output_summary": summarize_value(output, self.redact_fields),
|
|
342
|
+
"output_hash": stable_hash(output),
|
|
343
|
+
"duration_ms": _elapsed_ms(started_at),
|
|
344
|
+
}
|
|
345
|
+
]
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
return output
|
|
349
|
+
|
|
350
|
+
# ── Graph node lifecycle (opt-in) ───────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
async def wrap_node_run(self, ctx: Any, *, node: Any, handler: Any) -> Any:
|
|
353
|
+
if not self.record_node_events:
|
|
354
|
+
return await handler(node)
|
|
355
|
+
|
|
356
|
+
step_id = _new_step_id("node")
|
|
357
|
+
parent_step_id = _CURRENT_STEP.get()
|
|
358
|
+
node_name = node.__class__.__name__
|
|
359
|
+
started_at = time.perf_counter()
|
|
360
|
+
self._enqueue(
|
|
361
|
+
"observations",
|
|
362
|
+
self._observation(
|
|
363
|
+
"agent_action",
|
|
364
|
+
step_id,
|
|
365
|
+
parent_step_id,
|
|
366
|
+
"running",
|
|
367
|
+
input_summary=f"Node {node_name}",
|
|
368
|
+
metadata={"node": node_name, "run_step": getattr(ctx, "run_step", None)},
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
try:
|
|
372
|
+
result = await handler(node)
|
|
373
|
+
except BaseException as exc:
|
|
374
|
+
self._enqueue(
|
|
375
|
+
"observations",
|
|
376
|
+
self._observation(
|
|
377
|
+
"agent_action",
|
|
378
|
+
step_id,
|
|
379
|
+
parent_step_id,
|
|
380
|
+
"failed",
|
|
381
|
+
duration_ms=_elapsed_ms(started_at),
|
|
382
|
+
error_code=exc.__class__.__name__,
|
|
383
|
+
error_summary=str(exc),
|
|
384
|
+
metadata={"node": node_name},
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
raise
|
|
388
|
+
self._enqueue(
|
|
389
|
+
"observations",
|
|
390
|
+
self._observation(
|
|
391
|
+
"agent_action",
|
|
392
|
+
step_id,
|
|
393
|
+
parent_step_id,
|
|
394
|
+
"succeeded",
|
|
395
|
+
duration_ms=_elapsed_ms(started_at),
|
|
396
|
+
input_summary=f"Node {node_name} completed",
|
|
397
|
+
metadata={"node": node_name},
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
return result
|