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,241 @@
|
|
|
1
|
+
"""AgentLoop-owned contracts for optional offline semantic judgments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
JUDGMENT_VERSION = "1.0"
|
|
12
|
+
JUDGMENT_KEY = "agentloop.judgments"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def canonical(value: Any) -> str:
|
|
16
|
+
try:
|
|
17
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
18
|
+
except (TypeError, ValueError, RecursionError):
|
|
19
|
+
raise ValueError("judgment data must contain finite JSON values") from None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fingerprint(value: Any) -> str:
|
|
23
|
+
return hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def text(value: Any, name: str, *, optional: bool = False) -> None:
|
|
27
|
+
if value is None and optional:
|
|
28
|
+
return
|
|
29
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 4096:
|
|
30
|
+
raise ValueError(f"{name} must be nonempty text of at most 4096 characters")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def finite(value: Any) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
return type(value) in {int, float} and math.isfinite(value)
|
|
36
|
+
except OverflowError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_hash(value: Any) -> bool:
|
|
41
|
+
return (
|
|
42
|
+
isinstance(value, str)
|
|
43
|
+
and len(value) == 64
|
|
44
|
+
and all(char in "0123456789abcdef" for char in value)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class JudgeIdentity:
|
|
50
|
+
implementation: str
|
|
51
|
+
version: str
|
|
52
|
+
config_hash: str
|
|
53
|
+
provider: str = "local"
|
|
54
|
+
model_or_rule: str = "callback"
|
|
55
|
+
revision: str = "1"
|
|
56
|
+
|
|
57
|
+
def __post_init__(self):
|
|
58
|
+
for key, value in asdict(self).items():
|
|
59
|
+
text(value, key)
|
|
60
|
+
if not is_hash(self.config_hash):
|
|
61
|
+
raise ValueError("config_hash must be a SHA-256 digest")
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def configured(cls, implementation: str, version: str, configuration: Any, **kwargs):
|
|
65
|
+
"""Hash caller-owned configuration without retaining or forwarding its contents."""
|
|
66
|
+
return cls(implementation, version, fingerprint(configuration), **kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class JudgmentSpec:
|
|
71
|
+
question: str
|
|
72
|
+
kind: str
|
|
73
|
+
choices: tuple[str, ...] = ()
|
|
74
|
+
minimum: float | None = None
|
|
75
|
+
maximum: float | None = None
|
|
76
|
+
|
|
77
|
+
def __post_init__(self):
|
|
78
|
+
text(self.question, "question")
|
|
79
|
+
if self.kind not in {"boolean", "probability", "choice", "score"}:
|
|
80
|
+
raise ValueError("unsupported judgment kind")
|
|
81
|
+
if not isinstance(self.choices, (list, tuple)):
|
|
82
|
+
raise ValueError("choices must be a list or tuple")
|
|
83
|
+
object.__setattr__(self, "choices", tuple(self.choices))
|
|
84
|
+
if self.kind == "choice":
|
|
85
|
+
for item in self.choices:
|
|
86
|
+
text(item, "choice")
|
|
87
|
+
if not self.choices or len(set(self.choices)) != len(self.choices):
|
|
88
|
+
raise ValueError("choices must be nonempty and unique")
|
|
89
|
+
elif self.choices:
|
|
90
|
+
raise ValueError("choices apply only to choice judgments")
|
|
91
|
+
if self.kind == "score":
|
|
92
|
+
if not finite(self.minimum) or not finite(self.maximum) or self.minimum >= self.maximum:
|
|
93
|
+
raise ValueError("score requires finite minimum < maximum")
|
|
94
|
+
elif self.minimum is not None or self.maximum is not None:
|
|
95
|
+
raise ValueError("explicit bounds apply only to score judgments")
|
|
96
|
+
|
|
97
|
+
def accepts(self, value: Any) -> bool:
|
|
98
|
+
if self.kind == "boolean":
|
|
99
|
+
return type(value) is bool
|
|
100
|
+
if self.kind == "choice":
|
|
101
|
+
return isinstance(value, str) and value in self.choices
|
|
102
|
+
if not finite(value):
|
|
103
|
+
return False
|
|
104
|
+
low, high = (0, 1) if self.kind == "probability" else (self.minimum, self.maximum)
|
|
105
|
+
return low <= value <= high
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class JudgmentEvidence:
|
|
110
|
+
span_id: str
|
|
111
|
+
source_hash: str
|
|
112
|
+
operation_kind: str
|
|
113
|
+
status: str
|
|
114
|
+
duration_ms: float
|
|
115
|
+
summary: str | None = None
|
|
116
|
+
|
|
117
|
+
def __post_init__(self):
|
|
118
|
+
text(self.span_id, "span_id")
|
|
119
|
+
text(self.operation_kind, "operation_kind")
|
|
120
|
+
if not is_hash(self.source_hash):
|
|
121
|
+
raise ValueError("source_hash must be a SHA-256 digest")
|
|
122
|
+
if self.status not in {"ok", "error", "unknown"}:
|
|
123
|
+
raise ValueError("unsupported observed status")
|
|
124
|
+
if not finite(self.duration_ms) or self.duration_ms < 0:
|
|
125
|
+
raise ValueError("duration_ms must be finite and nonnegative")
|
|
126
|
+
text(self.summary, "summary", optional=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class JudgmentRequest:
|
|
131
|
+
run_id: str
|
|
132
|
+
spec: JudgmentSpec
|
|
133
|
+
evidence: tuple[JudgmentEvidence, ...]
|
|
134
|
+
|
|
135
|
+
def __post_init__(self):
|
|
136
|
+
text(self.run_id, "run_id")
|
|
137
|
+
if type(self.spec) is not JudgmentSpec:
|
|
138
|
+
raise ValueError("spec must be JudgmentSpec")
|
|
139
|
+
if not isinstance(self.evidence, (tuple, list)):
|
|
140
|
+
raise ValueError("evidence must be a list or tuple")
|
|
141
|
+
object.__setattr__(self, "evidence", tuple(self.evidence))
|
|
142
|
+
if not self.evidence or any(type(item) is not JudgmentEvidence for item in self.evidence):
|
|
143
|
+
raise ValueError("at least one JudgmentEvidence is required")
|
|
144
|
+
if len({item.span_id for item in self.evidence}) != len(self.evidence):
|
|
145
|
+
raise ValueError("evidence span IDs must be unique")
|
|
146
|
+
|
|
147
|
+
def to_dict(self):
|
|
148
|
+
return json.loads(canonical(asdict(self)))
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def from_dict(cls, value):
|
|
152
|
+
return cls(
|
|
153
|
+
value["run_id"],
|
|
154
|
+
JudgmentSpec(**value["spec"]),
|
|
155
|
+
tuple(JudgmentEvidence(**item) for item in value["evidence"]),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class JudgeUsage:
|
|
161
|
+
input_tokens: int | None = None
|
|
162
|
+
output_tokens: int | None = None
|
|
163
|
+
cost_usd: float | None = None
|
|
164
|
+
token_basis: str = "unknown"
|
|
165
|
+
cost_basis: str = "unknown"
|
|
166
|
+
|
|
167
|
+
def __post_init__(self):
|
|
168
|
+
for count in (self.input_tokens, self.output_tokens):
|
|
169
|
+
if count is not None and (type(count) is not int or count < 0):
|
|
170
|
+
raise ValueError("known token counts must be nonnegative integers")
|
|
171
|
+
if self.cost_usd is not None and (not finite(self.cost_usd) or self.cost_usd < 0):
|
|
172
|
+
raise ValueError("known cost must be finite and nonnegative")
|
|
173
|
+
if self.token_basis not in {"unknown", "reported", "estimated", "not_dispatched"}:
|
|
174
|
+
raise ValueError("unsupported token basis")
|
|
175
|
+
if self.cost_basis not in {"unknown", "reported", "calculated", "not_dispatched"}:
|
|
176
|
+
raise ValueError("unsupported cost basis")
|
|
177
|
+
if (self.input_tokens is not None or self.output_tokens is not None) == (
|
|
178
|
+
# A model-usage provenance label, not an authentication credential.
|
|
179
|
+
self.token_basis == "unknown" # nosec B105
|
|
180
|
+
):
|
|
181
|
+
raise ValueError("token counts and basis must agree")
|
|
182
|
+
if (self.cost_usd is not None) == (self.cost_basis == "unknown"):
|
|
183
|
+
raise ValueError("cost and basis must agree")
|
|
184
|
+
# This public label records that no model invocation occurred.
|
|
185
|
+
if self.token_basis == "not_dispatched" and (self.input_tokens, self.output_tokens) != ( # nosec B105
|
|
186
|
+
0,
|
|
187
|
+
0,
|
|
188
|
+
):
|
|
189
|
+
raise ValueError("undispatched usage must be zero")
|
|
190
|
+
if self.cost_basis == "not_dispatched" and self.cost_usd != 0:
|
|
191
|
+
raise ValueError("undispatched cost must be zero")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class JudgmentUncertainty:
|
|
196
|
+
confidence: float | None = None
|
|
197
|
+
calibration_status: str = "uncalibrated"
|
|
198
|
+
calibration_ref: str | None = None
|
|
199
|
+
|
|
200
|
+
def __post_init__(self):
|
|
201
|
+
if self.confidence is not None and (
|
|
202
|
+
not finite(self.confidence) or not 0 <= self.confidence <= 1
|
|
203
|
+
):
|
|
204
|
+
raise ValueError("confidence must be in [0, 1]")
|
|
205
|
+
if self.calibration_status not in {"uncalibrated", "measured"}:
|
|
206
|
+
raise ValueError("unsupported calibration status")
|
|
207
|
+
text(self.calibration_ref, "calibration_ref", optional=True)
|
|
208
|
+
if self.calibration_status == "measured" and self.calibration_ref is None:
|
|
209
|
+
raise ValueError("measured calibration requires a caller-supplied evidence reference")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class JudgmentAnswer:
|
|
214
|
+
value: Any = None
|
|
215
|
+
status: str = "known"
|
|
216
|
+
reason: str | None = None
|
|
217
|
+
usage: JudgeUsage = JudgeUsage()
|
|
218
|
+
uncertainty: JudgmentUncertainty = JudgmentUncertainty()
|
|
219
|
+
|
|
220
|
+
def __post_init__(self):
|
|
221
|
+
if self.status not in {"known", "unknown"}:
|
|
222
|
+
raise ValueError("answer status must be known or unknown")
|
|
223
|
+
if self.status == "unknown":
|
|
224
|
+
if self.value is not None or self.reason not in {"abstained", "insufficient_evidence"}:
|
|
225
|
+
raise ValueError("unknown answers require a reason and no value")
|
|
226
|
+
elif self.reason is not None:
|
|
227
|
+
raise ValueError("known answers cannot carry a failure reason")
|
|
228
|
+
if type(self.usage) is not JudgeUsage or type(self.uncertainty) is not JudgmentUncertainty:
|
|
229
|
+
raise ValueError("answer requires typed usage and uncertainty")
|
|
230
|
+
if self.value is not None and type(self.value) not in {str, bool, int, float}:
|
|
231
|
+
raise ValueError("answer must be a scalar")
|
|
232
|
+
canonical(self.value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class JudgmentBackend(Protocol):
|
|
236
|
+
"""Adapters own cooperative time limits and must return a typed answer."""
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def identity(self) -> JudgeIdentity: ...
|
|
240
|
+
|
|
241
|
+
def judge(self, request: JudgmentRequest, *, timeout_s: float | None) -> JudgmentAnswer: ...
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Text projection of saved judgment evidence; never executes a backend."""
|
|
2
|
+
|
|
3
|
+
from agentloop.markdown import markdown_table_cell
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def judgment_markdown(evidence):
|
|
7
|
+
lines = [
|
|
8
|
+
"",
|
|
9
|
+
"## Offline semantic judgments",
|
|
10
|
+
"",
|
|
11
|
+
"Recorded execution facts, deterministic rule inferences, and semantic judge answers "
|
|
12
|
+
"are separate evidence categories. Judge answers do not establish task quality or "
|
|
13
|
+
"calibrated confidence. Judge latency and cost are analysis overhead, separate from workflow totals.",
|
|
14
|
+
"",
|
|
15
|
+
f"Evidence status: {markdown_table_cell(evidence['status'])}",
|
|
16
|
+
"",
|
|
17
|
+
"| Judge / version | Kind | Status | Value | Confidence basis | Cache hit | Incurred cost USD |",
|
|
18
|
+
"| --- | --- | --- | --- | --- | --- | --- |",
|
|
19
|
+
]
|
|
20
|
+
for record in evidence["records"]:
|
|
21
|
+
judge, result = record["judge"], record["evaluation"]
|
|
22
|
+
cells = [
|
|
23
|
+
f"{judge['implementation']} / {judge['version']}",
|
|
24
|
+
record["request"]["spec"]["kind"],
|
|
25
|
+
record["effective_status"],
|
|
26
|
+
record["effective_value"],
|
|
27
|
+
result["uncertainty"]["calibration_status"],
|
|
28
|
+
record["invocation"]["cache_hit"],
|
|
29
|
+
record["invocation"]["usage"]["cost_usd"],
|
|
30
|
+
]
|
|
31
|
+
lines.append(
|
|
32
|
+
"| "
|
|
33
|
+
+ " | ".join(
|
|
34
|
+
markdown_table_cell("unavailable" if item is None else str(item)) for item in cells
|
|
35
|
+
)
|
|
36
|
+
+ " |"
|
|
37
|
+
)
|
|
38
|
+
return lines
|
agentloop/judgments.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Explicit offline judgment execution, bounded caching and portable receipts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from typing import Callable
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from agentloop.events import AgentEvent, utc_now_iso
|
|
14
|
+
from agentloop.judgment_types import (
|
|
15
|
+
JUDGMENT_KEY,
|
|
16
|
+
JUDGMENT_VERSION,
|
|
17
|
+
JudgeIdentity,
|
|
18
|
+
JudgeUsage,
|
|
19
|
+
JudgmentAnswer,
|
|
20
|
+
JudgmentBackend,
|
|
21
|
+
JudgmentEvidence,
|
|
22
|
+
JudgmentRequest,
|
|
23
|
+
JudgmentSpec,
|
|
24
|
+
JudgmentUncertainty,
|
|
25
|
+
canonical,
|
|
26
|
+
fingerprint,
|
|
27
|
+
finite,
|
|
28
|
+
)
|
|
29
|
+
from agentloop.operations import OPERATION_KINDS, operation_kind
|
|
30
|
+
|
|
31
|
+
_NO_USAGE = JudgeUsage(0, 0, 0.0, "not_dispatched", "not_dispatched")
|
|
32
|
+
_FAILURES = {"disabled", "timeout", "error", "invalid_result"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _copy(value):
|
|
36
|
+
return json.loads(canonical(value))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _event_hash(event):
|
|
40
|
+
# Use the importer normalization so an integer duration and its native JSON
|
|
41
|
+
# round trip have the same fingerprint. Bodies are hashed, never forwarded.
|
|
42
|
+
source = AgentEvent.from_dict(event.to_dict()).to_dict()
|
|
43
|
+
source["metadata"] = {
|
|
44
|
+
key: value
|
|
45
|
+
for key, value in source["metadata"].items()
|
|
46
|
+
if key not in {"otel_span_id", "otel_trace_id"}
|
|
47
|
+
}
|
|
48
|
+
return fingerprint(source)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _evidence(event, summary, hashes):
|
|
52
|
+
if event.event_id not in hashes:
|
|
53
|
+
hashes[event.event_id] = _event_hash(event)
|
|
54
|
+
kind = operation_kind(event)
|
|
55
|
+
return JudgmentEvidence(
|
|
56
|
+
event.event_id,
|
|
57
|
+
hashes[event.event_id],
|
|
58
|
+
kind if kind in OPERATION_KINDS else "unknown",
|
|
59
|
+
event.status if event.status in {"ok", "error"} else "unknown",
|
|
60
|
+
float(event.duration_ms),
|
|
61
|
+
summary,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def judgment_request(trace, spec: JudgmentSpec, span_ids, *, summaries=None) -> JudgmentRequest:
|
|
66
|
+
"""Select references/observations; only explicitly supplied summaries carry text."""
|
|
67
|
+
if not isinstance(span_ids, (list, tuple)) or not span_ids:
|
|
68
|
+
raise ValueError("span_ids must be a nonempty list or tuple")
|
|
69
|
+
if any(not isinstance(item, str) for item in span_ids) or len(set(span_ids)) != len(span_ids):
|
|
70
|
+
raise ValueError("span_ids must contain unique strings")
|
|
71
|
+
summaries = {} if summaries is None else summaries
|
|
72
|
+
if not isinstance(summaries, dict) or set(summaries) - set(span_ids):
|
|
73
|
+
raise ValueError("summaries must map selected span IDs to explicitly supplied text")
|
|
74
|
+
events = {event.event_id: event for event in trace.events}
|
|
75
|
+
if len(events) != len(trace.events) or set(span_ids) - events.keys():
|
|
76
|
+
raise ValueError("selected span IDs must resolve uniquely in the trace")
|
|
77
|
+
hashes = {}
|
|
78
|
+
evidence = [_evidence(events[span_id], summaries.get(span_id), hashes) for span_id in span_ids]
|
|
79
|
+
return JudgmentRequest(trace.run_id, spec, tuple(evidence))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class LocalCallbackJudge:
|
|
84
|
+
"""Trusted synchronous callback; no imports, provider client or worker threads."""
|
|
85
|
+
|
|
86
|
+
identity: JudgeIdentity
|
|
87
|
+
callback: Callable
|
|
88
|
+
|
|
89
|
+
def __post_init__(self):
|
|
90
|
+
if type(self.identity) is not JudgeIdentity or not callable(self.callback):
|
|
91
|
+
raise ValueError("local judge requires an identity and callable")
|
|
92
|
+
if inspect.iscoroutinefunction(self.callback) or inspect.isasyncgenfunction(self.callback):
|
|
93
|
+
raise ValueError("local callbacks must be synchronous")
|
|
94
|
+
|
|
95
|
+
def judge(self, request, *, timeout_s=None):
|
|
96
|
+
return self.callback(request, timeout_s=timeout_s)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class JudgmentSession:
|
|
100
|
+
"""One backend and a bounded session-local LRU; execution requires enabled=True.
|
|
101
|
+
|
|
102
|
+
Not thread-safe. Use one session per analysis worker. No cache is shared
|
|
103
|
+
across backend instances or restored from untrusted trace metadata.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, backend: JudgmentBackend, *, cache_size: int = 128):
|
|
107
|
+
if type(cache_size) is not int or cache_size < 0:
|
|
108
|
+
raise ValueError("cache_size must be a nonnegative integer")
|
|
109
|
+
if type(backend.identity) is not JudgeIdentity or not callable(backend.judge):
|
|
110
|
+
raise ValueError("backend must implement JudgmentBackend")
|
|
111
|
+
self._backend = backend
|
|
112
|
+
self._cache_size = cache_size
|
|
113
|
+
self._cache = OrderedDict()
|
|
114
|
+
|
|
115
|
+
def clear_cache(self):
|
|
116
|
+
self._cache.clear()
|
|
117
|
+
|
|
118
|
+
def evaluate(self, request: JudgmentRequest, *, enabled=False, timeout_s=None):
|
|
119
|
+
if type(request) is not JudgmentRequest or type(enabled) is not bool:
|
|
120
|
+
raise ValueError("evaluate requires a typed request and boolean enabled flag")
|
|
121
|
+
if timeout_s is not None and (not finite(timeout_s) or timeout_s <= 0):
|
|
122
|
+
raise ValueError("timeout_s must be finite and positive")
|
|
123
|
+
identity = self._backend.identity
|
|
124
|
+
if type(identity) is not JudgeIdentity:
|
|
125
|
+
raise ValueError("backend identity must be JudgeIdentity")
|
|
126
|
+
request_data, judge = request.to_dict(), asdict(identity)
|
|
127
|
+
key = fingerprint(
|
|
128
|
+
{"schema_version": JUDGMENT_VERSION, "request": request_data, "judge": judge}
|
|
129
|
+
)
|
|
130
|
+
started = time.perf_counter()
|
|
131
|
+
hit = enabled and key in self._cache
|
|
132
|
+
if hit:
|
|
133
|
+
evaluation = _copy(self._cache[key])
|
|
134
|
+
self._cache.move_to_end(key)
|
|
135
|
+
else:
|
|
136
|
+
evaluation = self._evaluate(request, enabled, timeout_s)
|
|
137
|
+
if enabled and self._backend.identity != identity:
|
|
138
|
+
evaluation.update(status="invalid_result", value=None, reason="identity_changed")
|
|
139
|
+
if evaluation["status"] == "known" and self._cache_size:
|
|
140
|
+
self._cache[key] = _copy(evaluation)
|
|
141
|
+
if len(self._cache) > self._cache_size:
|
|
142
|
+
self._cache.popitem(last=False)
|
|
143
|
+
record = {
|
|
144
|
+
"schema_version": JUDGMENT_VERSION,
|
|
145
|
+
"evidence_kind": "semantic_judgment",
|
|
146
|
+
"request": request_data,
|
|
147
|
+
"judge": judge,
|
|
148
|
+
"cache_key": key,
|
|
149
|
+
"evaluation": evaluation,
|
|
150
|
+
"invocation": {
|
|
151
|
+
"id": uuid4().hex,
|
|
152
|
+
"cache_hit": hit,
|
|
153
|
+
"dispatched": enabled and not hit,
|
|
154
|
+
"latency_ms": (time.perf_counter() - started) * 1000,
|
|
155
|
+
"usage": asdict(_NO_USAGE) if hit or not enabled else evaluation["usage"],
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
record["record_hash"] = fingerprint(record)
|
|
159
|
+
return _copy(record)
|
|
160
|
+
|
|
161
|
+
def _evaluate(self, request, enabled, timeout_s):
|
|
162
|
+
started = time.perf_counter()
|
|
163
|
+
result = {
|
|
164
|
+
"id": uuid4().hex,
|
|
165
|
+
"evaluated_at": utc_now_iso(),
|
|
166
|
+
"status": "disabled",
|
|
167
|
+
"value": None,
|
|
168
|
+
"reason": "disabled",
|
|
169
|
+
"usage": asdict(_NO_USAGE if not enabled else JudgeUsage()),
|
|
170
|
+
"uncertainty": asdict(JudgmentUncertainty()),
|
|
171
|
+
}
|
|
172
|
+
if enabled:
|
|
173
|
+
try:
|
|
174
|
+
answer = self._backend.judge(request, timeout_s=timeout_s)
|
|
175
|
+
if type(answer) is not JudgmentAnswer:
|
|
176
|
+
if inspect.iscoroutine(answer) or inspect.isgenerator(answer):
|
|
177
|
+
answer.close()
|
|
178
|
+
result.update(status="invalid_result", reason="invalid_result")
|
|
179
|
+
else:
|
|
180
|
+
result.update(asdict(answer))
|
|
181
|
+
if answer.status == "known" and not request.spec.accepts(answer.value):
|
|
182
|
+
result.update(status="invalid_result", value=None, reason="invalid_result")
|
|
183
|
+
except TimeoutError:
|
|
184
|
+
result.update(status="timeout", reason="timeout")
|
|
185
|
+
except Exception:
|
|
186
|
+
# Neither exception text nor arbitrary class names leave the callback.
|
|
187
|
+
result.update(status="error", reason="error")
|
|
188
|
+
elapsed = time.perf_counter() - started
|
|
189
|
+
if enabled and timeout_s is not None and elapsed > timeout_s:
|
|
190
|
+
result.update(status="timeout", value=None, reason="timeout")
|
|
191
|
+
result["latency_ms"] = elapsed * 1000
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_judgment_record(record):
|
|
196
|
+
"""Validate portable integrity and typed semantics, not a judge's truthfulness."""
|
|
197
|
+
value = _copy(record)
|
|
198
|
+
try:
|
|
199
|
+
digest = value.pop("record_hash")
|
|
200
|
+
if digest != fingerprint(value) or value["schema_version"] != JUDGMENT_VERSION:
|
|
201
|
+
raise ValueError("unsupported or altered judgment record")
|
|
202
|
+
if value["evidence_kind"] != "semantic_judgment":
|
|
203
|
+
raise ValueError("invalid evidence kind")
|
|
204
|
+
request = JudgmentRequest.from_dict(value["request"])
|
|
205
|
+
judge = JudgeIdentity(**value["judge"])
|
|
206
|
+
key = fingerprint(
|
|
207
|
+
{
|
|
208
|
+
"schema_version": JUDGMENT_VERSION,
|
|
209
|
+
"request": request.to_dict(),
|
|
210
|
+
"judge": asdict(judge),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
if value["cache_key"] != key:
|
|
214
|
+
raise ValueError("judgment cache identity mismatch")
|
|
215
|
+
result, invocation = value["evaluation"], value["invocation"]
|
|
216
|
+
usage = JudgeUsage(**result["usage"])
|
|
217
|
+
uncertainty = JudgmentUncertainty(**result["uncertainty"])
|
|
218
|
+
if result["status"] in {"known", "unknown"}:
|
|
219
|
+
answer = JudgmentAnswer(
|
|
220
|
+
result["value"], result["status"], result["reason"], usage, uncertainty
|
|
221
|
+
)
|
|
222
|
+
if answer.status == "known" and not request.spec.accepts(answer.value):
|
|
223
|
+
raise ValueError("answer violates typed bounds")
|
|
224
|
+
elif (
|
|
225
|
+
result["status"] not in _FAILURES
|
|
226
|
+
or result["value"] is not None
|
|
227
|
+
or result["reason"] not in _FAILURES | {"identity_changed"}
|
|
228
|
+
):
|
|
229
|
+
raise ValueError("invalid failure result")
|
|
230
|
+
for part in (result, invocation):
|
|
231
|
+
if (
|
|
232
|
+
not isinstance(part["id"], str)
|
|
233
|
+
or not part["id"]
|
|
234
|
+
or not finite(part["latency_ms"])
|
|
235
|
+
or part["latency_ms"] < 0
|
|
236
|
+
):
|
|
237
|
+
raise ValueError("invalid execution receipt")
|
|
238
|
+
if not isinstance(result["evaluated_at"], str):
|
|
239
|
+
raise ValueError("invalid evaluation timestamp")
|
|
240
|
+
if type(invocation["cache_hit"]) is not bool or type(invocation["dispatched"]) is not bool:
|
|
241
|
+
raise ValueError("invalid cache receipt")
|
|
242
|
+
incurred = JudgeUsage(**invocation["usage"])
|
|
243
|
+
if invocation["cache_hit"]:
|
|
244
|
+
if invocation["dispatched"] or result["status"] != "known":
|
|
245
|
+
raise ValueError("only successful undispatched evaluations can be cached")
|
|
246
|
+
if not invocation["dispatched"]:
|
|
247
|
+
if incurred != _NO_USAGE or (
|
|
248
|
+
not invocation["cache_hit"] and result["status"] != "disabled"
|
|
249
|
+
):
|
|
250
|
+
raise ValueError("invalid undispatched receipt")
|
|
251
|
+
elif incurred != usage or result["status"] == "disabled":
|
|
252
|
+
raise ValueError("invalid dispatched receipt")
|
|
253
|
+
value["record_hash"] = digest
|
|
254
|
+
return value
|
|
255
|
+
except (KeyError, TypeError, AttributeError):
|
|
256
|
+
raise ValueError("invalid judgment record") from None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _bound(trace, record, *, events=None, hashes=None):
|
|
260
|
+
request = JudgmentRequest.from_dict(record["request"])
|
|
261
|
+
if request.run_id != trace.run_id:
|
|
262
|
+
return False
|
|
263
|
+
if events is None:
|
|
264
|
+
events = {event.event_id: event for event in trace.events}
|
|
265
|
+
if len(events) != len(trace.events):
|
|
266
|
+
return False
|
|
267
|
+
hashes = {} if hashes is None else hashes
|
|
268
|
+
return all(
|
|
269
|
+
item.span_id in events and _evidence(events[item.span_id], item.summary, hashes) == item
|
|
270
|
+
for item in request.evidence
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def attach_judgment(trace, record):
|
|
275
|
+
"""Attach a validated receipt without replacing host metadata or prior results."""
|
|
276
|
+
owned = validate_judgment_record(record)
|
|
277
|
+
if not _bound(trace, owned):
|
|
278
|
+
raise ValueError("judgment evidence does not match the trace")
|
|
279
|
+
existing = trace.metadata.get(JUDGMENT_KEY)
|
|
280
|
+
if JUDGMENT_KEY not in trace.metadata:
|
|
281
|
+
existing = {"schema_version": JUDGMENT_VERSION, "records": []}
|
|
282
|
+
elif read_judgments(trace)["status"] in {"invalid", "unsupported"}:
|
|
283
|
+
raise ValueError("existing judgment evidence is invalid or unsupported")
|
|
284
|
+
existing = _copy(existing)
|
|
285
|
+
for previous in existing["records"]:
|
|
286
|
+
if previous["invocation"]["id"] == owned["invocation"]["id"]:
|
|
287
|
+
if previous != owned:
|
|
288
|
+
raise ValueError("conflicting invocation receipt")
|
|
289
|
+
return
|
|
290
|
+
existing["records"].append(owned)
|
|
291
|
+
trace.metadata[JUDGMENT_KEY] = existing
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def read_judgments(trace):
|
|
295
|
+
"""Read saved evidence only. Stale or failed judgments never become booleans."""
|
|
296
|
+
metadata = getattr(trace, "metadata", {})
|
|
297
|
+
envelope = metadata.get(JUDGMENT_KEY)
|
|
298
|
+
if JUDGMENT_KEY not in metadata:
|
|
299
|
+
return None
|
|
300
|
+
empty = {"status": "invalid", "records": [], "known_cost_usd": None, "cost_complete": False}
|
|
301
|
+
if not isinstance(envelope, dict):
|
|
302
|
+
return empty
|
|
303
|
+
if envelope.get("schema_version") != JUDGMENT_VERSION:
|
|
304
|
+
return {**empty, "status": "unsupported"}
|
|
305
|
+
try:
|
|
306
|
+
if not isinstance(envelope.get("records"), list):
|
|
307
|
+
return empty
|
|
308
|
+
records, identities, known_cost, complete_cost = [], set(), 0.0, True
|
|
309
|
+
events = {event.event_id: event for event in trace.events}
|
|
310
|
+
hashes = {}
|
|
311
|
+
for raw in envelope["records"]:
|
|
312
|
+
record = validate_judgment_record(raw)
|
|
313
|
+
identity = record["invocation"]["id"]
|
|
314
|
+
if identity in identities:
|
|
315
|
+
return empty
|
|
316
|
+
identities.add(identity)
|
|
317
|
+
try:
|
|
318
|
+
bound = _bound(trace, record, events=events, hashes=hashes)
|
|
319
|
+
except ValueError:
|
|
320
|
+
bound = False
|
|
321
|
+
result = record["evaluation"]
|
|
322
|
+
record["effective_status"] = result["status"] if bound else "stale"
|
|
323
|
+
record["effective_value"] = (
|
|
324
|
+
result["value"] if bound and result["status"] == "known" else None
|
|
325
|
+
)
|
|
326
|
+
cost = record["invocation"]["usage"]["cost_usd"]
|
|
327
|
+
if cost is None:
|
|
328
|
+
complete_cost = False
|
|
329
|
+
else:
|
|
330
|
+
known_cost += cost
|
|
331
|
+
records.append(record)
|
|
332
|
+
return {
|
|
333
|
+
"status": "complete"
|
|
334
|
+
if records and all(item["effective_status"] == "known" for item in records)
|
|
335
|
+
else "incomplete",
|
|
336
|
+
"records": records,
|
|
337
|
+
"known_cost_usd": known_cost if finite(known_cost) else None,
|
|
338
|
+
"cost_complete": complete_cost and finite(known_cost),
|
|
339
|
+
"cost_scope": "offline_judge_invocations_only",
|
|
340
|
+
}
|
|
341
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
342
|
+
return empty
|