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.
@@ -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
+ ![GitHub Release](https://img.shields.io/github/v/release/LixusSoftware/TraceAgent)
33
+ ![GitHub Stars](https://img.shields.io/github/stars/LixusSoftware/TraceAgent)
34
+ ![GitHub License](https://img.shields.io/github/license/LixusSoftware/TraceAgent)
35
+ ![Tests](https://github.com/LixusSoftware/TraceAgent/actions/workflows/test.yml/badge.svg)
36
+ [![Website](https://img.shields.io/badge/website-traceagent.vercel.app-blue)](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
+ ![GitHub Release](https://img.shields.io/github/v/release/LixusSoftware/TraceAgent)
4
+ ![GitHub Stars](https://img.shields.io/github/stars/LixusSoftware/TraceAgent)
5
+ ![GitHub License](https://img.shields.io/github/license/LixusSoftware/TraceAgent)
6
+ ![Tests](https://github.com/LixusSoftware/TraceAgent/actions/workflows/test.yml/badge.svg)
7
+ [![Website](https://img.shields.io/badge/website-traceagent.vercel.app-blue)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ __all__ = ["__version__", "TraceAgentCapability"]
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from trace_agent_pydantic_ai.capability import TraceAgentCapability
@@ -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