agentloop-profiler 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of agentloop-profiler might be problematic. Click here for more details.

Files changed (50) hide show
  1. agentloop/__init__.py +68 -0
  2. agentloop/audit.py +34 -0
  3. agentloop/autoinstrument.py +102 -0
  4. agentloop/ci.py +135 -0
  5. agentloop/cli.py +876 -0
  6. agentloop/client.py +170 -0
  7. agentloop/config.py +50 -0
  8. agentloop/costs.py +15 -0
  9. agentloop/decorators.py +160 -0
  10. agentloop/demo.py +283 -0
  11. agentloop/doctor.py +344 -0
  12. agentloop/events.py +49 -0
  13. agentloop/exporters.py +60 -0
  14. agentloop/findings.py +215 -0
  15. agentloop/graph.py +312 -0
  16. agentloop/integrations/__init__.py +1 -0
  17. agentloop/integrations/crewai.py +144 -0
  18. agentloop/integrations/langgraph.py +140 -0
  19. agentloop/integrations/openai.py +369 -0
  20. agentloop/integrations/openai_agents.py +212 -0
  21. agentloop/integrations/sdk.py +82 -0
  22. agentloop/integrations/vercel_ai.py +83 -0
  23. agentloop/issues.py +88 -0
  24. agentloop/markdown.py +102 -0
  25. agentloop/metrics.py +123 -0
  26. agentloop/optimizer.py +323 -0
  27. agentloop/otel.py +302 -0
  28. agentloop/otel_ids.py +81 -0
  29. agentloop/patches.py +583 -0
  30. agentloop/plan_export.py +72 -0
  31. agentloop/quality.py +222 -0
  32. agentloop/replay.py +424 -0
  33. agentloop/runtime.py +244 -0
  34. agentloop/savings.py +135 -0
  35. agentloop/server.py +292 -0
  36. agentloop/store.py +840 -0
  37. agentloop/timing.py +124 -0
  38. agentloop/tracer.py +356 -0
  39. agentloop/value.py +243 -0
  40. agentloop/version.py +3 -0
  41. agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
  42. agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
  43. agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
  44. agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
  45. agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
  46. agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
  47. agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
  48. dashboard/__init__.py +1 -0
  49. dashboard/app.py +698 -0
  50. dashboard/value_view.py +101 -0
