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.
- agentloop/__init__.py +68 -0
- agentloop/audit.py +34 -0
- agentloop/autoinstrument.py +102 -0
- agentloop/ci.py +135 -0
- agentloop/cli.py +876 -0
- agentloop/client.py +170 -0
- agentloop/config.py +50 -0
- agentloop/costs.py +15 -0
- agentloop/decorators.py +160 -0
- agentloop/demo.py +283 -0
- agentloop/doctor.py +344 -0
- agentloop/events.py +49 -0
- agentloop/exporters.py +60 -0
- agentloop/findings.py +215 -0
- agentloop/graph.py +312 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/langgraph.py +140 -0
- agentloop/integrations/openai.py +369 -0
- agentloop/integrations/openai_agents.py +212 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/issues.py +88 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +123 -0
- agentloop/optimizer.py +323 -0
- agentloop/otel.py +302 -0
- agentloop/otel_ids.py +81 -0
- agentloop/patches.py +583 -0
- agentloop/plan_export.py +72 -0
- agentloop/quality.py +222 -0
- agentloop/replay.py +424 -0
- agentloop/runtime.py +244 -0
- agentloop/savings.py +135 -0
- agentloop/server.py +292 -0
- agentloop/store.py +840 -0
- agentloop/timing.py +124 -0
- agentloop/tracer.py +356 -0
- agentloop/value.py +243 -0
- agentloop/version.py +3 -0
- agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
- agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
- agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
- agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +1 -0
- dashboard/app.py +698 -0
- 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)
|