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
@@ -0,0 +1,333 @@
1
+ """Bounded JSON and local artifact references for read-only import adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ import stat
9
+ from dataclasses import dataclass, fields
10
+ from hashlib import sha256
11
+ from pathlib import Path, PureWindowsPath
12
+ from typing import Any
13
+
14
+
15
+ class ImportValidationError(ValueError):
16
+ """A payload-free diagnostic: never include untrusted values in messages."""
17
+
18
+ def __init__(self, code: str, field: str, reason: str) -> None:
19
+ self.code, self.field, self.reason = code, field, reason
20
+ super().__init__(f"{field}: {reason}")
21
+
22
+ def to_dict(self) -> dict[str, str]:
23
+ return {"code": self.code, "field": self.field, "reason": self.reason}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ImportLimits:
28
+ max_json_bytes: int = 8 * 1024 * 1024
29
+ max_line_bytes: int = 8 * 1024 * 1024
30
+ max_total_bytes: int = 512 * 1024 * 1024
31
+ max_records: int = 10_000
32
+ max_depth: int = 64
33
+ max_nodes: int = 200_000
34
+ max_trials: int = 10_000
35
+ max_trajectories: int = 256
36
+ max_spans: int = 100_000
37
+ max_events_per_trace: int = 10_000
38
+ max_references: int = 1_024
39
+ max_path_depth: int = 32
40
+ max_metadata_bytes: int = 64 * 1024
41
+ max_number_chars: int = 128
42
+
43
+ def __post_init__(self) -> None:
44
+ for item in fields(self):
45
+ value = getattr(self, item.name)
46
+ if type(value) is not int or value <= 0:
47
+ raise ImportValidationError(
48
+ "invalid_limit", item.name, "must be a positive integer"
49
+ )
50
+ # The JSON decoder is recursive. Raising this setting must not bypass
51
+ # the preflight protection or expose platform-dependent recursion limits.
52
+ if self.max_depth > 128:
53
+ raise ImportValidationError("invalid_limit", "max_depth", "must not exceed 128")
54
+
55
+
56
+ @dataclass
57
+ class ImportBudget:
58
+ """Shared counters for adapters; rejected records still consume read bytes."""
59
+
60
+ limits: ImportLimits
61
+ total_bytes: int = 0
62
+ records: int = 0
63
+ trials: int = 0
64
+ trajectories: int = 0
65
+ spans: int = 0
66
+ references: int = 0
67
+
68
+ def consume(self, **counts: int) -> None:
69
+ pending = {}
70
+ for key, count in counts.items():
71
+ if key not in {
72
+ "total_bytes",
73
+ "records",
74
+ "trials",
75
+ "trajectories",
76
+ "spans",
77
+ "references",
78
+ }:
79
+ raise ImportValidationError("invalid_counter", "budget", "unknown counter")
80
+ if type(count) is not int or count < 0:
81
+ raise ImportValidationError(
82
+ "invalid_counter", "budget", "count must be nonnegative"
83
+ )
84
+ value = getattr(self, key) + count
85
+ if value > getattr(self.limits, "max_" + key):
86
+ raise ImportValidationError("limit_exceeded", key, "import budget exceeded")
87
+ pending[key] = value
88
+ for key, value in pending.items():
89
+ setattr(self, key, value)
90
+
91
+
92
+ def _reject_constant(_: str) -> Any:
93
+ raise ImportValidationError("invalid_number", "json", "non-finite numbers are unsupported")
94
+
95
+
96
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
97
+ result = {}
98
+ for key, value in pairs:
99
+ if key in result:
100
+ raise ImportValidationError(
101
+ "duplicate_key", "json", "duplicate object keys are ambiguous"
102
+ )
103
+ result[key] = value
104
+ return result
105
+
106
+
107
+ def _preflight_depth(data: bytes, limit: int) -> None:
108
+ depth = 0
109
+ quoted = escaped = False
110
+ for char in data:
111
+ if quoted:
112
+ if escaped:
113
+ escaped = False
114
+ elif char == 92:
115
+ escaped = True
116
+ elif char == 34:
117
+ quoted = False
118
+ elif char == 34:
119
+ quoted = True
120
+ elif char in (91, 123):
121
+ depth += 1
122
+ if depth > limit:
123
+ raise ImportValidationError(
124
+ "limit_exceeded", "json.depth", "nesting limit exceeded"
125
+ )
126
+ elif char in (93, 125):
127
+ depth -= 1
128
+
129
+
130
+ def validate_json_tree(value: Any, limits: ImportLimits = ImportLimits()) -> None:
131
+ """Check Python inputs too; no implicit tuple/key coercion or non-finite JSON."""
132
+
133
+ pending = [(value, 0)]
134
+ count = 0
135
+ while pending:
136
+ node, depth = pending.pop()
137
+ count += 1
138
+ if count > limits.max_nodes:
139
+ raise ImportValidationError("limit_exceeded", "json.nodes", "node limit exceeded")
140
+ if type(node) in {dict, list}:
141
+ if depth + 1 > limits.max_depth:
142
+ raise ImportValidationError(
143
+ "limit_exceeded", "json.depth", "nesting limit exceeded"
144
+ )
145
+ if len(node) + len(pending) + count > limits.max_nodes:
146
+ raise ImportValidationError("limit_exceeded", "json.nodes", "node limit exceeded")
147
+ if isinstance(node, dict):
148
+ if any(type(key) is not str for key in node):
149
+ raise ImportValidationError(
150
+ "invalid_json", "json", "object keys must be strings"
151
+ )
152
+ for key in node:
153
+ try:
154
+ key.encode("utf-8")
155
+ except UnicodeError as exc:
156
+ raise ImportValidationError(
157
+ "invalid_json", "json", "strings must be valid UTF-8"
158
+ ) from exc
159
+ pending.extend((item, depth + 1) for item in node.values())
160
+ else:
161
+ pending.extend((item, depth + 1) for item in node)
162
+ elif type(node) is float:
163
+ if not math.isfinite(node):
164
+ raise ImportValidationError("invalid_number", "json", "numbers must be finite")
165
+ elif type(node) is int:
166
+ if (
167
+ node.bit_length() > limits.max_number_chars * 4
168
+ or len(str(node)) > limits.max_number_chars
169
+ ):
170
+ raise ImportValidationError(
171
+ "limit_exceeded", "json.number", "number length exceeded"
172
+ )
173
+ elif type(node) is str:
174
+ try:
175
+ node.encode("utf-8")
176
+ except UnicodeError as exc:
177
+ raise ImportValidationError(
178
+ "invalid_json", "json", "strings must be valid UTF-8"
179
+ ) from exc
180
+ elif node is not None and type(node) not in {str, bool}:
181
+ raise ImportValidationError("invalid_json", "json", "unsupported JSON value")
182
+
183
+
184
+ def parse_json_bytes(data: bytes, limits: ImportLimits = ImportLimits()) -> Any:
185
+ if len(data) > limits.max_json_bytes:
186
+ raise ImportValidationError("limit_exceeded", "json.bytes", "JSON byte limit exceeded")
187
+ _preflight_depth(data, limits.max_depth)
188
+
189
+ def number(text: str, convert: Any) -> Any:
190
+ if len(text) > limits.max_number_chars:
191
+ raise ImportValidationError("limit_exceeded", "json.number", "number length exceeded")
192
+ return convert(text)
193
+
194
+ try:
195
+ value = json.loads(
196
+ data.decode("utf-8"),
197
+ object_pairs_hook=_unique_object,
198
+ parse_constant=_reject_constant,
199
+ parse_int=lambda text: number(text, int),
200
+ parse_float=lambda text: number(text, float),
201
+ )
202
+ except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
203
+ raise ImportValidationError("invalid_json", "json", "invalid UTF-8 JSON document") from exc
204
+ validate_json_tree(value, limits)
205
+ return value
206
+
207
+
208
+ _DEVICE = re.compile(r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)", re.IGNORECASE)
209
+
210
+
211
+ def relative_reference(value: Any, limits: ImportLimits = ImportLimits()) -> str:
212
+ """Require normalized POSIX relative names portable to Windows and Unix."""
213
+
214
+ try:
215
+ length = len(value.encode("utf-8")) if isinstance(value, str) else 0
216
+ except UnicodeError as exc:
217
+ raise ImportValidationError("unsafe_path", "reference", "path must be valid UTF-8") from exc
218
+ if not isinstance(value, str) or not value or length > 4096:
219
+ raise ImportValidationError("unsafe_path", "reference", "must be a bounded relative path")
220
+ if (
221
+ "\\" in value
222
+ or ":" in value
223
+ or any(ord(char) < 32 or char in '<>"|?*' for char in value)
224
+ or PureWindowsPath(value).drive
225
+ or value.startswith("/")
226
+ ):
227
+ raise ImportValidationError(
228
+ "unsafe_path", "reference", "absolute, remote or ambiguous path"
229
+ )
230
+ parts = value.split("/")
231
+ if len(parts) > limits.max_path_depth:
232
+ raise ImportValidationError("limit_exceeded", "reference.depth", "path depth exceeded")
233
+ if any(
234
+ part in {"", ".", ".."} or part.endswith((" ", ".")) or _DEVICE.match(part)
235
+ for part in parts
236
+ ):
237
+ raise ImportValidationError(
238
+ "unsafe_path", "reference", "path is not normalized or portable"
239
+ )
240
+ return value
241
+
242
+
243
+ def safe_artifact_path(
244
+ root: str | Path, reference: str, limits: ImportLimits = ImportLimits()
245
+ ) -> Path:
246
+ reference = relative_reference(reference, limits)
247
+ try:
248
+ base = Path(root).resolve(strict=True)
249
+ if not base.is_dir():
250
+ raise ImportValidationError("invalid_root", "root", "artifact root must be a directory")
251
+ candidate = base
252
+ for part in reference.split("/"):
253
+ candidate = candidate / part
254
+ if indirect_path(candidate):
255
+ raise ImportValidationError(
256
+ "unsafe_path", "reference", "symlinks and junctions are unsupported"
257
+ )
258
+ resolved = candidate.resolve(strict=True)
259
+ if not resolved.is_relative_to(base):
260
+ raise ImportValidationError("unsafe_path", "reference", "path leaves artifact root")
261
+ if not stat.S_ISREG(resolved.stat().st_mode):
262
+ raise ImportValidationError(
263
+ "invalid_artifact", "reference", "artifact must be a regular file"
264
+ )
265
+ return resolved
266
+ except OSError as exc:
267
+ raise ImportValidationError(
268
+ "missing_artifact", "reference", "artifact is unavailable"
269
+ ) from exc
270
+ except RuntimeError as exc:
271
+ raise ImportValidationError(
272
+ "unsafe_path", "reference", "unresolvable artifact path"
273
+ ) from exc
274
+
275
+
276
+ def indirect_path(path: Path) -> bool:
277
+ """Recognize symlinks/reparse entries on Python 3.10 as well as newer APIs."""
278
+ reparse = getattr(path.lstat(), "st_file_attributes", 0) & getattr(
279
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400
280
+ )
281
+ return bool(path.is_symlink() or reparse or getattr(path, "is_junction", lambda: False)())
282
+
283
+
284
+ @dataclass(frozen=True)
285
+ class JsonArtifact:
286
+ reference: str
287
+ artifact_sha256: str
288
+ byte_count: int
289
+ payload: Any
290
+
291
+
292
+ def load_json_artifact(
293
+ root: str | Path,
294
+ reference: str,
295
+ *,
296
+ limits: ImportLimits = ImportLimits(),
297
+ budget: ImportBudget | None = None,
298
+ ) -> JsonArtifact:
299
+ if budget is not None and budget.limits != limits:
300
+ raise ImportValidationError(
301
+ "invalid_limit", "budget", "budget and parser limits must match"
302
+ )
303
+ path = safe_artifact_path(root, reference, limits)
304
+ bound = limits.max_json_bytes
305
+ if budget is not None:
306
+ if budget.records >= limits.max_records:
307
+ raise ImportValidationError("limit_exceeded", "records", "import budget exceeded")
308
+ bound = min(bound, limits.max_total_bytes - budget.total_bytes)
309
+ try:
310
+ with path.open("rb") as stream:
311
+ data = stream.read(bound + 1)
312
+ # Recheck static references before accepting data. This is not an OS
313
+ # sandbox or an atomic defense against concurrent filesystem mutation.
314
+ if safe_artifact_path(root, reference, limits) != path:
315
+ raise ImportValidationError(
316
+ "unsafe_path", "reference", "artifact path changed while reading"
317
+ )
318
+ except OSError as exc:
319
+ raise ImportValidationError(
320
+ "invalid_artifact", "reference", "artifact could not be read"
321
+ ) from exc
322
+ if budget is not None:
323
+ budget.consume(total_bytes=len(data), records=1)
324
+ digest = sha256(data).hexdigest()
325
+ try:
326
+ payload = parse_json_bytes(data, limits)
327
+ except ImportValidationError as exc:
328
+ if exc.code != "limit_exceeded":
329
+ exc.artifact_reference = reference
330
+ exc.artifact_sha256 = digest
331
+ exc.byte_count = len(data)
332
+ raise
333
+ return JsonArtifact(reference, digest, len(data), payload)
@@ -0,0 +1,110 @@
1
+ """Create interventions from project-scoped stored evidence without re-diagnosis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import fields
7
+ from typing import Any
8
+
9
+ from agentloop.interventions import (
10
+ InterventionReferenceError,
11
+ InterventionValidationError,
12
+ build_intervention,
13
+ )
14
+ from agentloop.quality import build_quality_report, validate_quality_fixtures
15
+ from agentloop.replay import ReplayGates
16
+
17
+
18
+ def parse_replay_gates(value: Any) -> ReplayGates:
19
+ if not isinstance(value, dict):
20
+ raise InterventionValidationError("gates must be an object")
21
+ defaults = ReplayGates()
22
+ names = {field.name for field in fields(defaults)}
23
+ if set(value) - names:
24
+ raise InterventionValidationError("unknown replay gate option")
25
+ for key, item in value.items():
26
+ if key.startswith("require_"):
27
+ if type(item) is not bool:
28
+ raise InterventionValidationError(f"{key} must be a boolean")
29
+ elif item is None and key == "min_quality_score":
30
+ continue
31
+ elif (
32
+ isinstance(item, bool)
33
+ or not isinstance(item, int | float)
34
+ or not math.isfinite(item)
35
+ or item < 0
36
+ ):
37
+ raise InterventionValidationError(f"{key} must be a finite nonnegative number")
38
+ elif key == "min_quality_score" and item > 1:
39
+ raise InterventionValidationError("min_quality_score must be between 0 and 1")
40
+ return ReplayGates(**value)
41
+
42
+
43
+ def create_stored_intervention(
44
+ db: Any, request: dict[str, Any], project_id: str = "default"
45
+ ) -> dict[str, Any]:
46
+ allowed = {
47
+ "baseline_run_id",
48
+ "candidate_run_id",
49
+ "target_finding_ids",
50
+ "intervention_type",
51
+ "configuration",
52
+ "metadata",
53
+ "gates",
54
+ "quality_fixtures",
55
+ }
56
+ if not isinstance(request, dict) or set(request) - allowed:
57
+ raise InterventionValidationError("invalid intervention request fields")
58
+ for key in ("baseline_run_id", "candidate_run_id", "intervention_type"):
59
+ if not isinstance(request.get(key), str) or not request[key].strip():
60
+ raise InterventionValidationError(f"{key} must be a nonempty string")
61
+ targets = request.get("target_finding_ids")
62
+ if (
63
+ not isinstance(targets, list)
64
+ or not targets
65
+ or any(not isinstance(item, str) or not item for item in targets)
66
+ ):
67
+ raise InterventionValidationError("target_finding_ids must contain at least one ID")
68
+ gates = parse_replay_gates(request.get("gates", {}))
69
+ baseline = db.get_trace(request["baseline_run_id"], project_id=project_id)
70
+ candidate = db.get_trace(request["candidate_run_id"], project_id=project_id)
71
+ if baseline is None or candidate is None:
72
+ raise InterventionReferenceError("baseline or candidate trace not found")
73
+ snapshots = [
74
+ db.get_finding_snapshot(baseline.run_id, target, project_id=project_id)
75
+ for target in sorted(set(targets))
76
+ ]
77
+ if any(snapshot is None for snapshot in snapshots):
78
+ raise InterventionReferenceError("target baseline finding not found")
79
+ fixtures = request.get("quality_fixtures")
80
+ if fixtures is not None:
81
+ validate_quality_fixtures(fixtures)
82
+ if any(
83
+ str(fixture.get("scorer", {}).get("type", "")).strip().lower() == "custom"
84
+ for fixture in fixtures
85
+ ):
86
+ raise InterventionValidationError(
87
+ "custom Python scorers are not accepted for stored intervention requests"
88
+ )
89
+ quality = (
90
+ None
91
+ if fixtures is None
92
+ else build_quality_report(
93
+ fixtures,
94
+ baseline_trace=baseline,
95
+ candidate_trace=candidate,
96
+ min_score=gates.min_quality_score,
97
+ )
98
+ )
99
+ record = build_intervention(
100
+ baseline,
101
+ candidate,
102
+ target_finding_ids=targets,
103
+ intervention_type=request["intervention_type"],
104
+ configuration=request.get("configuration", {}),
105
+ metadata=request.get("metadata", {}),
106
+ diagnosis={"run_id": baseline.run_id, "findings": snapshots},
107
+ gates=gates,
108
+ quality_report=quality,
109
+ )
110
+ return db.save_intervention(record, project_id=project_id)
@@ -0,0 +1,118 @@
1
+ """Shared SQLite/Postgres intervention contract; dialect differences stay local."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from agentloop.interventions import (
9
+ InterventionConflictError,
10
+ InterventionRecord,
11
+ InterventionReferenceError,
12
+ canonical_json,
13
+ trace_fingerprint,
14
+ )
15
+ from agentloop.tracer import AgentTrace
16
+
17
+
18
+ class InterventionStoreMixin:
19
+ _postgres_interventions = False
20
+
21
+ def _intervention_sql(self, sql: str) -> str:
22
+ return sql.replace("?", "%s") if self._postgres_interventions else sql
23
+
24
+ def get_finding_snapshot(
25
+ self, run_id: str, finding_id: str, project_id: str = "default"
26
+ ) -> dict[str, Any] | None:
27
+ self.init()
28
+ with self._connect() as conn:
29
+ row = conn.execute(
30
+ self._intervention_sql(
31
+ "SELECT payload_json FROM trace_findings WHERE project_id = ? AND run_id = ? AND finding_id = ?"
32
+ ),
33
+ (project_id, run_id, finding_id),
34
+ ).fetchone()
35
+ if row is None:
36
+ return None
37
+ return json.loads(row[0]) if isinstance(row[0], str) else row[0]
38
+
39
+ def get_intervention(
40
+ self, intervention_id: str, project_id: str = "default"
41
+ ) -> dict[str, Any] | None:
42
+ self.init()
43
+ with self._connect() as conn:
44
+ row = conn.execute(
45
+ self._intervention_sql(
46
+ "SELECT payload_json FROM interventions WHERE project_id = ? AND intervention_id = ?"
47
+ ),
48
+ (project_id, intervention_id),
49
+ ).fetchone()
50
+ return None if row is None else json.loads(row[0])
51
+
52
+ def save_intervention(
53
+ self, record: InterventionRecord | dict[str, Any], project_id: str = "default"
54
+ ) -> dict[str, Any]:
55
+ validated = InterventionRecord.from_dict(
56
+ record.to_dict() if isinstance(record, InterventionRecord) else record
57
+ )
58
+ payload = validated.to_dict()
59
+ serialized = canonical_json(payload)
60
+ self.init()
61
+ with self._connect() as conn:
62
+ if not self._postgres_interventions:
63
+ conn.execute("BEGIN IMMEDIATE")
64
+ lookup = self._intervention_sql(
65
+ "SELECT payload_json FROM interventions WHERE project_id = ? AND intervention_id = ?"
66
+ )
67
+ identity = (project_id, validated.intervention_id)
68
+ existing = conn.execute(lookup, identity).fetchone()
69
+ if existing is not None:
70
+ if canonical_json(json.loads(existing[0])) != serialized:
71
+ raise InterventionConflictError(
72
+ "intervention identity already has different evidence"
73
+ )
74
+ return json.loads(existing[0])
75
+ for condition in ("baseline", "candidate"):
76
+ query = "SELECT payload_json FROM traces WHERE project_id = ? AND run_id = ?"
77
+ if self._postgres_interventions:
78
+ query += " FOR SHARE"
79
+ row = conn.execute(
80
+ self._intervention_sql(query), (project_id, payload[f"{condition}_run_id"])
81
+ ).fetchone()
82
+ if row is None:
83
+ raise InterventionReferenceError("baseline or candidate trace not found")
84
+ native = json.loads(row[0]) if isinstance(row[0], str) else row[0]
85
+ if (
86
+ trace_fingerprint(AgentTrace.from_dict(native))
87
+ != payload["trace_fingerprints"][condition]
88
+ ):
89
+ raise InterventionConflictError(
90
+ "stored trace differs from the intervention's source trace"
91
+ )
92
+ for finding_id in payload["target_finding_ids"]:
93
+ query = "SELECT finding_id FROM trace_findings WHERE project_id = ? AND run_id = ? AND finding_id = ?"
94
+ if self._postgres_interventions:
95
+ query += " FOR SHARE"
96
+ if (
97
+ conn.execute(
98
+ self._intervention_sql(query),
99
+ (project_id, payload["baseline_run_id"], finding_id),
100
+ ).fetchone()
101
+ is None
102
+ ):
103
+ raise InterventionReferenceError("target baseline finding not found")
104
+ conn.execute(
105
+ self._intervention_sql(
106
+ "INSERT INTO interventions(project_id, intervention_id, baseline_run_id, candidate_run_id, payload_json) "
107
+ "VALUES (?, ?, ?, ?, ?) ON CONFLICT(project_id, intervention_id) DO NOTHING"
108
+ ),
109
+ (*identity, payload["baseline_run_id"], payload["candidate_run_id"], serialized),
110
+ )
111
+ # Concurrent Postgres writers may reach INSERT together. Only an
112
+ # identical winner satisfies the retry contract.
113
+ stored = conn.execute(lookup, identity).fetchone()
114
+ if canonical_json(json.loads(stored[0])) != serialized:
115
+ raise InterventionConflictError(
116
+ "intervention identity already has different evidence"
117
+ )
118
+ return json.loads(stored[0])