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,337 @@
|
|
|
1
|
+
"""Explicit remove/compress candidates on the generic experiment contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import asdict, dataclass, replace
|
|
8
|
+
from fractions import Fraction
|
|
9
|
+
|
|
10
|
+
from agentloop.budget_types import Reservation, ResourceUsage
|
|
11
|
+
from agentloop.context_types import EXACT_COUNTS, ContextTokenCount, identifier, synchronous
|
|
12
|
+
from agentloop.experiment_observations import observation, record_experiment_observations
|
|
13
|
+
from agentloop.experiment_types import (
|
|
14
|
+
ExperimentResult,
|
|
15
|
+
ExperimentRunner,
|
|
16
|
+
canonical,
|
|
17
|
+
fingerprint,
|
|
18
|
+
reference,
|
|
19
|
+
)
|
|
20
|
+
from agentloop.tracer import current_trace
|
|
21
|
+
|
|
22
|
+
CONTEXT_EXPERIMENT_KEY = "agentloop.context_experiment"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _path(value):
|
|
26
|
+
if (
|
|
27
|
+
not isinstance(value, (list, tuple))
|
|
28
|
+
or not 1 <= len(value) <= 32
|
|
29
|
+
or any(
|
|
30
|
+
not (isinstance(part, str) and 0 < len(part) <= 256 or type(part) is int and part >= 0)
|
|
31
|
+
for part in value
|
|
32
|
+
)
|
|
33
|
+
):
|
|
34
|
+
raise ValueError("context paths require bounded string keys or nonnegative indexes")
|
|
35
|
+
return tuple(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _overlap(left, right):
|
|
39
|
+
return left[: len(right)] == right or right[: len(left)] == left
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _value(payload, path):
|
|
43
|
+
value = payload
|
|
44
|
+
for part in path:
|
|
45
|
+
if isinstance(value, dict) and isinstance(part, str) and part in value:
|
|
46
|
+
value = value[part]
|
|
47
|
+
elif isinstance(value, list) and type(part) is int and part < len(value):
|
|
48
|
+
value = value[part]
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError("context selection is missing or incompatible")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _deadline(request):
|
|
55
|
+
if request.remaining_s <= 0:
|
|
56
|
+
raise TimeoutError("experiment template deadline expired")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _count_kind(count):
|
|
60
|
+
return (
|
|
61
|
+
"observed"
|
|
62
|
+
if count.provenance in EXACT_COUNTS
|
|
63
|
+
else "estimated"
|
|
64
|
+
if count.provenance == "estimated_words"
|
|
65
|
+
else "declared"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _close_invalid(value):
|
|
70
|
+
if inspect.iscoroutine(value) or inspect.isgenerator(value):
|
|
71
|
+
value.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ContextSelection:
|
|
76
|
+
selection_id: str
|
|
77
|
+
path: tuple[str | int, ...]
|
|
78
|
+
source_ref: str
|
|
79
|
+
action: str = "remove"
|
|
80
|
+
|
|
81
|
+
def __post_init__(self):
|
|
82
|
+
identifier(self.selection_id, "selection_id")
|
|
83
|
+
reference(self.source_ref, "selection reference")
|
|
84
|
+
object.__setattr__(self, "path", _path(self.path))
|
|
85
|
+
if self.action not in {"remove", "compress"}:
|
|
86
|
+
raise ValueError("context action must be remove or compress")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sum_usage(usages):
|
|
90
|
+
if len(usages) == 1:
|
|
91
|
+
return usages[0]
|
|
92
|
+
ids = [value.usage_id for value in usages if value.usage_id is not None]
|
|
93
|
+
if len(ids) != len(set(ids)):
|
|
94
|
+
raise ValueError("compression and backend usage must be exclusive")
|
|
95
|
+
complete = all(value.complete and value.exclusive for value in usages)
|
|
96
|
+
tokens = (
|
|
97
|
+
sum(value.tokens for value in usages)
|
|
98
|
+
if all(value.tokens_known for value in usages)
|
|
99
|
+
else None
|
|
100
|
+
)
|
|
101
|
+
cost = (
|
|
102
|
+
float(sum((Fraction(str(value.cost_usd)) for value in usages), Fraction(0)))
|
|
103
|
+
if all(value.cost_known for value in usages)
|
|
104
|
+
else None
|
|
105
|
+
)
|
|
106
|
+
return ResourceUsage(
|
|
107
|
+
tokens=tokens,
|
|
108
|
+
cost_usd=cost,
|
|
109
|
+
token_provenance="user_supplied" if tokens is not None else "unavailable",
|
|
110
|
+
cost_provenance="user_reported" if cost is not None else "unavailable",
|
|
111
|
+
complete=complete,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def context_reduction_runner(
|
|
116
|
+
candidate_id,
|
|
117
|
+
backend,
|
|
118
|
+
*,
|
|
119
|
+
selections,
|
|
120
|
+
protected_paths=(),
|
|
121
|
+
token_counter=None,
|
|
122
|
+
counter_ref=None,
|
|
123
|
+
compressor=None,
|
|
124
|
+
compressor_ref=None,
|
|
125
|
+
on_missing="error",
|
|
126
|
+
reservation=None,
|
|
127
|
+
):
|
|
128
|
+
if (
|
|
129
|
+
type(backend) is not ExperimentRunner
|
|
130
|
+
or not isinstance(selections, (tuple, list))
|
|
131
|
+
or not 1 <= len(selections) <= 64
|
|
132
|
+
or any(type(item) is not ContextSelection for item in selections)
|
|
133
|
+
):
|
|
134
|
+
raise ValueError("context experiments require an explicit backend and selections")
|
|
135
|
+
selections = tuple(selections)
|
|
136
|
+
if not isinstance(protected_paths, (list, tuple)) or len(protected_paths) > 64:
|
|
137
|
+
raise ValueError("protected paths must be a bounded sequence")
|
|
138
|
+
protected = tuple(_path(path) for path in protected_paths)
|
|
139
|
+
if len(protected) > 64 or len({item.selection_id for item in selections}) != len(selections):
|
|
140
|
+
raise ValueError("context declarations must be unique and bounded")
|
|
141
|
+
if any(
|
|
142
|
+
_overlap(item.path, other.path)
|
|
143
|
+
for index, item in enumerate(selections)
|
|
144
|
+
for other in selections[index + 1 :]
|
|
145
|
+
) or any(_overlap(item.path, path) for item in selections for path in protected):
|
|
146
|
+
raise ValueError("selected paths overlap each other or protected context")
|
|
147
|
+
compressing = any(item.action == "compress" for item in selections)
|
|
148
|
+
if compressing and (not synchronous(compressor) or compressor_ref is None):
|
|
149
|
+
raise ValueError("compression requires a trusted explicit callback/reference")
|
|
150
|
+
if compressor_ref is not None:
|
|
151
|
+
reference(compressor_ref, "compressor_ref")
|
|
152
|
+
if token_counter is not None and (not synchronous(token_counter) or counter_ref is None):
|
|
153
|
+
raise ValueError("token counter requires a trusted callback and reference")
|
|
154
|
+
if counter_ref is not None:
|
|
155
|
+
identifier(counter_ref, "counter_ref")
|
|
156
|
+
if on_missing not in {"error", "keep_original"}:
|
|
157
|
+
raise ValueError("unknown missing-selection policy")
|
|
158
|
+
config = {
|
|
159
|
+
"schema_version": "1.0",
|
|
160
|
+
"backend": backend.declaration(),
|
|
161
|
+
"selections": [asdict(item) for item in selections],
|
|
162
|
+
"protected_paths": protected,
|
|
163
|
+
"counter_ref": counter_ref,
|
|
164
|
+
"compressor_ref": compressor_ref,
|
|
165
|
+
"on_missing": on_missing,
|
|
166
|
+
}
|
|
167
|
+
config_hash = fingerprint(config)
|
|
168
|
+
source = "context-config:" + config_hash
|
|
169
|
+
|
|
170
|
+
def count(payload):
|
|
171
|
+
if token_counter is None:
|
|
172
|
+
return ContextTokenCount()
|
|
173
|
+
result = token_counter(deepcopy(payload))
|
|
174
|
+
if type(result) is not ContextTokenCount or result.reference not in {None, counter_ref}:
|
|
175
|
+
_close_invalid(result)
|
|
176
|
+
raise ValueError("token counter result/reference differs from declaration")
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def invoke(request):
|
|
180
|
+
if backend.declaration() != config["backend"]:
|
|
181
|
+
raise ValueError("context backend binding changed after configuration")
|
|
182
|
+
before = request.inputs
|
|
183
|
+
trace = current_trace()
|
|
184
|
+
if trace is None:
|
|
185
|
+
raise ValueError("context candidate requires an active experiment")
|
|
186
|
+
receipt = {
|
|
187
|
+
"schema_version": "1.0",
|
|
188
|
+
"config_hash": config_hash,
|
|
189
|
+
"input_hash": fingerprint(before),
|
|
190
|
+
"selections": [],
|
|
191
|
+
"state": "started",
|
|
192
|
+
"output_input_hash": None,
|
|
193
|
+
"compression_usage": [],
|
|
194
|
+
}
|
|
195
|
+
trace.metadata[CONTEXT_EXPERIMENT_KEY] = receipt
|
|
196
|
+
usages = []
|
|
197
|
+
try:
|
|
198
|
+
_deadline(request)
|
|
199
|
+
before_count = count(before)
|
|
200
|
+
after = deepcopy(before)
|
|
201
|
+
try:
|
|
202
|
+
values = {item.selection_id: _value(before, item.path) for item in selections}
|
|
203
|
+
except ValueError:
|
|
204
|
+
receipt["state"] = "selection_missing"
|
|
205
|
+
record_experiment_observations(
|
|
206
|
+
{
|
|
207
|
+
"context_applied": observation(False, unit="boolean", source_ref=source),
|
|
208
|
+
"context_state": observation(
|
|
209
|
+
"selection_missing", unit="state", source_ref=source
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
if on_missing == "error":
|
|
214
|
+
raise
|
|
215
|
+
_deadline(request)
|
|
216
|
+
return backend.invoke(request)
|
|
217
|
+
ordered = sorted(
|
|
218
|
+
selections,
|
|
219
|
+
key=lambda item: tuple(
|
|
220
|
+
(0, part) if type(part) is int else (1, part) for part in item.path
|
|
221
|
+
),
|
|
222
|
+
reverse=True,
|
|
223
|
+
)
|
|
224
|
+
for item in ordered:
|
|
225
|
+
value = values[item.selection_id]
|
|
226
|
+
parent = _value(after, item.path[:-1]) if len(item.path) > 1 else after
|
|
227
|
+
record = {
|
|
228
|
+
"selection_id": item.selection_id,
|
|
229
|
+
"source_ref": item.source_ref,
|
|
230
|
+
"path_hash": fingerprint(item.path),
|
|
231
|
+
"source_hash": fingerprint(value),
|
|
232
|
+
"action": item.action,
|
|
233
|
+
"replacement_hash": None,
|
|
234
|
+
}
|
|
235
|
+
receipt["selections"].append(record)
|
|
236
|
+
if item.action == "remove":
|
|
237
|
+
del parent[item.path[-1]]
|
|
238
|
+
else:
|
|
239
|
+
_deadline(request)
|
|
240
|
+
replacement = compressor(deepcopy(value), request)
|
|
241
|
+
if type(replacement) is not ExperimentResult:
|
|
242
|
+
_close_invalid(replacement)
|
|
243
|
+
raise ValueError("compressor must return ExperimentResult with usage")
|
|
244
|
+
parent[item.path[-1]] = replacement.output
|
|
245
|
+
record["replacement_hash"] = fingerprint(replacement.output)
|
|
246
|
+
usages.append(replacement.usage)
|
|
247
|
+
receipt["compression_usage"].append(replacement.usage.to_dict())
|
|
248
|
+
_deadline(request)
|
|
249
|
+
after_count = count(after)
|
|
250
|
+
same_basis = (
|
|
251
|
+
before_count.reference == after_count.reference
|
|
252
|
+
and before_count.provenance == after_count.provenance
|
|
253
|
+
)
|
|
254
|
+
exact = (
|
|
255
|
+
same_basis
|
|
256
|
+
and before_count.provenance in EXACT_COUNTS
|
|
257
|
+
and before_count.value is not None
|
|
258
|
+
and after_count.value is not None
|
|
259
|
+
)
|
|
260
|
+
delta = (
|
|
261
|
+
before_count.value - after_count.value
|
|
262
|
+
if same_basis and before_count.value is not None and after_count.value is not None
|
|
263
|
+
else None
|
|
264
|
+
)
|
|
265
|
+
kind = (
|
|
266
|
+
"observed"
|
|
267
|
+
if exact
|
|
268
|
+
else "estimated"
|
|
269
|
+
if same_basis and before_count.provenance == "estimated_words"
|
|
270
|
+
else "declared"
|
|
271
|
+
)
|
|
272
|
+
receipt.update(
|
|
273
|
+
state="applied",
|
|
274
|
+
output_input_hash=fingerprint(after),
|
|
275
|
+
token_counts={"before": before_count.to_dict(), "after": after_count.to_dict()},
|
|
276
|
+
compression_usage=[usage.to_dict() for usage in usages],
|
|
277
|
+
)
|
|
278
|
+
record_experiment_observations(
|
|
279
|
+
{
|
|
280
|
+
"context_applied": observation(True, unit="boolean", source_ref=source),
|
|
281
|
+
"context_state": observation("applied", unit="state", source_ref=source),
|
|
282
|
+
"input_tokens_before": observation(
|
|
283
|
+
before_count.value,
|
|
284
|
+
kind=_count_kind(before_count),
|
|
285
|
+
unit="tokens",
|
|
286
|
+
source_ref=source,
|
|
287
|
+
),
|
|
288
|
+
"input_tokens_after": observation(
|
|
289
|
+
after_count.value,
|
|
290
|
+
kind=_count_kind(after_count),
|
|
291
|
+
unit="tokens",
|
|
292
|
+
source_ref=source,
|
|
293
|
+
),
|
|
294
|
+
"input_tokens_removed": observation(
|
|
295
|
+
delta, kind=kind, unit="tokens", source_ref=source
|
|
296
|
+
),
|
|
297
|
+
"exact_token_reduction": observation(
|
|
298
|
+
delta > 0 if exact else None, unit="boolean", source_ref=source
|
|
299
|
+
),
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
_deadline(request)
|
|
303
|
+
result = backend.invoke(replace(request, _input_json=canonical(after)))
|
|
304
|
+
if type(result) is not ExperimentResult:
|
|
305
|
+
_close_invalid(result)
|
|
306
|
+
raise ValueError("backend must return ExperimentResult")
|
|
307
|
+
receipt["backend_usage"] = result.usage.to_dict()
|
|
308
|
+
usages.append(result.usage)
|
|
309
|
+
return ExperimentResult(result.output, usage=_sum_usage(usages))
|
|
310
|
+
except BaseException:
|
|
311
|
+
if receipt["state"] == "started":
|
|
312
|
+
receipt["state"] = "failed"
|
|
313
|
+
try:
|
|
314
|
+
record_experiment_observations(
|
|
315
|
+
{
|
|
316
|
+
"context_applied": observation(
|
|
317
|
+
False, unit="boolean", source_ref=source
|
|
318
|
+
),
|
|
319
|
+
"context_state": observation("failed", unit="state", source_ref=source),
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
except BaseException:
|
|
323
|
+
receipt["capture_error"] = "observation_capture_failed"
|
|
324
|
+
raise
|
|
325
|
+
|
|
326
|
+
return ExperimentRunner(
|
|
327
|
+
candidate_id,
|
|
328
|
+
"1.0",
|
|
329
|
+
"agentloop.context-reduction:1.0",
|
|
330
|
+
"sha256:" + config_hash,
|
|
331
|
+
invoke,
|
|
332
|
+
reservation=reservation
|
|
333
|
+
if reservation is not None
|
|
334
|
+
else Reservation()
|
|
335
|
+
if compressing
|
|
336
|
+
else backend.reservation,
|
|
337
|
+
)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Explicit, owned request contracts for optional tool-result summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
from agentloop.budget_types import DispatchOptions, ResourceUsage
|
|
13
|
+
|
|
14
|
+
CONTEXT_VERSION = "1.0"
|
|
15
|
+
CONTEXT_KEY = "agentloop.context_transform"
|
|
16
|
+
_IDENTIFIER = re.compile(r"[A-Za-z0-9_.:-]{1,96}\Z")
|
|
17
|
+
EXACT_COUNTS = {"provider", "tokenizer", "user_supplied"}
|
|
18
|
+
_REASONS = frozenset(
|
|
19
|
+
"invalid_history invalid_role invalid_tool_calls unsupported_content "
|
|
20
|
+
"unpaired_tool_result missing_tool_result invalid_tool_reference ambiguous_tool_call "
|
|
21
|
+
"token_counter_failed invalid_token_count unsupported_adapter streaming_not_supported "
|
|
22
|
+
"unknown_tool_selection summary_count_limit summary_failed invalid_summary "
|
|
23
|
+
"token_counter_changed context_limit_unverified tokens_not_reduced "
|
|
24
|
+
"unexpected_streaming_result recursive_context_transform evidence_limit "
|
|
25
|
+
"invalid_trace_evidence invalid_json_request invalid_request request_too_large "
|
|
26
|
+
"invalid_evidence_selection transformation_error".split()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ContextTransformError(ValueError):
|
|
31
|
+
"""A bounded failure category; never include request or summary content."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, reason):
|
|
34
|
+
if not isinstance(reason, str) or reason not in _REASONS:
|
|
35
|
+
reason = "transformation_error"
|
|
36
|
+
self.reason_code = reason
|
|
37
|
+
super().__init__(f"Context transformation failed: {reason}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonical(value):
|
|
41
|
+
try:
|
|
42
|
+
|
|
43
|
+
def validate_keys(item):
|
|
44
|
+
if isinstance(item, dict):
|
|
45
|
+
if any(not isinstance(key, str) for key in item):
|
|
46
|
+
raise TypeError("non-string JSON key")
|
|
47
|
+
for child in item.values():
|
|
48
|
+
validate_keys(child)
|
|
49
|
+
elif isinstance(item, (list, tuple)):
|
|
50
|
+
for child in item:
|
|
51
|
+
validate_keys(child)
|
|
52
|
+
|
|
53
|
+
validate_keys(value)
|
|
54
|
+
return json.dumps(
|
|
55
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
|
|
56
|
+
)
|
|
57
|
+
except (ValueError, TypeError, RecursionError):
|
|
58
|
+
raise ContextTransformError("invalid_json_request") from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fingerprint(value):
|
|
62
|
+
return sha256(canonical(value).encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def identifier(value, label):
|
|
66
|
+
if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value):
|
|
67
|
+
raise ValueError(f"{label} must be a bounded identifier")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def synchronous(function):
|
|
71
|
+
return callable(function) and not any(
|
|
72
|
+
check(function) or check(getattr(function, "__call__", None))
|
|
73
|
+
for check in (
|
|
74
|
+
inspect.iscoroutinefunction,
|
|
75
|
+
inspect.isgeneratorfunction,
|
|
76
|
+
inspect.isasyncgenfunction,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, init=False)
|
|
82
|
+
class ContextRequest:
|
|
83
|
+
"""Own a JSON request; every payload export is a fresh deep copy.
|
|
84
|
+
|
|
85
|
+
Only explicitly optional tool results can change. All other messages and
|
|
86
|
+
all provider options are preserved. Protected tool results take precedence.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_json: str = field(repr=False)
|
|
90
|
+
optional_tool_results: tuple[str, ...]
|
|
91
|
+
protected_tool_results: tuple[str, ...]
|
|
92
|
+
|
|
93
|
+
def __init__(self, payload, *, optional_tool_results=(), protected_tool_results=()):
|
|
94
|
+
if not isinstance(payload, dict):
|
|
95
|
+
raise ContextTransformError("invalid_request")
|
|
96
|
+
canonical(payload) # Validate finite JSON without changing option ordering.
|
|
97
|
+
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
98
|
+
if len(encoded.encode("utf-8")) > 1_000_000:
|
|
99
|
+
raise ContextTransformError("request_too_large")
|
|
100
|
+
for name, values in (
|
|
101
|
+
("optional_tool_results", optional_tool_results),
|
|
102
|
+
("protected_tool_results", protected_tool_results),
|
|
103
|
+
):
|
|
104
|
+
if (
|
|
105
|
+
not isinstance(values, (tuple, list, set, frozenset))
|
|
106
|
+
or len(values) > 256
|
|
107
|
+
or any(
|
|
108
|
+
not isinstance(value, str) or not value or len(value) > 256 for value in values
|
|
109
|
+
)
|
|
110
|
+
):
|
|
111
|
+
raise ContextTransformError("invalid_evidence_selection")
|
|
112
|
+
object.__setattr__(self, name, tuple(sorted(set(values))))
|
|
113
|
+
object.__setattr__(self, "_json", encoded)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def payload(self):
|
|
117
|
+
return json.loads(self._json)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class ContextPolicyConfig:
|
|
122
|
+
policy_id: str
|
|
123
|
+
version: str
|
|
124
|
+
branch_id: str
|
|
125
|
+
quality_ref: str
|
|
126
|
+
max_summary_chars: int = 512
|
|
127
|
+
max_summaries: int = 1
|
|
128
|
+
max_input_tokens: int | None = None
|
|
129
|
+
on_invalid: str = "error"
|
|
130
|
+
|
|
131
|
+
def __post_init__(self):
|
|
132
|
+
for name in ("policy_id", "version", "branch_id", "quality_ref"):
|
|
133
|
+
identifier(getattr(self, name), name)
|
|
134
|
+
for name, maximum in (("max_summary_chars", 8000), ("max_summaries", 8)):
|
|
135
|
+
value = getattr(self, name)
|
|
136
|
+
if type(value) is not int or not 1 <= value <= maximum:
|
|
137
|
+
raise ValueError(f"{name} must be a positive bounded integer")
|
|
138
|
+
if self.max_input_tokens is not None and (
|
|
139
|
+
type(self.max_input_tokens) is not int or self.max_input_tokens < 1
|
|
140
|
+
):
|
|
141
|
+
raise ValueError("max_input_tokens must be positive when configured")
|
|
142
|
+
if self.on_invalid not in {"error", "original"}:
|
|
143
|
+
raise ValueError("on_invalid must be error or original")
|
|
144
|
+
|
|
145
|
+
def to_dict(self):
|
|
146
|
+
return asdict(self)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class ContextAdapter:
|
|
151
|
+
"""Host declaration for the first supported synchronous text-chat boundary."""
|
|
152
|
+
|
|
153
|
+
name: str
|
|
154
|
+
before_model: bool = True
|
|
155
|
+
format: str = "text_tool_messages_v1"
|
|
156
|
+
|
|
157
|
+
def __post_init__(self):
|
|
158
|
+
identifier(self.name, "adapter name")
|
|
159
|
+
if type(self.before_model) is not bool or not isinstance(self.format, str):
|
|
160
|
+
raise ValueError("invalid adapter declaration")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class ContextTokenCount:
|
|
165
|
+
value: int | None = None
|
|
166
|
+
provenance: str = "unavailable"
|
|
167
|
+
reference: str | None = None
|
|
168
|
+
|
|
169
|
+
def __post_init__(self):
|
|
170
|
+
if self.provenance not in EXACT_COUNTS | {"estimated_words", "unavailable"}:
|
|
171
|
+
raise ValueError("unsupported token provenance")
|
|
172
|
+
if self.value is not None and (type(self.value) is not int or self.value < 0):
|
|
173
|
+
raise ValueError("token count must be nonnegative")
|
|
174
|
+
if self.provenance == "unavailable" and self.value is not None:
|
|
175
|
+
raise ValueError("unavailable token count cannot claim a value")
|
|
176
|
+
if self.provenance != "unavailable" and (self.value is None or self.reference is None):
|
|
177
|
+
raise ValueError("available token counts require a versioned reference")
|
|
178
|
+
if self.reference is not None:
|
|
179
|
+
identifier(self.reference, "tokenizer reference")
|
|
180
|
+
|
|
181
|
+
def to_dict(self):
|
|
182
|
+
return asdict(self)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class SummaryInput:
|
|
187
|
+
"""Untrusted tool data; the callback must preserve this trust classification."""
|
|
188
|
+
|
|
189
|
+
content: str = field(repr=False)
|
|
190
|
+
max_chars: int
|
|
191
|
+
source_hash: str
|
|
192
|
+
trust_class: str = field(default="tool", init=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class SummaryResult:
|
|
197
|
+
text: str = field(repr=False)
|
|
198
|
+
usage: ResourceUsage | None = None
|
|
199
|
+
|
|
200
|
+
def __post_init__(self):
|
|
201
|
+
if not isinstance(self.text, str) or (
|
|
202
|
+
self.usage is not None and type(self.usage) is not ResourceUsage
|
|
203
|
+
):
|
|
204
|
+
raise ValueError("summary requires text and normalized usage")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class SummaryBackend:
|
|
209
|
+
reference: str
|
|
210
|
+
summarize: Callable[[SummaryInput], SummaryResult] = field(repr=False, compare=False)
|
|
211
|
+
dispatch: DispatchOptions = field(default_factory=DispatchOptions)
|
|
212
|
+
|
|
213
|
+
def __post_init__(self):
|
|
214
|
+
identifier(self.reference, "summarizer reference")
|
|
215
|
+
if not synchronous(self.summarize) or type(self.dispatch) is not DispatchOptions:
|
|
216
|
+
raise ValueError("summarizer must be a synchronous declared dispatch")
|