agentloop-profiler 0.1.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.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/tracer.py ADDED
@@ -0,0 +1,494 @@
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.costs import format_cost_usd
11
+ from agentloop.events import (
12
+ AgentEvent,
13
+ format_exception_detail,
14
+ new_event_id,
15
+ new_run_id,
16
+ utc_now_iso,
17
+ )
18
+ from agentloop.metrics import build_report
19
+ from agentloop.schema import SCHEMA_VERSION, validate_elapsed_ms, validate_trace_dict
20
+ from agentloop.tokens import (
21
+ ESTIMATED_WORDS,
22
+ UNAVAILABLE,
23
+ USER_SUPPLIED,
24
+ describe_token_status,
25
+ is_token_basis_exact,
26
+ validate_provenance,
27
+ )
28
+
29
+ _current_trace: ContextVar["AgentTrace | None"] = ContextVar(
30
+ "agentloop_current_trace", default=None
31
+ )
32
+ _current_event_id: ContextVar[str | None] = ContextVar("agentloop_current_event_id", default=None)
33
+
34
+
35
+ def _count_tokens(text: str | None) -> int:
36
+ """Approximate a token count from whitespace words.
37
+
38
+ This is a proxy, not a token count. Every caller must record
39
+ :data:`agentloop.tokens.ESTIMATED_WORDS` provenance alongside the number so
40
+ downstream cost, replay, and research output never present it as exact usage.
41
+ """
42
+
43
+ if not text:
44
+ return 0
45
+ return max(1, len(text.split()))
46
+
47
+
48
+ def _resolve_token_counts(
49
+ input_tokens: int | None,
50
+ output_tokens: int | None,
51
+ input_text: str | None,
52
+ output_text: str | None,
53
+ ) -> tuple[int, int, str]:
54
+ """Resolve a model call's token counts and record where they came from.
55
+
56
+ Explicit counts are used verbatim and marked ``user_supplied``. A side with no
57
+ explicit count falls back to the word-count proxy; if that proxy actually
58
+ contributes a number, the whole event is marked ``estimated_words`` — the
59
+ weaker of the two provenances, because a total mixing a real count with an
60
+ approximation is itself an approximation. With neither counts nor text there
61
+ is nothing to report, which is ``unavailable`` rather than a confident zero.
62
+ """
63
+
64
+ resolved_input = input_tokens if input_tokens is not None else _count_tokens(input_text)
65
+ resolved_output = output_tokens if output_tokens is not None else _count_tokens(output_text)
66
+ estimated = (input_tokens is None and resolved_input > 0) or (
67
+ output_tokens is None and resolved_output > 0
68
+ )
69
+ if estimated:
70
+ provenance = ESTIMATED_WORDS
71
+ elif input_tokens is None and output_tokens is None:
72
+ provenance = UNAVAILABLE
73
+ else:
74
+ provenance = USER_SUPPLIED
75
+ return resolved_input, resolved_output, provenance
76
+
77
+
78
+ class AgentTrace:
79
+ def __init__(
80
+ self,
81
+ name: str,
82
+ run_id: str | None = None,
83
+ metadata: dict[str, Any] | None = None,
84
+ *,
85
+ started_at: str | None = None,
86
+ ended_at: str | None = None,
87
+ elapsed_ms: float | None = None,
88
+ ):
89
+ self.name = name
90
+ self.run_id = run_id or new_run_id()
91
+ self.metadata = metadata or {}
92
+ self.events: list[AgentEvent] = []
93
+ self.started_at = started_at or utc_now_iso()
94
+ self.ended_at = ended_at
95
+ self.elapsed_ms = validate_elapsed_ms(elapsed_ms)
96
+ self._start_perf = time.perf_counter()
97
+ self._timing_active = False
98
+ self.finalize_result: dict[str, Any] | None = None
99
+
100
+ def add_event(self, event: AgentEvent) -> None:
101
+ self.events.append(event)
102
+
103
+ def to_dict(self) -> dict[str, Any]:
104
+ return {
105
+ "schema_version": SCHEMA_VERSION,
106
+ "name": self.name,
107
+ "run_id": self.run_id,
108
+ "started_at": self.started_at,
109
+ "ended_at": self.ended_at,
110
+ "elapsed_ms": self.elapsed_ms,
111
+ "metadata": self.metadata,
112
+ "events": [event.to_dict() for event in self.events],
113
+ }
114
+
115
+ @classmethod
116
+ def from_dict(cls, data: dict[str, Any]) -> "AgentTrace":
117
+ """Deserialize a trace, validating the shared schema contract.
118
+
119
+ Raises :class:`agentloop.schema.TraceValidationError` (a ``ValueError``)
120
+ when the envelope or any event violates the schema. Traces without a
121
+ ``schema_version`` (0.4-era files) are accepted for backward
122
+ compatibility.
123
+ """
124
+
125
+ validate_trace_dict(data)
126
+ trace = cls(
127
+ name=data["name"],
128
+ run_id=data["run_id"],
129
+ metadata=data.get("metadata", {}),
130
+ started_at=data.get("started_at"),
131
+ ended_at=data.get("ended_at"),
132
+ elapsed_ms=data.get("elapsed_ms"),
133
+ )
134
+ trace.events = [
135
+ AgentEvent.from_dict(item, index=index)
136
+ for index, item in enumerate(data.get("events", []))
137
+ ]
138
+ return trace
139
+
140
+ def finish(self) -> None:
141
+ """Finalize trace timing once, using a monotonic clock when available."""
142
+
143
+ if self.ended_at is not None and self.elapsed_ms is not None:
144
+ self._timing_active = False
145
+ return
146
+
147
+ if self.ended_at is None:
148
+ self.ended_at = utc_now_iso()
149
+ if self.elapsed_ms is None:
150
+ if self._timing_active:
151
+ self.elapsed_ms = max(0.0, (time.perf_counter() - self._start_perf) * 1000)
152
+ else:
153
+ from agentloop.timing import elapsed_runtime_ms
154
+
155
+ self.elapsed_ms = elapsed_runtime_ms(self)
156
+ self._timing_active = False
157
+
158
+ @classmethod
159
+ def from_json(cls, path: str | Path) -> "AgentTrace":
160
+ return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
161
+
162
+ def export_json(self, path: str | Path) -> Path:
163
+ out = Path(path)
164
+ out.parent.mkdir(parents=True, exist_ok=True)
165
+ out.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
166
+ return out
167
+
168
+ def report(self) -> dict[str, Any]:
169
+ return build_report(self)
170
+
171
+ def print_report(self) -> None:
172
+ report = self.report()
173
+ print(f"AgentLoop Report: {self.name}")
174
+ print(f"Run ID: {self.run_id}")
175
+ from agentloop.timing import format_duration_ms
176
+
177
+ print(f"Total runtime: {format_duration_ms(report['total_runtime_ms'])}")
178
+ token_status = report.get("token_status", "unspecified")
179
+ print(
180
+ "Estimated cost: "
181
+ + format_cost_usd(
182
+ report.get("estimated_cost_usd"),
183
+ report.get("cost_status", "complete"),
184
+ token_status=token_status,
185
+ )
186
+ )
187
+ print(f"Model time: {format_duration_ms(report['model_time_ms'])}")
188
+ print(f"Tool time: {format_duration_ms(report['tool_time_ms'])}")
189
+ print(f"Retry time: {format_duration_ms(report['retry_time_ms'])}")
190
+ token_note = "" if is_token_basis_exact(token_status) else f" [{token_status}]"
191
+ print(f"Input tokens: {report['input_tokens']}{token_note}")
192
+ print(f"Output tokens: {report['output_tokens']}{token_note}")
193
+ if not is_token_basis_exact(token_status):
194
+ print(f" Token basis: {describe_token_status(token_status)}")
195
+ ratio = report["repeated_context_ratio"]
196
+ print("Repeated context ratio: " + ("unavailable" if ratio is None else f"{ratio:.1%}"))
197
+ print("\nRecommendations:")
198
+ for rec in report["recommendations"]:
199
+ print(f"- {rec['title']}: {rec['description']}")
200
+
201
+
202
+ def current_trace() -> AgentTrace | None:
203
+ """Return the active AgentLoop trace, if one is running in this context."""
204
+
205
+ return _current_trace.get()
206
+
207
+
208
+ def current_event_id() -> str | None:
209
+ """Return the event id currently being recorded under, if any."""
210
+
211
+ return _current_event_id.get()
212
+
213
+
214
+ @contextmanager
215
+ def bind_trace_context(trace: AgentTrace | None, event_id: str | None = None) -> Iterator[None]:
216
+ """Temporarily bind a captured trace and parent event in the current context.
217
+
218
+ ``trace=None`` clears the ambient trace; ``event_id=None`` clears its parent.
219
+ On exit, both previous bindings
220
+ are restored, including when an exception or cancellation propagates. This
221
+ helper does not create events, start timers, finish traces, or export them.
222
+ Enter and exit each binding in the same execution context; generator adapters
223
+ should use a fresh binding for each resume/close operation, ending it before
224
+ yielding to their caller.
225
+ """
226
+
227
+ trace_token = _current_trace.set(trace)
228
+ event_token = _current_event_id.set(event_id)
229
+ try:
230
+ yield
231
+ finally:
232
+ _current_event_id.reset(event_token)
233
+ _current_trace.reset(trace_token)
234
+
235
+
236
+ @contextmanager
237
+ def _trace_execution(name: str, metadata: dict[str, Any] | None = None) -> Iterator[AgentTrace]:
238
+ trace = AgentTrace(name=name, metadata=metadata)
239
+ trace._timing_active = True
240
+ token = _current_trace.set(trace)
241
+ event_token = _current_event_id.set(None)
242
+ try:
243
+ yield trace
244
+ finally:
245
+ trace.finish()
246
+ _current_event_id.reset(event_token)
247
+ _current_trace.reset(token)
248
+ from agentloop.runtime import finalize_trace, should_auto_export
249
+
250
+ if should_auto_export():
251
+ trace.finalize_result = finalize_trace(trace)
252
+
253
+
254
+ @contextmanager
255
+ def trace_agent(name: str, metadata: dict[str, Any] | None = None) -> Iterator[AgentTrace]:
256
+ """Backward-compatible agent scope sharing the generic execution lifecycle."""
257
+ with _trace_execution(name, metadata) as trace:
258
+ yield trace
259
+
260
+
261
+ def record_model_call(
262
+ name: str,
263
+ *,
264
+ duration_ms: float,
265
+ started_at: str,
266
+ ended_at: str | None = None,
267
+ model: str | None = None,
268
+ input_tokens: int = 0,
269
+ output_tokens: int = 0,
270
+ token_provenance: str | None = None,
271
+ input_text: str | None = None,
272
+ output_text: str | None = None,
273
+ status: str = "ok",
274
+ error: str | None = None,
275
+ metadata: dict[str, Any] | None = None,
276
+ event_id: str | None = None,
277
+ parent_id: str | None = None,
278
+ trace: AgentTrace | None = None,
279
+ ) -> None:
280
+ """Record a completed model call into a trace.
281
+
282
+ Integrations use this when token counts are only known after a framework call
283
+ returns. User code should usually prefer `trace_model_call(...)`.
284
+
285
+ ``token_provenance`` records where the token counts came from; an integration
286
+ that read a provider usage object should pass
287
+ :data:`agentloop.tokens.PROVIDER`. When omitted it is inferred from the counts
288
+ themselves: any non-zero count is treated as ``user_supplied`` (this entry
289
+ point takes explicit integers, so a caller passing one is supplying it), and
290
+ an all-zero call is recorded as ``unavailable`` rather than as a confident
291
+ zero. Passing a value AgentLoop does not define raises
292
+ :class:`agentloop.tokens.TokenProvenanceError`.
293
+
294
+ By default the event is recorded into the trace active in the current context.
295
+ Pass ``trace`` to record into a specific trace instead — for example an
296
+ integration that captured trace ownership when a streaming call was invoked and
297
+ finalizes it later, possibly in a different context. When ``trace`` is given the
298
+ ambient event-id context is not used for the parent.
299
+ """
300
+
301
+ target = trace if trace is not None else _require_trace()
302
+ resolved_parent = (
303
+ parent_id
304
+ if parent_id is not None
305
+ else (None if trace is not None else _current_event_id.get())
306
+ )
307
+ if token_provenance is None:
308
+ resolved_provenance = USER_SUPPLIED if (input_tokens or output_tokens) else UNAVAILABLE
309
+ else:
310
+ resolved_provenance = validate_provenance(token_provenance)
311
+ target.add_event(
312
+ AgentEvent(
313
+ event_id=event_id or new_event_id(),
314
+ run_id=target.run_id,
315
+ event_type="model_call",
316
+ name=name,
317
+ started_at=started_at,
318
+ ended_at=ended_at or utc_now_iso(),
319
+ duration_ms=duration_ms,
320
+ parent_id=resolved_parent,
321
+ model=model,
322
+ input_tokens=input_tokens,
323
+ output_tokens=output_tokens,
324
+ token_provenance=resolved_provenance,
325
+ input_text=input_text,
326
+ output_text=output_text,
327
+ status=status,
328
+ error=error,
329
+ metadata=metadata or {},
330
+ )
331
+ )
332
+
333
+
334
+ def record_tool_call(
335
+ name: str,
336
+ *,
337
+ duration_ms: float,
338
+ started_at: str,
339
+ ended_at: str | None = None,
340
+ status: str = "ok",
341
+ error: str | None = None,
342
+ metadata: dict[str, Any] | None = None,
343
+ event_id: str | None = None,
344
+ parent_id: str | None = None,
345
+ trace: AgentTrace | None = None,
346
+ ) -> None:
347
+ """Record a completed tool/framework step into a trace.
348
+
349
+ Without ``trace``, use the active trace and inherit its current parent unless
350
+ ``parent_id`` is supplied. An explicit ``trace`` ignores the ambient parent:
351
+ pass a captured ``parent_id`` to attach the event, or omit it for a root event.
352
+ This matches ``record_model_call`` and never changes the active context.
353
+ """
354
+
355
+ target = trace if trace is not None else _require_trace()
356
+ resolved_parent = (
357
+ parent_id
358
+ if parent_id is not None
359
+ else (None if trace is not None else _current_event_id.get())
360
+ )
361
+ target.add_event(
362
+ AgentEvent(
363
+ event_id=event_id or new_event_id(),
364
+ run_id=target.run_id,
365
+ event_type="tool_call",
366
+ name=name,
367
+ started_at=started_at,
368
+ ended_at=ended_at or utc_now_iso(),
369
+ duration_ms=duration_ms,
370
+ parent_id=resolved_parent,
371
+ status=status,
372
+ error=error,
373
+ metadata=metadata or {},
374
+ )
375
+ )
376
+
377
+
378
+ @contextmanager
379
+ def trace_model_call(
380
+ name: str,
381
+ model: str | None = None,
382
+ input_tokens: int | None = None,
383
+ output_tokens: int | None = None,
384
+ input_text: str | None = None,
385
+ output_text: str | None = None,
386
+ metadata: dict[str, Any] | None = None,
387
+ ) -> Iterator[None]:
388
+ _require_trace()
389
+ event_id = new_event_id()
390
+ parent_id = _current_event_id.get()
391
+ event_token = _current_event_id.set(event_id)
392
+ started_at = utc_now_iso()
393
+ start = time.perf_counter()
394
+ status = "ok"
395
+ error = None
396
+ try:
397
+ yield
398
+ except BaseException as exc: # noqa: BLE001 - record then propagate unchanged
399
+ status = "error"
400
+ error = format_exception_detail(exc)
401
+ raise
402
+ finally:
403
+ ended_at = utc_now_iso()
404
+ duration_ms = (time.perf_counter() - start) * 1000
405
+ _current_event_id.reset(event_token)
406
+ resolved_input, resolved_output, provenance = _resolve_token_counts(
407
+ input_tokens, output_tokens, input_text, output_text
408
+ )
409
+ record_model_call(
410
+ name,
411
+ started_at=started_at,
412
+ ended_at=ended_at,
413
+ duration_ms=duration_ms,
414
+ model=model,
415
+ input_tokens=resolved_input,
416
+ output_tokens=resolved_output,
417
+ token_provenance=provenance,
418
+ input_text=input_text,
419
+ output_text=output_text,
420
+ status=status,
421
+ error=error,
422
+ metadata=metadata or {},
423
+ event_id=event_id,
424
+ parent_id=parent_id,
425
+ )
426
+
427
+
428
+ @contextmanager
429
+ def trace_tool_call(name: str, metadata: dict[str, Any] | None = None) -> Iterator[None]:
430
+ _require_trace()
431
+ event_id = new_event_id()
432
+ parent_id = _current_event_id.get()
433
+ event_token = _current_event_id.set(event_id)
434
+ started_at = utc_now_iso()
435
+ start = time.perf_counter()
436
+ status = "ok"
437
+ error = None
438
+ try:
439
+ yield
440
+ except BaseException as exc: # noqa: BLE001 - record then propagate unchanged
441
+ status = "error"
442
+ error = format_exception_detail(exc)
443
+ raise
444
+ finally:
445
+ ended_at = utc_now_iso()
446
+ duration_ms = (time.perf_counter() - start) * 1000
447
+ _current_event_id.reset(event_token)
448
+ record_tool_call(
449
+ name,
450
+ started_at=started_at,
451
+ ended_at=ended_at,
452
+ duration_ms=duration_ms,
453
+ status=status,
454
+ error=error,
455
+ metadata=metadata or {},
456
+ event_id=event_id,
457
+ parent_id=parent_id,
458
+ )
459
+
460
+
461
+ @contextmanager
462
+ def trace_retry(name: str, metadata: dict[str, Any] | None = None) -> Iterator[None]:
463
+ trace = _require_trace()
464
+ event_id = new_event_id()
465
+ parent_id = _current_event_id.get()
466
+ event_token = _current_event_id.set(event_id)
467
+ started_at = utc_now_iso()
468
+ start = time.perf_counter()
469
+ try:
470
+ yield
471
+ finally:
472
+ ended_at = utc_now_iso()
473
+ duration_ms = (time.perf_counter() - start) * 1000
474
+ _current_event_id.reset(event_token)
475
+ trace.add_event(
476
+ AgentEvent(
477
+ event_id=event_id,
478
+ run_id=trace.run_id,
479
+ event_type="retry",
480
+ name=name,
481
+ started_at=started_at,
482
+ ended_at=ended_at,
483
+ duration_ms=duration_ms,
484
+ parent_id=parent_id,
485
+ metadata=metadata or {},
486
+ )
487
+ )
488
+
489
+
490
+ def _require_trace() -> AgentTrace:
491
+ trace = _current_trace.get()
492
+ if trace is None:
493
+ raise RuntimeError("No active AgentLoop trace. Use `with trace_agent(...):` first.")
494
+ return trace