agentloop/graph.py ADDED
@@ -0,0 +1,312 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from agentloop.timing import (
7
+ cumulative_span_time_ms,
8
+ elapsed_runtime_ms,
9
+ event_interval_ms,
10
+ )
11
+ from agentloop.timing import (
12
+ duration_ms as recorded_duration_ms,
13
+ )
14
+
15
+
16
+ @dataclass
17
+ class ExecutionNode:
18
+ node_id: str
19
+ name: str
20
+ event_type: str
21
+ duration_ms: float
22
+ model: str | None = None
23
+ input_tokens: int = 0
24
+ output_tokens: int = 0
25
+ status: str = "ok"
26
+ metadata: dict[str, Any] = field(default_factory=dict)
27
+ started_at: str | None = None
28
+ ended_at: str | None = None
29
+ parent_id: str | None = None
30
+
31
+ @property
32
+ def total_tokens(self) -> int:
33
+ return self.input_tokens + self.output_tokens
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "node_id": self.node_id,
38
+ "name": self.name,
39
+ "event_type": self.event_type,
40
+ "duration_ms": round(self.duration_ms, 3),
41
+ "started_at": self.started_at,
42
+ "ended_at": self.ended_at,
43
+ "parent_id": self.parent_id,
44
+ "model": self.model,
45
+ "input_tokens": self.input_tokens,
46
+ "output_tokens": self.output_tokens,
47
+ "total_tokens": self.total_tokens,
48
+ "status": self.status,
49
+ "metadata": self.metadata,
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class ExecutionEdge:
55
+ source: str
56
+ target: str
57
+ kind: str = "sequence"
58
+
59
+ def to_dict(self) -> dict[str, str]:
60
+ return {"source": self.source, "target": self.target, "kind": self.kind}
61
+
62
+
63
+ @dataclass
64
+ class CriticalPath:
65
+ node_ids: list[str]
66
+ duration_ms: float
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ return {"node_ids": self.node_ids, "duration_ms": round(self.duration_ms, 3)}
70
+
71
+
72
+ @dataclass
73
+ class _PathState:
74
+ node_ids: list[str]
75
+ duration_ms: float
76
+ started_ms: float | None = None
77
+ ended_ms: float | None = None
78
+
79
+
80
+ @dataclass
81
+ class ExecutionGraph:
82
+ nodes: list[ExecutionNode]
83
+ edges: list[ExecutionEdge]
84
+ elapsed_ms: float | None = None
85
+
86
+ @classmethod
87
+ def from_trace(cls, trace: Any) -> "ExecutionGraph":
88
+ indexed_events = list(enumerate(trace.events))
89
+ indexed_events.sort(key=_event_sort_key)
90
+
91
+ nodes: list[ExecutionNode] = []
92
+ event_by_node_id: dict[str, Any] = {}
93
+ for original_index, event in indexed_events:
94
+ node_id = event.event_id or f"node_{original_index}"
95
+ nodes.append(
96
+ ExecutionNode(
97
+ node_id=node_id,
98
+ name=event.name,
99
+ event_type=event.event_type,
100
+ duration_ms=event.duration_ms,
101
+ model=event.model,
102
+ input_tokens=event.input_tokens,
103
+ output_tokens=event.output_tokens,
104
+ status=event.status,
105
+ metadata=event.metadata or {},
106
+ started_at=event.started_at,
107
+ ended_at=event.ended_at,
108
+ parent_id=event.parent_id,
109
+ )
110
+ )
111
+ event_by_node_id[node_id] = event
112
+
113
+ node_ids = set(event_by_node_id)
114
+ edges: list[ExecutionEdge] = []
115
+ roots: list[ExecutionNode] = []
116
+ for node in nodes:
117
+ if node.parent_id and node.parent_id in node_ids and node.parent_id != node.node_id:
118
+ edges.append(
119
+ ExecutionEdge(source=node.parent_id, target=node.node_id, kind="parent")
120
+ )
121
+ else:
122
+ roots.append(node)
123
+
124
+ edges.extend(_inferred_sequence_edges(roots))
125
+ return cls(
126
+ nodes=nodes, edges=_deduplicate_edges(edges), elapsed_ms=elapsed_runtime_ms(trace)
127
+ )
128
+
129
+ def total_runtime_ms(self) -> float:
130
+ if self.elapsed_ms is not None:
131
+ return max(0.0, self.elapsed_ms)
132
+ intervals = [event_interval_ms(node) for node in self.nodes]
133
+ if intervals and all(interval is not None for interval in intervals):
134
+ valid = [interval for interval in intervals if interval is not None]
135
+ return max(ended for _, ended in valid) - min(started for started, _ in valid)
136
+ return cumulative_span_time_ms(self.nodes)
137
+
138
+ def critical_path(self) -> CriticalPath:
139
+ if not self.nodes:
140
+ return CriticalPath([], 0.0)
141
+
142
+ node_by_id = {node.node_id: node for node in self.nodes}
143
+ predecessors: dict[str, list[str]] = {node_id: [] for node_id in node_by_id}
144
+ successors: dict[str, list[str]] = {node_id: [] for node_id in node_by_id}
145
+ for edge in self.edges:
146
+ if edge.source not in node_by_id or edge.target not in node_by_id:
147
+ continue
148
+ predecessors[edge.target].append(edge.source)
149
+ successors[edge.source].append(edge.target)
150
+
151
+ ordered_ids = _topological_order(self.nodes, predecessors, successors)
152
+ states: dict[str, _PathState] = {}
153
+ for node_id in ordered_ids:
154
+ node = node_by_id[node_id]
155
+ interval = event_interval_ms(node)
156
+ if interval is None:
157
+ own = _PathState([node_id], recorded_duration_ms(node))
158
+ else:
159
+ own = _PathState([node_id], interval[1] - interval[0], interval[0], interval[1])
160
+
161
+ candidates = [own]
162
+ for predecessor_id in predecessors[node_id]:
163
+ predecessor = states.get(predecessor_id)
164
+ if predecessor is not None:
165
+ candidates.append(_extend_path(predecessor, node, interval))
166
+ states[node_id] = max(
167
+ candidates, key=lambda state: (state.duration_ms, len(state.node_ids))
168
+ )
169
+
170
+ best = max(states.values(), key=lambda state: (state.duration_ms, len(state.node_ids)))
171
+ return CriticalPath(best.node_ids, best.duration_ms)
172
+
173
+ def parallelizable_groups(self) -> list[dict[str, Any]]:
174
+ groups: dict[tuple[str, str], list[ExecutionNode]] = {}
175
+ for node in self.nodes:
176
+ key = (node.event_type, node.name)
177
+ groups.setdefault(key, []).append(node)
178
+
179
+ out: list[dict[str, Any]] = []
180
+ for (event_type, name), items in groups.items():
181
+ if event_type != "tool_call" or len(items) < 3:
182
+ continue
183
+ sequential = sum(item.duration_ms for item in items)
184
+ parallel = max(item.duration_ms for item in items)
185
+ out.append(
186
+ {
187
+ "name": name,
188
+ "event_type": event_type,
189
+ "count": len(items),
190
+ "node_ids": [item.node_id for item in items],
191
+ "sequential_time_ms": round(sequential, 3),
192
+ "estimated_parallel_time_ms": round(parallel, 3),
193
+ "estimated_savings_ms": round(max(0.0, sequential - parallel), 3),
194
+ }
195
+ )
196
+ return sorted(out, key=lambda item: item["estimated_savings_ms"], reverse=True)
197
+
198
+ def bottlenecks(self, limit: int = 5) -> list[dict[str, Any]]:
199
+ total = self.total_runtime_ms() or 1.0
200
+ ranked = sorted(self.nodes, key=lambda node: node.duration_ms, reverse=True)[:limit]
201
+ return [
202
+ {
203
+ **node.to_dict(),
204
+ "runtime_share": round(node.duration_ms / total, 4),
205
+ }
206
+ for node in ranked
207
+ ]
208
+
209
+ def to_dict(self) -> dict[str, Any]:
210
+ return {
211
+ "nodes": [node.to_dict() for node in self.nodes],
212
+ "edges": [edge.to_dict() for edge in self.edges],
213
+ "total_runtime_ms": round(self.total_runtime_ms(), 3),
214
+ "cumulative_span_time_ms": round(cumulative_span_time_ms(self.nodes), 3),
215
+ "critical_path": self.critical_path().to_dict(),
216
+ "parallelizable_groups": self.parallelizable_groups(),
217
+ "bottlenecks": self.bottlenecks(),
218
+ }
219
+
220
+
221
+ def _event_sort_key(item: tuple[int, Any]) -> tuple[Any, ...]:
222
+ original_index, event = item
223
+ interval = event_interval_ms(event)
224
+ if interval is None:
225
+ return (1, original_index)
226
+ return (0, interval[0], interval[1], str(event.event_id), original_index)
227
+
228
+
229
+ def _node_sort_key(node: ExecutionNode) -> tuple[Any, ...]:
230
+ interval = event_interval_ms(node)
231
+ if interval is None:
232
+ return (1, node.node_id)
233
+ return (0, interval[0], interval[1], node.node_id)
234
+
235
+
236
+ def _inferred_sequence_edges(roots: list[ExecutionNode]) -> list[ExecutionEdge]:
237
+ if len(roots) < 2:
238
+ return []
239
+
240
+ intervals = {node.node_id: event_interval_ms(node) for node in roots}
241
+ if any(interval is None for interval in intervals.values()):
242
+ return [
243
+ ExecutionEdge(source=previous.node_id, target=current.node_id)
244
+ for previous, current in zip(roots, roots[1:])
245
+ ]
246
+
247
+ ordered = sorted(roots, key=_node_sort_key)
248
+ edges: list[ExecutionEdge] = []
249
+ for index, current in enumerate(ordered):
250
+ current_interval = intervals[current.node_id]
251
+ if current_interval is None:
252
+ continue
253
+ candidates = []
254
+ for previous in ordered[:index]:
255
+ previous_interval = intervals[previous.node_id]
256
+ if previous_interval is not None and previous_interval[1] <= current_interval[0]:
257
+ candidates.append((previous_interval[1], previous_interval[0], previous))
258
+ if candidates:
259
+ predecessor = max(candidates, key=lambda item: (item[0], item[1], item[2].node_id))[2]
260
+ edges.append(ExecutionEdge(source=predecessor.node_id, target=current.node_id))
261
+ return edges
262
+
263
+
264
+ def _deduplicate_edges(edges: list[ExecutionEdge]) -> list[ExecutionEdge]:
265
+ seen: set[tuple[str, str, str]] = set()
266
+ result: list[ExecutionEdge] = []
267
+ for edge in edges:
268
+ key = (edge.source, edge.target, edge.kind)
269
+ if key not in seen:
270
+ result.append(edge)
271
+ seen.add(key)
272
+ return result
273
+
274
+
275
+ def _topological_order(
276
+ nodes: list[ExecutionNode],
277
+ predecessors: dict[str, list[str]],
278
+ successors: dict[str, list[str]],
279
+ ) -> list[str]:
280
+ node_by_id = {node.node_id: node for node in nodes}
281
+ indegree = {node_id: len(set(items)) for node_id, items in predecessors.items()}
282
+ ready = sorted(
283
+ (node_by_id[node_id] for node_id, count in indegree.items() if count == 0),
284
+ key=_node_sort_key,
285
+ )
286
+ ordered: list[str] = []
287
+ while ready:
288
+ node = ready.pop(0)
289
+ ordered.append(node.node_id)
290
+ for target in successors[node.node_id]:
291
+ indegree[target] -= 1
292
+ if indegree[target] == 0:
293
+ ready.append(node_by_id[target])
294
+ ready.sort(key=_node_sort_key)
295
+
296
+ if len(ordered) != len(nodes):
297
+ remaining = [node for node in nodes if node.node_id not in set(ordered)]
298
+ ordered.extend(node.node_id for node in sorted(remaining, key=_node_sort_key))
299
+ return ordered
300
+
301
+
302
+ def _extend_path(
303
+ predecessor: _PathState,
304
+ node: ExecutionNode,
305
+ interval: tuple[float, float] | None,
306
+ ) -> _PathState:
307
+ node_ids = [*predecessor.node_ids, node.node_id]
308
+ if predecessor.started_ms is not None and predecessor.ended_ms is not None and interval:
309
+ started = min(predecessor.started_ms, interval[0])
310
+ ended = max(predecessor.ended_ms, interval[1])
311
+ return _PathState(node_ids, ended - started, started, ended)
312
+ return _PathState(node_ids, predecessor.duration_ms + recorded_duration_ms(node))
@@ -0,0 +1 @@
1
+ """Optional integrations for external agent frameworks."""
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import time
5
+ from functools import wraps
6
+ from typing import Any
7
+
8
+ from agentloop.events import utc_now_iso
9
+ from agentloop.tracer import record_tool_call, trace_agent
10
+
11
+
12
+ def _patch_method(obj: Any, method_name: str, span_name: str, metadata: dict[str, Any]) -> bool:
13
+ method = getattr(obj, method_name, None)
14
+ if method is None or not callable(method):
15
+ return False
16
+ if getattr(method, "_agentloop_wrapped", False):
17
+ return True
18
+
19
+ if inspect.iscoroutinefunction(method):
20
+
21
+ @wraps(method)
22
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
23
+ started_at = utc_now_iso()
24
+ start = time.perf_counter()
25
+ status = "ok"
26
+ error = None
27
+ try:
28
+ return await method(*args, **kwargs)
29
+ except Exception as exc:
30
+ status = "error"
31
+ error = str(exc)
32
+ raise
33
+ finally:
34
+ record_tool_call(
35
+ span_name,
36
+ started_at=started_at,
37
+ duration_ms=(time.perf_counter() - start) * 1000,
38
+ status=status,
39
+ error=error,
40
+ metadata=metadata,
41
+ )
42
+
43
+ async_wrapper._agentloop_wrapped = True # type: ignore[attr-defined]
44
+ setattr(obj, method_name, async_wrapper)
45
+ return True
46
+
47
+ @wraps(method)
48
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
49
+ started_at = utc_now_iso()
50
+ start = time.perf_counter()
51
+ status = "ok"
52
+ error = None
53
+ try:
54
+ return method(*args, **kwargs)
55
+ except Exception as exc:
56
+ status = "error"
57
+ error = str(exc)
58
+ raise
59
+ finally:
60
+ record_tool_call(
61
+ span_name,
62
+ started_at=started_at,
63
+ duration_ms=(time.perf_counter() - start) * 1000,
64
+ status=status,
65
+ error=error,
66
+ metadata=metadata,
67
+ )
68
+
69
+ wrapper._agentloop_wrapped = True # type: ignore[attr-defined]
70
+ setattr(obj, method_name, wrapper)
71
+ return True
72
+
73
+
74
+ def instrument_crew(crew: Any, *, name: str | None = None) -> Any:
75
+ """Wrap a CrewAI Crew-like object with AgentLoop tracing.
76
+
77
+ This patches `.kickoff(...)` and `.kickoff_async(...)` when present. CrewAI is
78
+ optional; the integration accepts any object with those methods.
79
+ """
80
+
81
+ run_name = name or getattr(crew, "name", None) or crew.__class__.__name__
82
+ metadata = {"integration": "crewai", "crew_name": str(run_name)}
83
+
84
+ kickoff = getattr(crew, "kickoff", None)
85
+ if (
86
+ kickoff is not None
87
+ and callable(kickoff)
88
+ and not getattr(kickoff, "_agentloop_wrapped", False)
89
+ ):
90
+
91
+ @wraps(kickoff)
92
+ def kickoff_wrapper(*args: Any, **kwargs: Any) -> Any:
93
+ with trace_agent(str(run_name), metadata=metadata):
94
+ return kickoff(*args, **kwargs)
95
+
96
+ kickoff_wrapper._agentloop_wrapped = True # type: ignore[attr-defined]
97
+ setattr(crew, "kickoff", kickoff_wrapper)
98
+
99
+ kickoff_async = getattr(crew, "kickoff_async", None)
100
+ if (
101
+ kickoff_async is not None
102
+ and callable(kickoff_async)
103
+ and not getattr(kickoff_async, "_agentloop_wrapped", False)
104
+ ):
105
+
106
+ @wraps(kickoff_async)
107
+ async def kickoff_async_wrapper(*args: Any, **kwargs: Any) -> Any:
108
+ with trace_agent(str(run_name), metadata=metadata):
109
+ return await kickoff_async(*args, **kwargs)
110
+
111
+ kickoff_async_wrapper._agentloop_wrapped = True # type: ignore[attr-defined]
112
+ setattr(crew, "kickoff_async", kickoff_async_wrapper)
113
+
114
+ return crew
115
+
116
+
117
+ def instrument_task(task: Any, *, name: str | None = None) -> Any:
118
+ """Patch common CrewAI task execution methods as tool-call spans."""
119
+
120
+ task_name = (
121
+ name
122
+ or getattr(task, "description", None)
123
+ or getattr(task, "name", None)
124
+ or task.__class__.__name__
125
+ )
126
+ metadata = {"integration": "crewai", "task_name": str(task_name)}
127
+ for method_name in ("execute", "execute_sync", "execute_async", "run"):
128
+ _patch_method(task, method_name, f"crewai.task.{method_name}", metadata)
129
+ return task
130
+
131
+
132
+ def instrument_agent(agent: Any, *, name: str | None = None) -> Any:
133
+ """Patch common CrewAI agent execution methods as tool-call spans."""
134
+
135
+ agent_name = (
136
+ name
137
+ or getattr(agent, "role", None)
138
+ or getattr(agent, "name", None)
139
+ or agent.__class__.__name__
140
+ )
141
+ metadata = {"integration": "crewai", "agent_name": str(agent_name)}
142
+ for method_name in ("execute_task", "run", "invoke"):
143
+ _patch_method(agent, method_name, f"crewai.agent.{method_name}", metadata)
144
+ return agent
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from functools import wraps
6
+ from typing import Any, TypeVar
7
+
8
+ from agentloop import trace_agent, trace_tool_call
9
+
10
+ F = TypeVar("F", bound=Callable[..., Any])
11
+
12
+
13
+ def _merge_metadata(
14
+ metadata: dict[str, Any] | None, extra: dict[str, Any] | None = None
15
+ ) -> dict[str, Any]:
16
+ merged = {"integration": "langgraph"}
17
+ if metadata:
18
+ merged.update(metadata)
19
+ if extra:
20
+ merged.update(extra)
21
+ return merged
22
+
23
+
24
+ def trace_node(name: str | None = None, metadata: dict[str, Any] | None = None) -> Callable[[F], F]:
25
+ """Wrap a LangGraph node function as an AgentLoop node span.
26
+
27
+ This is dependency-free: it works for normal callables, async callables, or
28
+ LangGraph node functions without importing LangGraph inside AgentLoop.
29
+ """
30
+
31
+ def decorator(fn: F) -> F:
32
+ step_name = name or getattr(fn, "__name__", "langgraph_node")
33
+ step_metadata = _merge_metadata(metadata, {"node_name": step_name})
34
+
35
+ if inspect.iscoroutinefunction(fn):
36
+
37
+ @wraps(fn)
38
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
39
+ with trace_tool_call(step_name, metadata=step_metadata):
40
+ return await fn(*args, **kwargs)
41
+
42
+ return async_wrapper # type: ignore[return-value]
43
+
44
+ @wraps(fn)
45
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
46
+ with trace_tool_call(step_name, metadata=step_metadata):
47
+ return fn(*args, **kwargs)
48
+
49
+ return wrapper # type: ignore[return-value]
50
+
51
+ return decorator
52
+
53
+
54
+ def instrument_state_graph(graph: Any, metadata: dict[str, Any] | None = None) -> Any:
55
+ """Monkey-patch a LangGraph StateGraph-like builder so add_node auto-wraps nodes.
56
+
57
+ Usage:
58
+ builder = StateGraph(State)
59
+ instrument_state_graph(builder)
60
+ builder.add_node("retrieve", retrieve_fn) # automatically traced
61
+
62
+ The function intentionally accepts `Any` so AgentLoop does not need LangGraph
63
+ as a dependency. It expects the object to expose an `add_node(name, action, ...)`
64
+ method, which matches LangGraph's builder style.
65
+ """
66
+
67
+ if getattr(graph, "_agentloop_instrumented", False):
68
+ return graph
69
+ original_add_node = getattr(graph, "add_node", None)
70
+ if original_add_node is None or not callable(original_add_node):
71
+ raise TypeError("Expected a LangGraph-like object with callable add_node(...).")
72
+
73
+ @wraps(original_add_node)
74
+ def add_node(name: str, action: Any = None, *args: Any, **kwargs: Any) -> Any:
75
+ wrapped_action = action
76
+ if callable(action):
77
+ wrapped_action = trace_node(
78
+ str(name), _merge_metadata(metadata, {"auto_instrumented": True})
79
+ )(action)
80
+ return original_add_node(name, wrapped_action, *args, **kwargs)
81
+
82
+ setattr(graph, "add_node", add_node)
83
+ setattr(graph, "_agentloop_instrumented", True)
84
+ return graph
85
+
86
+
87
+ class TracedRunnable:
88
+ """Small wrapper for compiled LangGraph apps/runnables.
89
+
90
+ It creates an AgentLoop trace around `.invoke(...)`, `.ainvoke(...)`, `.stream(...)`,
91
+ and `.astream(...)` while delegating every other attribute to the wrapped app.
92
+ """
93
+
94
+ def __init__(
95
+ self, app: Any, name: str = "langgraph_app", metadata: dict[str, Any] | None = None
96
+ ):
97
+ self.app = app
98
+ self.name = name
99
+ self.metadata = _merge_metadata(metadata, {"runnable": True})
100
+ self.last_trace = None
101
+
102
+ def __getattr__(self, item: str) -> Any:
103
+ return getattr(self.app, item)
104
+
105
+ def invoke(self, *args: Any, **kwargs: Any) -> Any:
106
+ with trace_agent(self.name, metadata=self.metadata) as trace:
107
+ result = self.app.invoke(*args, **kwargs)
108
+ self.last_trace = trace
109
+ return result
110
+
111
+ async def ainvoke(self, *args: Any, **kwargs: Any) -> Any:
112
+ with trace_agent(self.name, metadata=self.metadata) as trace:
113
+ result = await self.app.ainvoke(*args, **kwargs)
114
+ self.last_trace = trace
115
+ return result
116
+
117
+ def stream(self, *args: Any, **kwargs: Any) -> Any:
118
+ with trace_agent(self.name, metadata=self.metadata) as trace:
119
+ for item in self.app.stream(*args, **kwargs):
120
+ yield item
121
+ self.last_trace = trace
122
+
123
+ async def astream(self, *args: Any, **kwargs: Any) -> Any:
124
+ with trace_agent(self.name, metadata=self.metadata) as trace:
125
+ async for item in self.app.astream(*args, **kwargs):
126
+ yield item
127
+ self.last_trace = trace
128
+
129
+ def export_last_trace(self, path: str) -> None:
130
+ if self.last_trace is None:
131
+ raise RuntimeError("No trace recorded yet. Call invoke/ainvoke/stream/astream first.")
132
+ self.last_trace.export_json(path)
133
+
134
+
135
+ def trace_runnable(
136
+ app: Any, name: str = "langgraph_app", metadata: dict[str, Any] | None = None
137
+ ) -> TracedRunnable:
138
+ """Wrap a compiled LangGraph app/runnable with AgentLoop tracing."""
139
+
140
+ return TracedRunnable(app=app, name=name, metadata=metadata)