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
agentloop/client.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from agentloop.tracer import AgentTrace
|
|
13
|
+
|
|
14
|
+
CredentialScope = Literal["project", "admin"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentLoopClientError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AgentLoopClient:
|
|
23
|
+
"""Dependency-free API v1 client; base_url is the origin or mount path, without /v1."""
|
|
24
|
+
|
|
25
|
+
base_url: str = "http://127.0.0.1:8000"
|
|
26
|
+
api_key: str | None = None
|
|
27
|
+
admin_api_key: str | None = None
|
|
28
|
+
timeout_s: float = 10.0
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_env(cls) -> "AgentLoopClient":
|
|
32
|
+
return cls(
|
|
33
|
+
base_url=os.getenv("AGENTLOOP_API_URL", "http://127.0.0.1:8000"),
|
|
34
|
+
api_key=os.getenv("AGENTLOOP_API_KEY"),
|
|
35
|
+
admin_api_key=os.getenv("AGENTLOOP_ADMIN_API_KEY"),
|
|
36
|
+
timeout_s=float(os.getenv("AGENTLOOP_TIMEOUT_S", "10")),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def health(self) -> dict[str, Any]:
|
|
40
|
+
return self._request("GET", "/health")
|
|
41
|
+
|
|
42
|
+
def create_api_key(self, project_id: str = "default", name: str = "default") -> dict[str, Any]:
|
|
43
|
+
return self._request(
|
|
44
|
+
"POST",
|
|
45
|
+
"/api-keys",
|
|
46
|
+
{"project_id": project_id, "name": name},
|
|
47
|
+
credential_scope="admin",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def upload_trace(self, trace: AgentTrace | str | Path | dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
if isinstance(trace, AgentTrace):
|
|
52
|
+
payload = trace.to_dict()
|
|
53
|
+
elif isinstance(trace, dict):
|
|
54
|
+
payload = trace
|
|
55
|
+
else:
|
|
56
|
+
payload = json.loads(Path(trace).read_text(encoding="utf-8"))
|
|
57
|
+
return self._request("POST", "/traces", payload)
|
|
58
|
+
|
|
59
|
+
def list_traces(
|
|
60
|
+
self,
|
|
61
|
+
project_id: str | None = None,
|
|
62
|
+
*,
|
|
63
|
+
page_size: int | None = None,
|
|
64
|
+
cursor: str | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
params: dict[str, Any] = {}
|
|
67
|
+
if project_id:
|
|
68
|
+
params["project_id"] = project_id
|
|
69
|
+
if page_size is not None:
|
|
70
|
+
params["page_size"] = page_size
|
|
71
|
+
if cursor:
|
|
72
|
+
params["cursor"] = cursor
|
|
73
|
+
route = "/traces"
|
|
74
|
+
if params:
|
|
75
|
+
route += "?" + urllib.parse.urlencode(params)
|
|
76
|
+
return self._request("GET", route)
|
|
77
|
+
|
|
78
|
+
def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
|
|
79
|
+
route = "/usage"
|
|
80
|
+
if project_id:
|
|
81
|
+
route += "?" + urllib.parse.urlencode({"project_id": project_id})
|
|
82
|
+
return self._request("GET", route)
|
|
83
|
+
|
|
84
|
+
def get_report(self, run_id: str) -> dict[str, Any]:
|
|
85
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
86
|
+
return self._request("GET", f"/traces/{encoded_run_id}/report")
|
|
87
|
+
|
|
88
|
+
def get_optimization_plan(self, run_id: str) -> dict[str, Any]:
|
|
89
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
90
|
+
return self._request("GET", f"/traces/{encoded_run_id}/optimization")
|
|
91
|
+
|
|
92
|
+
def get_diagnosis(self, run_id: str) -> dict[str, Any]:
|
|
93
|
+
"""Compute a diagnosis without changing the server's stored findings."""
|
|
94
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
95
|
+
return self._request("GET", f"/traces/{encoded_run_id}/diagnosis")
|
|
96
|
+
|
|
97
|
+
def save_diagnosis(self, run_id: str) -> dict[str, Any]:
|
|
98
|
+
"""Recompute and persist a stored trace's diagnosis on the server."""
|
|
99
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
100
|
+
return self._request("POST", f"/traces/{encoded_run_id}/diagnosis")
|
|
101
|
+
|
|
102
|
+
def list_findings(
|
|
103
|
+
self,
|
|
104
|
+
project_id: str | None = None,
|
|
105
|
+
status: str | None = None,
|
|
106
|
+
*,
|
|
107
|
+
page_size: int | None = None,
|
|
108
|
+
cursor: str | None = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
params: dict[str, Any] = {}
|
|
111
|
+
if project_id:
|
|
112
|
+
params["project_id"] = project_id
|
|
113
|
+
if status:
|
|
114
|
+
params["status"] = status
|
|
115
|
+
if page_size is not None:
|
|
116
|
+
params["page_size"] = page_size
|
|
117
|
+
if cursor:
|
|
118
|
+
params["cursor"] = cursor
|
|
119
|
+
route = "/findings"
|
|
120
|
+
if params:
|
|
121
|
+
route += "?" + urllib.parse.urlencode(params)
|
|
122
|
+
return self._request("GET", route)
|
|
123
|
+
|
|
124
|
+
def update_finding_status(self, run_id: str, finding_id: str, status: str) -> dict[str, Any]:
|
|
125
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
126
|
+
encoded_finding_id = urllib.parse.quote(finding_id, safe="")
|
|
127
|
+
return self._request(
|
|
128
|
+
"POST", f"/findings/{encoded_run_id}/{encoded_finding_id}/status", {"status": status}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def optimization_queue(self, project_id: str | None = None) -> dict[str, Any]:
|
|
132
|
+
route = "/optimization-queue"
|
|
133
|
+
if project_id:
|
|
134
|
+
route += "?" + urllib.parse.urlencode({"project_id": project_id})
|
|
135
|
+
return self._request("GET", route)
|
|
136
|
+
|
|
137
|
+
def github_issue_drafts(self, project_id: str | None = None, limit: int = 5) -> dict[str, Any]:
|
|
138
|
+
params: dict[str, Any] = {"limit": limit}
|
|
139
|
+
if project_id:
|
|
140
|
+
params["project_id"] = project_id
|
|
141
|
+
return self._request(
|
|
142
|
+
"GET", "/optimization-queue/github-issues?" + urllib.parse.urlencode(params)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def build_quality_report(
|
|
146
|
+
self,
|
|
147
|
+
fixtures: list[dict[str, Any]],
|
|
148
|
+
*,
|
|
149
|
+
baseline_run_id: str | None = None,
|
|
150
|
+
candidate_run_id: str | None = None,
|
|
151
|
+
min_score: float | None = None,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
return self._request(
|
|
154
|
+
"POST",
|
|
155
|
+
"/quality-reports",
|
|
156
|
+
{
|
|
157
|
+
"fixtures": fixtures,
|
|
158
|
+
"baseline_run_id": baseline_run_id,
|
|
159
|
+
"candidate_run_id": candidate_run_id,
|
|
160
|
+
"min_score": min_score,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def get_value_report(
|
|
165
|
+
self,
|
|
166
|
+
run_id: str,
|
|
167
|
+
*,
|
|
168
|
+
runs_per_month: int | None = None,
|
|
169
|
+
engineer_hourly_rate_usd: float | None = None,
|
|
170
|
+
incident_cost_usd: float | None = None,
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
params: dict[str, Any] = {}
|
|
173
|
+
if runs_per_month is not None:
|
|
174
|
+
params["runs_per_month"] = runs_per_month
|
|
175
|
+
if engineer_hourly_rate_usd is not None:
|
|
176
|
+
params["engineer_hourly_rate_usd"] = engineer_hourly_rate_usd
|
|
177
|
+
if incident_cost_usd is not None:
|
|
178
|
+
params["incident_cost_usd"] = incident_cost_usd
|
|
179
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
180
|
+
route = f"/traces/{encoded_run_id}/value"
|
|
181
|
+
if params:
|
|
182
|
+
route += "?" + urllib.parse.urlencode(params)
|
|
183
|
+
return self._request("GET", route)
|
|
184
|
+
|
|
185
|
+
def create_intervention(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
186
|
+
return self._request("POST", "/interventions", request)
|
|
187
|
+
|
|
188
|
+
def get_intervention(self, intervention_id: str) -> dict[str, Any]:
|
|
189
|
+
encoded_id = urllib.parse.quote(intervention_id, safe="")
|
|
190
|
+
return self._request("GET", f"/interventions/{encoded_id}")
|
|
191
|
+
|
|
192
|
+
def _request(
|
|
193
|
+
self,
|
|
194
|
+
method: str,
|
|
195
|
+
path: str,
|
|
196
|
+
payload: dict[str, Any] | None = None,
|
|
197
|
+
*,
|
|
198
|
+
credential_scope: CredentialScope = "project",
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
parsed_base_url = urllib.parse.urlsplit(self.base_url)
|
|
201
|
+
if parsed_base_url.scheme not in {"http", "https"} or not parsed_base_url.netloc:
|
|
202
|
+
raise AgentLoopClientError("AgentLoop API URL must use http:// or https://")
|
|
203
|
+
url = self.base_url.rstrip("/") + "/v1" + path
|
|
204
|
+
body = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
205
|
+
headers = {"Accept": "application/json"}
|
|
206
|
+
if payload is not None:
|
|
207
|
+
headers["Content-Type"] = "application/json"
|
|
208
|
+
if credential_scope == "project":
|
|
209
|
+
if self.api_key:
|
|
210
|
+
headers["X-AgentLoop-Key"] = self.api_key
|
|
211
|
+
elif credential_scope == "admin":
|
|
212
|
+
if self.admin_api_key:
|
|
213
|
+
headers["X-AgentLoop-Admin-Key"] = self.admin_api_key
|
|
214
|
+
else:
|
|
215
|
+
raise AgentLoopClientError(f"Unsupported credential scope: {credential_scope}")
|
|
216
|
+
req = urllib.request.Request(url=url, data=body, method=method, headers=headers)
|
|
217
|
+
try:
|
|
218
|
+
with urllib.request.urlopen( # nosec B310 - base URL scheme is validated above.
|
|
219
|
+
req, timeout=self.timeout_s
|
|
220
|
+
) as response:
|
|
221
|
+
return json.loads(response.read().decode("utf-8"))
|
|
222
|
+
except urllib.error.HTTPError as exc:
|
|
223
|
+
detail = exc.read().decode("utf-8", errors="ignore")
|
|
224
|
+
raise AgentLoopClientError(f"AgentLoop API returned {exc.code}: {detail}") from exc
|
|
225
|
+
except urllib.error.URLError as exc:
|
|
226
|
+
raise AgentLoopClientError(
|
|
227
|
+
f"Could not reach AgentLoop API at {url}: {exc.reason}"
|
|
228
|
+
) from exc
|
agentloop/completion.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Single-use, bounded before-finish verification on a shared harness run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from asyncio import CancelledError
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from threading import Event, RLock
|
|
10
|
+
from time import monotonic
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from agentloop.completion_types import (
|
|
14
|
+
COMPLETION_KEY,
|
|
15
|
+
COMPLETION_VERSION,
|
|
16
|
+
CheckResult,
|
|
17
|
+
CompletionCandidate,
|
|
18
|
+
CompletionConfig,
|
|
19
|
+
CompletionResult,
|
|
20
|
+
RepairBackend,
|
|
21
|
+
VerificationContext,
|
|
22
|
+
VerificationFeedback,
|
|
23
|
+
)
|
|
24
|
+
from agentloop.context_types import fingerprint
|
|
25
|
+
from agentloop.harness import (
|
|
26
|
+
Decision,
|
|
27
|
+
HarnessControlError,
|
|
28
|
+
HarnessDeniedError,
|
|
29
|
+
HarnessRun,
|
|
30
|
+
Hook,
|
|
31
|
+
Policy,
|
|
32
|
+
)
|
|
33
|
+
from agentloop.tracer import current_trace
|
|
34
|
+
|
|
35
|
+
_ACTIVE = ContextVar("agentloop_completion", default=None)
|
|
36
|
+
_EVIDENCE_LOCK = RLock()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class _CompletionAdmission:
|
|
41
|
+
config: CompletionConfig
|
|
42
|
+
|
|
43
|
+
def __call__(self, context):
|
|
44
|
+
if context.branch_id != self.config.branch_id:
|
|
45
|
+
return Decision("continue", "completion_not_selected")
|
|
46
|
+
active = _ACTIVE.get()
|
|
47
|
+
if (
|
|
48
|
+
active is None
|
|
49
|
+
or active["used"]
|
|
50
|
+
or active["run"] != context.run_id
|
|
51
|
+
or active["config"] is not self.config
|
|
52
|
+
):
|
|
53
|
+
return Decision("deny", "completion_adapter_required")
|
|
54
|
+
active["used"] = True
|
|
55
|
+
active["record"]["harness_call_id"] = context.call_id
|
|
56
|
+
active["record"]["decision_id"] = context.decision_id
|
|
57
|
+
return Decision(active["action"], active["reason"], (active["reference"],))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def completion_policy(config):
|
|
61
|
+
if type(config) is not CompletionConfig:
|
|
62
|
+
raise ValueError("completion policy requires CompletionConfig")
|
|
63
|
+
return Policy(
|
|
64
|
+
config.policy_id,
|
|
65
|
+
config.version,
|
|
66
|
+
_CompletionAdmission(config),
|
|
67
|
+
hooks=frozenset({Hook("completion")}),
|
|
68
|
+
actions=frozenset({"continue", "deny", "stop", "escalate"}),
|
|
69
|
+
priority=50,
|
|
70
|
+
configuration=config.to_dict(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CompletionGate:
|
|
75
|
+
"""Verify once before the host releases a final marker; never retract a stream.
|
|
76
|
+
|
|
77
|
+
Deadlines are cooperative. Trusted callbacks must use the provided remaining
|
|
78
|
+
time for their I/O or subprocess timeout. Late results cannot pass.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, run, *, config, repair=None):
|
|
82
|
+
if not isinstance(run, HarnessRun) or type(config) is not CompletionConfig:
|
|
83
|
+
raise ValueError("typed run and completion configuration required")
|
|
84
|
+
if run.harness.config.mode != "disabled" and not any(
|
|
85
|
+
type(policy.evaluate) is _CompletionAdmission and policy.evaluate.config is config
|
|
86
|
+
for policy in run.harness.config.policies
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("register matching completion_policy before binding")
|
|
89
|
+
needed = {
|
|
90
|
+
Hook("completion"),
|
|
91
|
+
Hook("completion", "after"),
|
|
92
|
+
Hook("tool"),
|
|
93
|
+
Hook("tool", "after"),
|
|
94
|
+
}
|
|
95
|
+
if repair is not None:
|
|
96
|
+
if type(repair) is not RepairBackend:
|
|
97
|
+
raise ValueError("repair must be RepairBackend")
|
|
98
|
+
needed.update({Hook(repair.boundary), Hook(repair.boundary, "after")})
|
|
99
|
+
if (
|
|
100
|
+
"sync" not in run.harness.capabilities.execution_kinds
|
|
101
|
+
or not needed <= run.harness.capabilities.hooks
|
|
102
|
+
):
|
|
103
|
+
raise ValueError("unsupported completion lifecycle")
|
|
104
|
+
if bool(config.max_repairs) != (repair is not None):
|
|
105
|
+
raise ValueError("bounded repair configuration and backend must be supplied together")
|
|
106
|
+
self._run, self._config, self._repair = run, config, repair
|
|
107
|
+
self._lock, self._used, self._records = RLock(), False, {}
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def run(self):
|
|
111
|
+
return self._run
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def config(self):
|
|
115
|
+
return self._config
|
|
116
|
+
|
|
117
|
+
def export_evidence(self):
|
|
118
|
+
with self._lock:
|
|
119
|
+
return {"schema_version": COMPLETION_VERSION, "records": deepcopy(self._records)}
|
|
120
|
+
|
|
121
|
+
def complete(self, candidate, *, cancel_event=None):
|
|
122
|
+
if type(candidate) is not CompletionCandidate:
|
|
123
|
+
raise ValueError("candidate must be CompletionCandidate")
|
|
124
|
+
if cancel_event is not None and not isinstance(cancel_event, Event):
|
|
125
|
+
raise ValueError("cancel_event must be threading.Event")
|
|
126
|
+
with self._lock:
|
|
127
|
+
if self._used:
|
|
128
|
+
raise ValueError("completion gate is single-use")
|
|
129
|
+
self._used = True
|
|
130
|
+
mode = self.run.harness.config.mode
|
|
131
|
+
if mode == "disabled":
|
|
132
|
+
return CompletionResult("unverified", candidate, False, 0)
|
|
133
|
+
identity = "completion_" + uuid4().hex
|
|
134
|
+
trace = current_trace()
|
|
135
|
+
receipt = {
|
|
136
|
+
"completion_id": identity,
|
|
137
|
+
"mode": mode,
|
|
138
|
+
"policy_config_hash": completion_policy(self.config).config_hash,
|
|
139
|
+
"attempts": [],
|
|
140
|
+
"repairs": [],
|
|
141
|
+
"status": "running",
|
|
142
|
+
"independent_task_evaluation": False,
|
|
143
|
+
"already_streamed": candidate.already_streamed,
|
|
144
|
+
}
|
|
145
|
+
envelope = None
|
|
146
|
+
if trace is not None:
|
|
147
|
+
with _EVIDENCE_LOCK:
|
|
148
|
+
envelope = trace.metadata.setdefault(
|
|
149
|
+
COMPLETION_KEY, {"schema_version": COMPLETION_VERSION, "records": {}}
|
|
150
|
+
)
|
|
151
|
+
if (
|
|
152
|
+
not isinstance(envelope, dict)
|
|
153
|
+
or envelope.get("schema_version") != COMPLETION_VERSION
|
|
154
|
+
or not isinstance(envelope.get("records"), dict)
|
|
155
|
+
or len(envelope["records"]) >= 10000
|
|
156
|
+
):
|
|
157
|
+
raise ValueError("invalid completion trace evidence")
|
|
158
|
+
envelope["records"][identity] = deepcopy(receipt)
|
|
159
|
+
deadline = monotonic() + self.config.total_timeout_s
|
|
160
|
+
verification_remaining = self.config.verification_timeout_s
|
|
161
|
+
repairs = 0
|
|
162
|
+
failure = None
|
|
163
|
+
try:
|
|
164
|
+
while True:
|
|
165
|
+
self._cancel(cancel_event)
|
|
166
|
+
attempt = {
|
|
167
|
+
"index": len(receipt["attempts"]),
|
|
168
|
+
"candidate_hash": fingerprint(candidate.value),
|
|
169
|
+
"present": candidate.present,
|
|
170
|
+
"already_streamed": candidate.already_streamed,
|
|
171
|
+
"checks": [],
|
|
172
|
+
"harness_call_id": None,
|
|
173
|
+
"decision_id": None,
|
|
174
|
+
}
|
|
175
|
+
receipt["attempts"].append(attempt)
|
|
176
|
+
feedback = []
|
|
177
|
+
if not candidate.present:
|
|
178
|
+
reason = "completion_missing"
|
|
179
|
+
elif candidate.already_streamed:
|
|
180
|
+
reason = "completion_already_streamed"
|
|
181
|
+
elif monotonic() >= deadline or verification_remaining <= 0:
|
|
182
|
+
reason = "completion_timeout"
|
|
183
|
+
else:
|
|
184
|
+
started = monotonic()
|
|
185
|
+
context = VerificationContext(
|
|
186
|
+
min(deadline, started + verification_remaining), cancel_event
|
|
187
|
+
)
|
|
188
|
+
for check_index, check in enumerate(self.config.checks):
|
|
189
|
+
self._cancel(cancel_event)
|
|
190
|
+
branch = f"{identity}:check:{attempt['index']}:{check_index}"
|
|
191
|
+
item = {**check.declaration(), "status": "pending", "branch_id": branch}
|
|
192
|
+
attempt["checks"].append(item)
|
|
193
|
+
if context.remaining_s <= 0:
|
|
194
|
+
item["status"] = "timeout"
|
|
195
|
+
feedback.append(VerificationFeedback(check.check_id, "timeout"))
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
def invoke_check(candidate, context, check=check):
|
|
199
|
+
self._cancel(cancel_event)
|
|
200
|
+
if context.remaining_s <= 0:
|
|
201
|
+
raise TimeoutError("verification admission deadline expired")
|
|
202
|
+
return check.invoke(candidate, context)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
result = self.run.wrap(
|
|
206
|
+
invoke_check,
|
|
207
|
+
boundary="tool",
|
|
208
|
+
branch_id=branch,
|
|
209
|
+
dispatch=check.dispatch,
|
|
210
|
+
usage_reader=check.usage_reader,
|
|
211
|
+
error_usage_reader=check.error_usage_reader,
|
|
212
|
+
)(candidate, context)
|
|
213
|
+
except HarnessControlError:
|
|
214
|
+
item["status"] = "blocked"
|
|
215
|
+
raise
|
|
216
|
+
except Exception:
|
|
217
|
+
item["status"] = "error"
|
|
218
|
+
result = None
|
|
219
|
+
except BaseException:
|
|
220
|
+
item["status"] = "cancelled"
|
|
221
|
+
raise
|
|
222
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
223
|
+
item["status"] = "cancelled"
|
|
224
|
+
self._cancel(cancel_event)
|
|
225
|
+
detail = ""
|
|
226
|
+
if context.remaining_s <= 0:
|
|
227
|
+
item["status"] = "timeout"
|
|
228
|
+
elif item["status"] == "pending":
|
|
229
|
+
if type(result) is not CheckResult:
|
|
230
|
+
item["status"] = "invalid"
|
|
231
|
+
else:
|
|
232
|
+
item["status"] = "passed" if result.passed else "failed"
|
|
233
|
+
detail = result.feedback
|
|
234
|
+
item["feedback_hash"] = fingerprint(detail)
|
|
235
|
+
if item["status"] != "passed":
|
|
236
|
+
feedback.append(
|
|
237
|
+
VerificationFeedback(check.check_id, item["status"], detail)
|
|
238
|
+
)
|
|
239
|
+
verification_remaining -= monotonic() - started
|
|
240
|
+
reason = "completion_verified" if not feedback else "completion_checks_failed"
|
|
241
|
+
passed = reason == "completion_verified"
|
|
242
|
+
if not passed and not feedback:
|
|
243
|
+
feedback.append(VerificationFeedback("completion", reason))
|
|
244
|
+
can_repair = (
|
|
245
|
+
not passed
|
|
246
|
+
and reason != "completion_already_streamed"
|
|
247
|
+
and repairs < self.config.max_repairs
|
|
248
|
+
and monotonic() < deadline
|
|
249
|
+
and verification_remaining > 0
|
|
250
|
+
)
|
|
251
|
+
action = "continue" if passed else "deny" if can_repair else self.config.on_failure
|
|
252
|
+
attempt.update(action=action, reason=reason, repair_requested=can_repair)
|
|
253
|
+
binding = {
|
|
254
|
+
"used": False,
|
|
255
|
+
"run": self.run.run_id,
|
|
256
|
+
"config": self.config,
|
|
257
|
+
"record": attempt,
|
|
258
|
+
"action": action,
|
|
259
|
+
"reason": reason,
|
|
260
|
+
"reference": f"completion:{identity}:attempt:{attempt['index']}",
|
|
261
|
+
}
|
|
262
|
+
token = _ACTIVE.set(binding)
|
|
263
|
+
try:
|
|
264
|
+
self.run.wrap(
|
|
265
|
+
lambda: None, boundary="completion", branch_id=self.config.branch_id
|
|
266
|
+
)()
|
|
267
|
+
except HarnessDeniedError as exc:
|
|
268
|
+
if not can_repair or any(
|
|
269
|
+
proposal.decision.action != "continue"
|
|
270
|
+
and proposal.policy_id != self.config.policy_id
|
|
271
|
+
for proposal in exc.result.proposals
|
|
272
|
+
):
|
|
273
|
+
raise
|
|
274
|
+
finally:
|
|
275
|
+
_ACTIVE.reset(token)
|
|
276
|
+
if mode == "shadow":
|
|
277
|
+
receipt["status"] = "shadow"
|
|
278
|
+
return CompletionResult("unverified", candidate, False, 0)
|
|
279
|
+
if passed:
|
|
280
|
+
self._cancel(cancel_event)
|
|
281
|
+
if monotonic() >= deadline:
|
|
282
|
+
raise TimeoutError("completion deadline expired before release")
|
|
283
|
+
receipt["status"] = "accepted"
|
|
284
|
+
return CompletionResult("accepted", candidate, True, repairs)
|
|
285
|
+
self._cancel(cancel_event)
|
|
286
|
+
if monotonic() >= deadline:
|
|
287
|
+
raise TimeoutError("completion deadline expired before repair")
|
|
288
|
+
branch = f"{identity}:repair:{repairs + 1}"
|
|
289
|
+
repair_record = {"index": repairs + 1, "status": "pending", "branch_id": branch}
|
|
290
|
+
receipt["repairs"].append(repair_record)
|
|
291
|
+
backend = self._repair
|
|
292
|
+
repairs += 1
|
|
293
|
+
|
|
294
|
+
def invoke_repair(candidate, feedback, context):
|
|
295
|
+
self._cancel(cancel_event)
|
|
296
|
+
if context.remaining_s <= 0:
|
|
297
|
+
raise TimeoutError("repair admission deadline expired")
|
|
298
|
+
return backend.invoke(candidate, feedback, context)
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
candidate = self.run.wrap(
|
|
302
|
+
invoke_repair,
|
|
303
|
+
boundary=backend.boundary,
|
|
304
|
+
branch_id=branch,
|
|
305
|
+
dispatch=replace(backend.dispatch, retry_source="harness"),
|
|
306
|
+
usage_reader=backend.usage_reader,
|
|
307
|
+
error_usage_reader=backend.error_usage_reader,
|
|
308
|
+
)(candidate, tuple(feedback), VerificationContext(deadline, cancel_event))
|
|
309
|
+
if type(candidate) is not CompletionCandidate:
|
|
310
|
+
raise ValueError("repair must return CompletionCandidate")
|
|
311
|
+
repair_record["status"] = "returned"
|
|
312
|
+
except BaseException:
|
|
313
|
+
repair_record["status"] = "failed"
|
|
314
|
+
raise
|
|
315
|
+
except BaseException as exc:
|
|
316
|
+
failure = exc
|
|
317
|
+
receipt["status"] = (
|
|
318
|
+
"blocked"
|
|
319
|
+
if isinstance(exc, HarnessControlError)
|
|
320
|
+
else "cancelled"
|
|
321
|
+
if not isinstance(exc, Exception)
|
|
322
|
+
else "error"
|
|
323
|
+
)
|
|
324
|
+
raise
|
|
325
|
+
finally:
|
|
326
|
+
with self._lock, _EVIDENCE_LOCK:
|
|
327
|
+
self._records[identity] = deepcopy(receipt)
|
|
328
|
+
if trace is not None:
|
|
329
|
+
if (
|
|
330
|
+
trace.metadata.get(COMPLETION_KEY) is not envelope
|
|
331
|
+
or envelope.get("schema_version") != COMPLETION_VERSION
|
|
332
|
+
or not isinstance(envelope.get("records"), dict)
|
|
333
|
+
):
|
|
334
|
+
receipt["capture_error"] = "invalid_trace_evidence"
|
|
335
|
+
receipt["status"] = "evidence_error"
|
|
336
|
+
self._records[identity] = deepcopy(receipt)
|
|
337
|
+
if failure is None:
|
|
338
|
+
raise ValueError("invalid completion trace evidence")
|
|
339
|
+
else:
|
|
340
|
+
envelope["records"][identity] = deepcopy(receipt)
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _cancel(cancel_event):
|
|
344
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
345
|
+
raise CancelledError("completion cancelled")
|