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/timing.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Iterable
7
+
8
+
9
+ def timestamp_ms(value: Any) -> float | None:
10
+ """Parse an ISO-8601 timestamp into milliseconds since the Unix epoch."""
11
+
12
+ if not isinstance(value, str) or not value:
13
+ return None
14
+ try:
15
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
16
+ except ValueError:
17
+ return None
18
+ if parsed.tzinfo is None:
19
+ parsed = parsed.replace(tzinfo=timezone.utc)
20
+ result = parsed.timestamp() * 1000
21
+ return result if math.isfinite(result) else None
22
+
23
+
24
+ def duration_ms(value: Any) -> float:
25
+ """Return a finite, non-negative duration for an event-like object."""
26
+
27
+ try:
28
+ result = float(getattr(value, "duration_ms", 0.0) or 0.0)
29
+ except (TypeError, ValueError):
30
+ return 0.0
31
+ return result if math.isfinite(result) and result >= 0 else 0.0
32
+
33
+
34
+ def event_interval_ms(event: Any) -> tuple[float, float] | None:
35
+ """Return a trustworthy timestamp interval for an event, when available.
36
+
37
+ Older adapters sometimes filled both timestamps with the current time while
38
+ carrying the real duration in ``duration_ms``. Treat a large disagreement
39
+ between those values as missing timing data so legacy traces can use the
40
+ cumulative-duration fallback instead of reporting an almost-zero runtime.
41
+ """
42
+
43
+ started = timestamp_ms(getattr(event, "started_at", None))
44
+ ended = timestamp_ms(getattr(event, "ended_at", None))
45
+ if started is None or ended is None or ended < started:
46
+ return None
47
+
48
+ recorded_duration = duration_ms(event)
49
+ observed_duration = ended - started
50
+ if recorded_duration > 0:
51
+ tolerance = max(5.0, recorded_duration * 0.10)
52
+ if abs(observed_duration - recorded_duration) > tolerance:
53
+ return None
54
+ return started, ended
55
+
56
+
57
+ def event_time_bounds(events: Iterable[Any]) -> tuple[float, float] | None:
58
+ """Return the earliest reliable start and latest reliable end.
59
+
60
+ A positive-duration event without trustworthy timestamps makes the complete
61
+ trace envelope unknowable, so callers should fall back to cumulative work.
62
+ Zero-duration events without timestamps do not affect either result.
63
+ """
64
+
65
+ earliest: float | None = None
66
+ latest: float | None = None
67
+ for event in events:
68
+ interval = event_interval_ms(event)
69
+ if interval is None:
70
+ if duration_ms(event) > 0:
71
+ return None
72
+ continue
73
+ started, ended = interval
74
+ earliest = started if earliest is None else min(earliest, started)
75
+ latest = ended if latest is None else max(latest, ended)
76
+ if earliest is None or latest is None:
77
+ return None
78
+ return earliest, latest
79
+
80
+
81
+ def cumulative_span_time_ms(events: Iterable[Any]) -> float:
82
+ """Return total instrumented work, including overlap and nesting."""
83
+
84
+ return sum(duration_ms(event) for event in events)
85
+
86
+
87
+ def elapsed_runtime_ms(trace: Any) -> float:
88
+ """Resolve end-to-end trace duration with backward-compatible fallbacks."""
89
+
90
+ explicit = _non_negative_float(getattr(trace, "elapsed_ms", None))
91
+ if explicit is not None:
92
+ return explicit
93
+
94
+ if getattr(trace, "_timing_active", False):
95
+ started_perf = _non_negative_float(getattr(trace, "_start_perf", None))
96
+ if started_perf is not None:
97
+ return max(0.0, (time.perf_counter() - started_perf) * 1000)
98
+
99
+ trace_started = timestamp_ms(getattr(trace, "started_at", None))
100
+ trace_ended = timestamp_ms(getattr(trace, "ended_at", None))
101
+ if trace_started is not None and trace_ended is not None and trace_ended >= trace_started:
102
+ return trace_ended - trace_started
103
+
104
+ events = list(getattr(trace, "events", []))
105
+ bounds = event_time_bounds(events)
106
+ if bounds is not None:
107
+ earliest, latest = bounds
108
+ if trace_started is not None and trace_started <= latest:
109
+ earliest = min(earliest, trace_started)
110
+ return max(0.0, latest - earliest)
111
+
112
+ return cumulative_span_time_ms(events)
113
+
114
+
115
+ def _non_negative_float(value: Any) -> float | None:
116
+ if value is None or value == "":
117
+ return None
118
+ try:
119
+ result = float(value)
120
+ except (TypeError, ValueError):
121
+ return None
122
+ if not math.isfinite(result) or result < 0:
123
+ return None
124
+ return result
agentloop/tracer.py ADDED
@@ -0,0 +1,356 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from pathlib import Path
8
+ from typing import Any, Iterator
9
+
10
+ from agentloop.events import AgentEvent, new_event_id, new_run_id, utc_now_iso
11
+ from agentloop.metrics import build_report
12
+
13
+ _current_trace: ContextVar["AgentTrace | None"] = ContextVar(
14
+ "agentloop_current_trace", default=None
15
+ )
16
+ _current_event_id: ContextVar[str | None] = ContextVar("agentloop_current_event_id", default=None)
17
+
18
+
19
+ def _count_tokens(text: str | None) -> int:
20
+ if not text:
21
+ return 0
22
+ return max(1, len(text.split()))
23
+
24
+
25
+ class AgentTrace:
26
+ def __init__(
27
+ self,
28
+ name: str,
29
+ run_id: str | None = None,
30
+ metadata: dict[str, Any] | None = None,
31
+ *,
32
+ started_at: str | None = None,
33
+ ended_at: str | None = None,
34
+ elapsed_ms: float | None = None,
35
+ ):
36
+ self.name = name
37
+ self.run_id = run_id or new_run_id()
38
+ self.metadata = metadata or {}
39
+ self.events: list[AgentEvent] = []
40
+ self.started_at = started_at or utc_now_iso()
41
+ self.ended_at = ended_at
42
+ self.elapsed_ms = float(elapsed_ms) if elapsed_ms is not None else None
43
+ self._start_perf = time.perf_counter()
44
+ self._timing_active = False
45
+ self.finalize_result: dict[str, Any] | None = None
46
+
47
+ def add_event(self, event: AgentEvent) -> None:
48
+ self.events.append(event)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "name": self.name,
53
+ "run_id": self.run_id,
54
+ "started_at": self.started_at,
55
+ "ended_at": self.ended_at,
56
+ "elapsed_ms": self.elapsed_ms,
57
+ "metadata": self.metadata,
58
+ "events": [event.to_dict() for event in self.events],
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, Any]) -> "AgentTrace":
63
+ trace = cls(
64
+ name=data["name"],
65
+ run_id=data["run_id"],
66
+ metadata=data.get("metadata", {}),
67
+ started_at=data.get("started_at"),
68
+ ended_at=data.get("ended_at"),
69
+ elapsed_ms=data.get("elapsed_ms"),
70
+ )
71
+ trace.events = [AgentEvent.from_dict(item) for item in data.get("events", [])]
72
+ return trace
73
+
74
+ def finish(self) -> None:
75
+ """Finalize trace timing once, using a monotonic clock when available."""
76
+
77
+ if self.ended_at is not None and self.elapsed_ms is not None:
78
+ self._timing_active = False
79
+ return
80
+
81
+ if self.ended_at is None:
82
+ self.ended_at = utc_now_iso()
83
+ if self.elapsed_ms is None:
84
+ if self._timing_active:
85
+ self.elapsed_ms = max(0.0, (time.perf_counter() - self._start_perf) * 1000)
86
+ else:
87
+ from agentloop.timing import elapsed_runtime_ms
88
+
89
+ self.elapsed_ms = elapsed_runtime_ms(self)
90
+ self._timing_active = False
91
+
92
+ @classmethod
93
+ def from_json(cls, path: str | Path) -> "AgentTrace":
94
+ return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
95
+
96
+ def export_json(self, path: str | Path) -> Path:
97
+ out = Path(path)
98
+ out.parent.mkdir(parents=True, exist_ok=True)
99
+ out.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
100
+ return out
101
+
102
+ def report(self) -> dict[str, Any]:
103
+ return build_report(self)
104
+
105
+ def print_report(self) -> None:
106
+ report = self.report()
107
+ print(f"AgentLoop Report: {self.name}")
108
+ print(f"Run ID: {self.run_id}")
109
+ print(f"Total runtime: {report['total_runtime_ms'] / 1000:.2f}s")
110
+ print(f"Estimated cost: ${report['estimated_cost_usd']:.4f}")
111
+ print(f"Model time: {report['model_time_ms'] / 1000:.2f}s")
112
+ print(f"Tool time: {report['tool_time_ms'] / 1000:.2f}s")
113
+ print(f"Retry time: {report['retry_time_ms'] / 1000:.2f}s")
114
+ print(f"Input tokens: {report['input_tokens']}")
115
+ print(f"Output tokens: {report['output_tokens']}")
116
+ print(f"Repeated context ratio: {report['repeated_context_ratio']:.1%}")
117
+ print("\nRecommendations:")
118
+ for rec in report["recommendations"]:
119
+ print(f"- {rec['title']}: {rec['description']}")
120
+
121
+
122
+ def current_trace() -> AgentTrace | None:
123
+ """Return the active AgentLoop trace, if one is running in this context."""
124
+
125
+ return _current_trace.get()
126
+
127
+
128
+ def current_event_id() -> str | None:
129
+ """Return the event id currently being recorded under, if any."""
130
+
131
+ return _current_event_id.get()
132
+
133
+
134
+ @contextmanager
135
+ def trace_agent(name: str, metadata: dict[str, Any] | None = None) -> Iterator[AgentTrace]:
136
+ trace = AgentTrace(name=name, metadata=metadata)
137
+ trace._timing_active = True
138
+ token = _current_trace.set(trace)
139
+ event_token = _current_event_id.set(None)
140
+ try:
141
+ yield trace
142
+ finally:
143
+ trace.finish()
144
+ _current_event_id.reset(event_token)
145
+ _current_trace.reset(token)
146
+ from agentloop.runtime import finalize_trace, should_auto_export
147
+
148
+ if should_auto_export():
149
+ trace.finalize_result = finalize_trace(trace)
150
+
151
+
152
+ def record_model_call(
153
+ name: str,
154
+ *,
155
+ duration_ms: float,
156
+ started_at: str,
157
+ ended_at: str | None = None,
158
+ model: str | None = None,
159
+ input_tokens: int = 0,
160
+ output_tokens: int = 0,
161
+ input_text: str | None = None,
162
+ output_text: str | None = None,
163
+ status: str = "ok",
164
+ error: str | None = None,
165
+ metadata: dict[str, Any] | None = None,
166
+ event_id: str | None = None,
167
+ parent_id: str | None = None,
168
+ trace: AgentTrace | None = None,
169
+ ) -> None:
170
+ """Record a completed model call into a trace.
171
+
172
+ Integrations use this when token counts are only known after a framework call
173
+ returns. User code should usually prefer `trace_model_call(...)`.
174
+
175
+ By default the event is recorded into the trace active in the current context.
176
+ Pass ``trace`` to record into a specific trace instead — for example an
177
+ integration that captured trace ownership when a streaming call was invoked and
178
+ finalizes it later, possibly in a different context. When ``trace`` is given the
179
+ ambient event-id context is not used for the parent.
180
+ """
181
+
182
+ target = trace if trace is not None else _require_trace()
183
+ resolved_parent = (
184
+ parent_id
185
+ if parent_id is not None
186
+ else (None if trace is not None else _current_event_id.get())
187
+ )
188
+ target.add_event(
189
+ AgentEvent(
190
+ event_id=event_id or new_event_id(),
191
+ run_id=target.run_id,
192
+ event_type="model_call",
193
+ name=name,
194
+ started_at=started_at,
195
+ ended_at=ended_at or utc_now_iso(),
196
+ duration_ms=duration_ms,
197
+ parent_id=resolved_parent,
198
+ model=model,
199
+ input_tokens=input_tokens,
200
+ output_tokens=output_tokens,
201
+ input_text=input_text,
202
+ output_text=output_text,
203
+ status=status,
204
+ error=error,
205
+ metadata=metadata or {},
206
+ )
207
+ )
208
+
209
+
210
+ def record_tool_call(
211
+ name: str,
212
+ *,
213
+ duration_ms: float,
214
+ started_at: str,
215
+ ended_at: str | None = None,
216
+ status: str = "ok",
217
+ error: str | None = None,
218
+ metadata: dict[str, Any] | None = None,
219
+ event_id: str | None = None,
220
+ parent_id: str | None = None,
221
+ ) -> None:
222
+ """Record a completed tool/framework step into the active trace."""
223
+
224
+ trace = _require_trace()
225
+ trace.add_event(
226
+ AgentEvent(
227
+ event_id=event_id or new_event_id(),
228
+ run_id=trace.run_id,
229
+ event_type="tool_call",
230
+ name=name,
231
+ started_at=started_at,
232
+ ended_at=ended_at or utc_now_iso(),
233
+ duration_ms=duration_ms,
234
+ parent_id=parent_id if parent_id is not None else _current_event_id.get(),
235
+ status=status,
236
+ error=error,
237
+ metadata=metadata or {},
238
+ )
239
+ )
240
+
241
+
242
+ @contextmanager
243
+ def trace_model_call(
244
+ name: str,
245
+ model: str | None = None,
246
+ input_tokens: int | None = None,
247
+ output_tokens: int | None = None,
248
+ input_text: str | None = None,
249
+ output_text: str | None = None,
250
+ metadata: dict[str, Any] | None = None,
251
+ ) -> Iterator[None]:
252
+ _require_trace()
253
+ event_id = new_event_id()
254
+ parent_id = _current_event_id.get()
255
+ event_token = _current_event_id.set(event_id)
256
+ started_at = utc_now_iso()
257
+ start = time.perf_counter()
258
+ status = "ok"
259
+ error = None
260
+ try:
261
+ yield
262
+ except Exception as exc:
263
+ status = "error"
264
+ error = str(exc)
265
+ raise
266
+ finally:
267
+ ended_at = utc_now_iso()
268
+ duration_ms = (time.perf_counter() - start) * 1000
269
+ _current_event_id.reset(event_token)
270
+ record_model_call(
271
+ name,
272
+ started_at=started_at,
273
+ ended_at=ended_at,
274
+ duration_ms=duration_ms,
275
+ model=model,
276
+ input_tokens=input_tokens if input_tokens is not None else _count_tokens(input_text),
277
+ output_tokens=output_tokens
278
+ if output_tokens is not None
279
+ else _count_tokens(output_text),
280
+ input_text=input_text,
281
+ output_text=output_text,
282
+ status=status,
283
+ error=error,
284
+ metadata=metadata or {},
285
+ event_id=event_id,
286
+ parent_id=parent_id,
287
+ )
288
+
289
+
290
+ @contextmanager
291
+ def trace_tool_call(name: str, metadata: dict[str, Any] | None = None) -> Iterator[None]:
292
+ _require_trace()
293
+ event_id = new_event_id()
294
+ parent_id = _current_event_id.get()
295
+ event_token = _current_event_id.set(event_id)
296
+ started_at = utc_now_iso()
297
+ start = time.perf_counter()
298
+ status = "ok"
299
+ error = None
300
+ try:
301
+ yield
302
+ except Exception as exc:
303
+ status = "error"
304
+ error = str(exc)
305
+ raise
306
+ finally:
307
+ ended_at = utc_now_iso()
308
+ duration_ms = (time.perf_counter() - start) * 1000
309
+ _current_event_id.reset(event_token)
310
+ record_tool_call(
311
+ name,
312
+ started_at=started_at,
313
+ ended_at=ended_at,
314
+ duration_ms=duration_ms,
315
+ status=status,
316
+ error=error,
317
+ metadata=metadata or {},
318
+ event_id=event_id,
319
+ parent_id=parent_id,
320
+ )
321
+
322
+
323
+ @contextmanager
324
+ def trace_retry(name: str, metadata: dict[str, Any] | None = None) -> Iterator[None]:
325
+ trace = _require_trace()
326
+ event_id = new_event_id()
327
+ parent_id = _current_event_id.get()
328
+ event_token = _current_event_id.set(event_id)
329
+ started_at = utc_now_iso()
330
+ start = time.perf_counter()
331
+ try:
332
+ yield
333
+ finally:
334
+ ended_at = utc_now_iso()
335
+ duration_ms = (time.perf_counter() - start) * 1000
336
+ _current_event_id.reset(event_token)
337
+ trace.add_event(
338
+ AgentEvent(
339
+ event_id=event_id,
340
+ run_id=trace.run_id,
341
+ event_type="retry",
342
+ name=name,
343
+ started_at=started_at,
344
+ ended_at=ended_at,
345
+ duration_ms=duration_ms,
346
+ parent_id=parent_id,
347
+ metadata=metadata or {},
348
+ )
349
+ )
350
+
351
+
352
+ def _require_trace() -> AgentTrace:
353
+ trace = _current_trace.get()
354
+ if trace is None:
355
+ raise RuntimeError("No active AgentLoop trace. Use `with trace_agent(...):` first.")
356
+ return trace