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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- 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])
|