repro-engine 1.2.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.
- repro/__init__.py +8 -0
- repro/__main__.py +3 -0
- repro/agents/__init__.py +67 -0
- repro/agents/backend.py +129 -0
- repro/agents/bias.py +109 -0
- repro/agents/cassette.py +413 -0
- repro/agents/cost.py +66 -0
- repro/agents/dispatch.py +148 -0
- repro/agents/events.py +165 -0
- repro/agents/evidence.py +254 -0
- repro/agents/gap.py +181 -0
- repro/agents/lockdown.py +131 -0
- repro/agents/openai_compat.py +474 -0
- repro/agents/prober.py +173 -0
- repro/agents/prompts/__init__.py +143 -0
- repro/agents/prompts/gap.md +20 -0
- repro/agents/prompts/lock.json +7 -0
- repro/agents/prompts/probe.md +22 -0
- repro/agents/prompts/qa.md +24 -0
- repro/agents/prompts/reader.md +28 -0
- repro/agents/prompts/reviewer.md +34 -0
- repro/agents/qa.py +173 -0
- repro/agents/reader.py +167 -0
- repro/agents/reviewer.py +191 -0
- repro/agents/sdk.py +236 -0
- repro/agents/setup.py +117 -0
- repro/agents/sources.py +102 -0
- repro/agents/stages.py +539 -0
- repro/agents/tools.py +231 -0
- repro/agents/untrusted.py +48 -0
- repro/agents/validate.py +169 -0
- repro/analysis/__init__.py +34 -0
- repro/analysis/_grimp_worker.py +66 -0
- repro/analysis/api.py +275 -0
- repro/analysis/blast.py +110 -0
- repro/analysis/changes.py +315 -0
- repro/analysis/imports.py +218 -0
- repro/analysis/modules.py +59 -0
- repro/analysis/static.py +54 -0
- repro/analysis/symbols.py +192 -0
- repro/api/__init__.py +5 -0
- repro/api/app.py +212 -0
- repro/api/cockpit/assets/JetBrainsMono-OFL.txt +93 -0
- repro/api/cockpit/assets/atlas.worker-CP1cHLLd.js +1 -0
- repro/api/cockpit/assets/demo-run-C9SGu-Og.jsonl +2334 -0
- repro/api/cockpit/assets/fleet-Bn3DZ83b.css +1 -0
- repro/api/cockpit/assets/fleet-C5arwxhB.js +1 -0
- repro/api/cockpit/assets/force-DkXbt76I.js +1 -0
- repro/api/cockpit/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- repro/api/cockpit/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- repro/api/cockpit/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- repro/api/cockpit/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- repro/api/cockpit/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- repro/api/cockpit/assets/jsxRuntime-B9Idad31.js +2 -0
- repro/api/cockpit/assets/jsxRuntime-DHHyguPz.css +1 -0
- repro/api/cockpit/assets/layout.worker-DLYbolgN.js +1 -0
- repro/api/cockpit/assets/main-BbRvaUOf.css +1 -0
- repro/api/cockpit/assets/main-CpPW1Zab.js +4376 -0
- repro/api/cockpit/assets/stress-run-TCZCEJwc.jsonl +18542 -0
- repro/api/cockpit/fleet.html +15 -0
- repro/api/cockpit/index.html +15 -0
- repro/api/stream.py +195 -0
- repro/api/token.py +48 -0
- repro/api/ui.py +17 -0
- repro/bus.py +150 -0
- repro/card/__init__.py +30 -0
- repro/card/assemble.py +224 -0
- repro/card/exports.py +329 -0
- repro/card/job.py +76 -0
- repro/card/markdown.py +175 -0
- repro/cli.py +923 -0
- repro/clock.py +15 -0
- repro/config/__init__.py +25 -0
- repro/config/settings.py +297 -0
- repro/doctor.py +461 -0
- repro/gitops/__init__.py +36 -0
- repro/gitops/changes.py +186 -0
- repro/gitops/git.py +121 -0
- repro/gitops/snapshot.py +398 -0
- repro/gitops/worktree.py +94 -0
- repro/ids.py +85 -0
- repro/integrations/__init__.py +1 -0
- repro/integrations/github/__init__.py +72 -0
- repro/integrations/github/action.py +183 -0
- repro/integrations/github/api.py +190 -0
- repro/integrations/github/mirror.py +104 -0
- repro/integrations/github/pr.py +127 -0
- repro/integrations/github/report.py +256 -0
- repro/integrations/mcp_server.py +224 -0
- repro/jsts.py +271 -0
- repro/logs.py +144 -0
- repro/orchestrator/__init__.py +43 -0
- repro/orchestrator/jobs.py +207 -0
- repro/orchestrator/runner.py +449 -0
- repro/paths.py +101 -0
- repro/pipeline.py +288 -0
- repro/policy/__init__.py +23 -0
- repro/policy/gap.py +173 -0
- repro/policy/invariants.py +379 -0
- repro/policy/loader.py +330 -0
- repro/policy/repro-yml.schema.json +100 -0
- repro/policy/stage.py +160 -0
- repro/policy/status.py +263 -0
- repro/provision/__init__.py +32 -0
- repro/provision/deps.py +274 -0
- repro/provision/env.py +541 -0
- repro/provision/project.py +416 -0
- repro/py.typed +0 -0
- repro/readable.py +73 -0
- repro/run_stage.py +1024 -0
- repro/runner/__init__.py +25 -0
- repro/runner/behaviour.py +341 -0
- repro/runner/observations.py +648 -0
- repro/runner/outcomes.py +193 -0
- repro/runner/perf.py +358 -0
- repro/runner/probes.py +681 -0
- repro/runner/pytest.py +549 -0
- repro/runner/pytest_config.py +125 -0
- repro/runner/selection.py +360 -0
- repro/runner/sides.py +63 -0
- repro/runner/testdiff.py +262 -0
- repro/runner/trace.py +195 -0
- repro/runner/twosided.py +435 -0
- repro/sandbox/__init__.py +30 -0
- repro/sandbox/_hook/repro_encode.py +734 -0
- repro/sandbox/_hook/repro_probe.py +77 -0
- repro/sandbox/_hook/repro_pytest.py +459 -0
- repro/sandbox/_hook/repro_trace.py +333 -0
- repro/sandbox/_hook/sitecustomize.py +199 -0
- repro/sandbox/_rlimits.py +55 -0
- repro/sandbox/detect.py +105 -0
- repro/sandbox/docker.py +462 -0
- repro/sandbox/evidence.py +68 -0
- repro/sandbox/image/Dockerfile +20 -0
- repro/sandbox/image.py +44 -0
- repro/sandbox/limits.py +33 -0
- repro/sandbox/process.py +336 -0
- repro/sandbox/winjob.py +242 -0
- repro/scrub.py +86 -0
- repro/store/__init__.py +24 -0
- repro/store/artifacts.py +145 -0
- repro/store/cbor.py +191 -0
- repro/store/db.py +118 -0
- repro/store/reader.py +132 -0
- repro/store/schema.py +165 -0
- repro/store/writer.py +452 -0
- repro/symbolic.py +288 -0
- repro_engine-1.2.0.dist-info/METADATA +196 -0
- repro_engine-1.2.0.dist-info/RECORD +151 -0
- repro_engine-1.2.0.dist-info/WHEEL +4 -0
- repro_engine-1.2.0.dist-info/entry_points.txt +3 -0
repro/agents/cassette.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Cassettes: agent calls recorded once, then replayed offline (G02; ARCHITECTURE §10.5).
|
|
2
|
+
|
|
3
|
+
A cassette holds one agent call: what was asked (the request, kept for review), what happened
|
|
4
|
+
(the model's progress and the Repro tools it called, with what each returned, in order) and
|
|
5
|
+
how it ended (the result). It is keyed by the prompt's version, the model and a digest of the
|
|
6
|
+
input, and stored at ``<root>/<prompt name>/<prompt version>/<model>/<digest>.json``.
|
|
7
|
+
|
|
8
|
+
**Run-specific identifiers.** Evidence, event and run IDs are ULIDs drawn afresh in every run,
|
|
9
|
+
and prompts carry them, so the same call made in two runs differs in its IDs and nothing else.
|
|
10
|
+
The digest is taken over the input with each distinct ID replaced by its kind and the order it
|
|
11
|
+
was first seen in (``ev_#1``, ``run_#2``, ...), so the same call in another run has the same
|
|
12
|
+
digest. On replay, the recording's IDs are mapped to the current run's by that order, in the
|
|
13
|
+
tool calls made again, the progress shown and the answer: an ``ev_`` ID the answer cites is the
|
|
14
|
+
current run's. An ID the model made up, never seen in its input, is left as it is.
|
|
15
|
+
|
|
16
|
+
**Replay never reaches a model**: ``replay`` mode has no other backend to call. A missing
|
|
17
|
+
cassette raises ``MissingCassetteError``, so a test without its recording fails instead of calling
|
|
18
|
+
the API. The recorded tool calls are made again with the current run's tools, and each must
|
|
19
|
+
return what it returned when recorded (IDs normalised); otherwise ``StaleCassetteError``: the
|
|
20
|
+
evidence the model saw has changed, and so might its answer.
|
|
21
|
+
|
|
22
|
+
**Recording** (``record`` mode) serves a call from its cassette when one exists and is still
|
|
23
|
+
true (not stale), and otherwise makes it through the given backend, writes the cassette and
|
|
24
|
+
returns the result: recording a run again calls the model only for what is new or changed, and
|
|
25
|
+
leaves the rest as it was (delete a cassette to record it again). A call that ends in
|
|
26
|
+
``timeout`` or ``error`` is not recorded: that is the infrastructure, not the model.
|
|
27
|
+
Cassettes are canonical JSON (sorted keys, two-space indent, LF), scrubbed of credentials like
|
|
28
|
+
every event (``repro.scrub``) and of the home directory (written ``~``), and hold no recording
|
|
29
|
+
time, so a cassette changes only when the call does.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import tempfile
|
|
37
|
+
from collections.abc import Mapping
|
|
38
|
+
from dataclasses import dataclass, field, replace
|
|
39
|
+
from importlib import metadata
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Final, Literal
|
|
42
|
+
|
|
43
|
+
from repro.agents.backend import (
|
|
44
|
+
AgentRequest,
|
|
45
|
+
AgentResult,
|
|
46
|
+
LLMBackend,
|
|
47
|
+
OnProgress,
|
|
48
|
+
Progress,
|
|
49
|
+
ReproTool,
|
|
50
|
+
Usage,
|
|
51
|
+
)
|
|
52
|
+
from repro.agents.untrusted import without_home
|
|
53
|
+
from repro.scrub import scrub
|
|
54
|
+
|
|
55
|
+
CASSETTE_VERSION: Final = 1
|
|
56
|
+
#: The run-specific identifiers a call's input can carry (the event schema's ``RunId``,
|
|
57
|
+
#: ``EventId`` and ``EvidenceId``).
|
|
58
|
+
RUN_SPECIFIC_ID: Final = re.compile(r"\b(ev|evt|run)_[0-9A-HJKMNP-TV-Z]{26}\b")
|
|
59
|
+
#: Outcomes that say something about the infrastructure, not the model: never recorded.
|
|
60
|
+
UNRECORDED: Final = frozenset({"timeout", "error"})
|
|
61
|
+
_PATH_UNSAFE: Final = re.compile(r"[^A-Za-z0-9._-]")
|
|
62
|
+
|
|
63
|
+
Mode = Literal["replay", "record"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CassetteError(Exception):
|
|
67
|
+
"""A cassette that cannot serve the call."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MissingCassetteError(CassetteError, LookupError):
|
|
71
|
+
"""No cassette for this call: in replay mode the call is never made."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class StaleCassetteError(CassetteError):
|
|
75
|
+
"""A tool returned something other than what the model saw when the call was recorded."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class Ids:
|
|
80
|
+
"""The run-specific IDs of one call, in the order they were first seen."""
|
|
81
|
+
|
|
82
|
+
seen: list[str] = field(default_factory=list)
|
|
83
|
+
_index: dict[str, int] = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
def normalise(self, text: str) -> str:
|
|
86
|
+
"""``text`` with each ID replaced by its kind and its first-seen position; IDs not
|
|
87
|
+
seen before are added."""
|
|
88
|
+
|
|
89
|
+
def placeholder(match: re.Match[str]) -> str:
|
|
90
|
+
ident = match.group(0)
|
|
91
|
+
if ident not in self._index:
|
|
92
|
+
self._index[ident] = len(self.seen)
|
|
93
|
+
self.seen.append(ident)
|
|
94
|
+
return f"{match.group(1)}_#{self._index[ident] + 1}"
|
|
95
|
+
|
|
96
|
+
return RUN_SPECIFIC_ID.sub(placeholder, text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _canonical(value: Any) -> str:
|
|
100
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _sha256(text: str) -> str:
|
|
104
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _input(request: AgentRequest) -> dict[str, Any]:
|
|
108
|
+
"""What the model is given, which decides what it does. Not the budget: a run's ledger
|
|
109
|
+
can lower a call's dollar budget without changing the call."""
|
|
110
|
+
return {
|
|
111
|
+
"role": request.role,
|
|
112
|
+
"system_prompt": request.system_prompt,
|
|
113
|
+
"prompt": request.prompt,
|
|
114
|
+
"tools": [
|
|
115
|
+
{"name": t.name, "description": t.description, "input_schema": dict(t.input_schema)}
|
|
116
|
+
for t in request.tools
|
|
117
|
+
],
|
|
118
|
+
"output_schema": dict(request.output_schema) if request.output_schema is not None else None,
|
|
119
|
+
"effort": request.effort,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _normalised(value: Any, ids: Ids) -> Any:
|
|
124
|
+
"""``value`` with the IDs in its strings normalised, keys and values in the order the
|
|
125
|
+
canonical JSON writes them. Each string is normalised as it is, not as JSON writes it: an
|
|
126
|
+
ID right after a line break would follow the ``\\n`` escape's ``n``, where the pattern's
|
|
127
|
+
word boundary does not match, and stay as it is."""
|
|
128
|
+
if isinstance(value, str):
|
|
129
|
+
return ids.normalise(value)
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
return {ids.normalise(key): _normalised(value[key], ids) for key in sorted(value)}
|
|
132
|
+
if isinstance(value, list):
|
|
133
|
+
return [_normalised(item, ids) for item in value]
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def input_digest(request: AgentRequest, ids: Ids | None = None) -> str:
|
|
138
|
+
"""The SHA-256 of the call's input, its run-specific IDs normalised (into ``ids``)."""
|
|
139
|
+
found = ids if ids is not None else Ids()
|
|
140
|
+
return _sha256(_canonical(_normalised(_input(request), found)))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class CassetteKey:
|
|
145
|
+
prompt_version: str
|
|
146
|
+
model: str
|
|
147
|
+
input_sha256: str
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def of(cls, request: AgentRequest, ids: Ids | None = None) -> "CassetteKey":
|
|
151
|
+
return cls(request.prompt_version, request.model, input_digest(request, ids))
|
|
152
|
+
|
|
153
|
+
def path(self, root: Path) -> Path:
|
|
154
|
+
name, version = self.prompt_version.split("/")
|
|
155
|
+
model = _PATH_UNSAFE.sub("_", self.model)
|
|
156
|
+
return root / name / version / model / f"{self.input_sha256}.json"
|
|
157
|
+
|
|
158
|
+
def as_dict(self) -> dict[str, str]:
|
|
159
|
+
return {
|
|
160
|
+
"prompt_version": self.prompt_version,
|
|
161
|
+
"model": self.model,
|
|
162
|
+
"input_sha256": self.input_sha256,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _translate(text: str, mapping: Mapping[str, str]) -> str:
|
|
167
|
+
"""The recording's IDs in ``text`` as the current run's; IDs it never saw stay."""
|
|
168
|
+
return RUN_SPECIFIC_ID.sub(lambda match: mapping.get(match.group(0), match.group(0)), text)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _translate_json(value: Any, mapping: Mapping[str, str]) -> Any:
|
|
172
|
+
return json.loads(_translate(json.dumps(value, ensure_ascii=False), mapping))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _usage(value: Mapping[str, Any]) -> Usage:
|
|
176
|
+
return Usage(
|
|
177
|
+
input_tokens=int(value["input_tokens"]),
|
|
178
|
+
output_tokens=int(value["output_tokens"]),
|
|
179
|
+
cache_read_tokens=int(value["cache_read_tokens"]),
|
|
180
|
+
cost_usd=float(value["cost_usd"]),
|
|
181
|
+
priced=bool(value.get("priced", True)),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _usage_dict(usage: Usage) -> dict[str, Any]:
|
|
186
|
+
return {
|
|
187
|
+
"input_tokens": usage.input_tokens,
|
|
188
|
+
"output_tokens": usage.output_tokens,
|
|
189
|
+
"cache_read_tokens": usage.cache_read_tokens,
|
|
190
|
+
"cost_usd": usage.cost_usd,
|
|
191
|
+
"priced": usage.priced,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _versions() -> dict[str, str]:
|
|
196
|
+
"""What the call was recorded with: the Agent SDK and the CLI it bundles."""
|
|
197
|
+
try:
|
|
198
|
+
sdk = metadata.version("claude-agent-sdk")
|
|
199
|
+
from claude_agent_sdk._cli_version import __cli_version__ # noqa: PLC0415 - optional
|
|
200
|
+
|
|
201
|
+
return {"claude_agent_sdk": sdk, "claude_code": str(__cli_version__)}
|
|
202
|
+
except (metadata.PackageNotFoundError, ImportError):
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _unhomed(value: Any) -> Any:
|
|
207
|
+
"""``value`` with the user's home directory written ``~`` in every string: a cassette is
|
|
208
|
+
committed, and must not carry the machine it was recorded on (G03)."""
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
return without_home(value)
|
|
211
|
+
if isinstance(value, dict):
|
|
212
|
+
return {key: _unhomed(item) for key, item in value.items()}
|
|
213
|
+
if isinstance(value, list):
|
|
214
|
+
return [_unhomed(item) for item in value]
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _write(path: Path, document: Mapping[str, Any]) -> None:
|
|
219
|
+
"""Writes the cassette whole or not at all."""
|
|
220
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
data = (json.dumps(document, sort_keys=True, indent=2, ensure_ascii=False) + "\n").encode()
|
|
222
|
+
handle, temporary = tempfile.mkstemp(prefix=".cassette-", dir=path.parent)
|
|
223
|
+
try:
|
|
224
|
+
with os.fdopen(handle, "wb") as out:
|
|
225
|
+
out.write(data)
|
|
226
|
+
Path(temporary).replace(path)
|
|
227
|
+
except BaseException:
|
|
228
|
+
Path(temporary).unlink(missing_ok=True)
|
|
229
|
+
raise
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class _Recorder:
|
|
233
|
+
"""Collects one call's transcript as it happens: progress and tool calls, in order."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, ids: Ids, on_progress: OnProgress | None) -> None:
|
|
236
|
+
self.ids = ids
|
|
237
|
+
self.on_progress = on_progress
|
|
238
|
+
self.transcript: list[dict[str, Any]] = []
|
|
239
|
+
|
|
240
|
+
def progress(self, update: Progress) -> None:
|
|
241
|
+
self.transcript.append(
|
|
242
|
+
{
|
|
243
|
+
"progress": {
|
|
244
|
+
"state": update.state,
|
|
245
|
+
"turn": update.turn,
|
|
246
|
+
"tool": update.tool,
|
|
247
|
+
"detail": update.detail,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
if self.on_progress is not None:
|
|
252
|
+
self.on_progress(update)
|
|
253
|
+
|
|
254
|
+
def wrap(self, spec: ReproTool) -> ReproTool:
|
|
255
|
+
async def handle(arguments: Mapping[str, Any]) -> str:
|
|
256
|
+
try:
|
|
257
|
+
output = await spec.handler(arguments)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
self._tool(spec.name, arguments, str(exc), error=True)
|
|
260
|
+
raise
|
|
261
|
+
self._tool(spec.name, arguments, output, error=False)
|
|
262
|
+
return output
|
|
263
|
+
|
|
264
|
+
return replace(spec, handler=handle)
|
|
265
|
+
|
|
266
|
+
def _tool(self, name: str, arguments: Mapping[str, Any], output: str, *, error: bool) -> None:
|
|
267
|
+
call = {
|
|
268
|
+
"name": name,
|
|
269
|
+
"input": dict(arguments),
|
|
270
|
+
"output": output,
|
|
271
|
+
"output_sha256": _sha256(self.ids.normalise(output)),
|
|
272
|
+
"error": error,
|
|
273
|
+
}
|
|
274
|
+
self.transcript.append({"tool": call})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class CassetteBackend:
|
|
278
|
+
"""An ``LLMBackend`` over cassettes in ``root``: ``replay`` serves recorded calls only;
|
|
279
|
+
``record`` makes each call through ``backend`` and records it."""
|
|
280
|
+
|
|
281
|
+
def __init__(self, root: Path, mode: Mode, backend: LLMBackend | None = None) -> None:
|
|
282
|
+
if mode == "record" and backend is None:
|
|
283
|
+
raise ValueError("recording needs a backend to make the calls")
|
|
284
|
+
if mode == "replay" and backend is not None:
|
|
285
|
+
raise ValueError("replay never makes a call: give it no backend")
|
|
286
|
+
self.root = root
|
|
287
|
+
self.mode = mode
|
|
288
|
+
self.backend = backend
|
|
289
|
+
|
|
290
|
+
async def run(
|
|
291
|
+
self, request: AgentRequest, on_progress: OnProgress | None = None
|
|
292
|
+
) -> AgentResult:
|
|
293
|
+
if self.mode == "record":
|
|
294
|
+
return await self._record(request, on_progress)
|
|
295
|
+
return await self._replay(request, on_progress)
|
|
296
|
+
|
|
297
|
+
async def _record(self, request: AgentRequest, on_progress: OnProgress | None) -> AgentResult:
|
|
298
|
+
assert self.backend is not None # noqa: S101 - the constructor guarantees it
|
|
299
|
+
if CassetteKey.of(request).path(self.root).exists():
|
|
300
|
+
try:
|
|
301
|
+
return await self._replay(request, on_progress)
|
|
302
|
+
except StaleCassetteError:
|
|
303
|
+
pass # the evidence changed since: record the call again
|
|
304
|
+
ids = Ids()
|
|
305
|
+
key = CassetteKey.of(request, ids)
|
|
306
|
+
recorder = _Recorder(ids, on_progress)
|
|
307
|
+
recorded = replace(request, tools=tuple(recorder.wrap(t) for t in request.tools))
|
|
308
|
+
result = await self.backend.run(recorded, recorder.progress)
|
|
309
|
+
if result.outcome in UNRECORDED:
|
|
310
|
+
return result
|
|
311
|
+
document = {
|
|
312
|
+
"cassette": CASSETTE_VERSION,
|
|
313
|
+
"key": key.as_dict(),
|
|
314
|
+
"request": {**_input(request), "budget": _budget(request)},
|
|
315
|
+
"ids": list(ids.seen),
|
|
316
|
+
"transcript": recorder.transcript,
|
|
317
|
+
"result": {
|
|
318
|
+
"outcome": result.outcome,
|
|
319
|
+
"structured": result.structured,
|
|
320
|
+
"text": result.text,
|
|
321
|
+
"turns": result.turns,
|
|
322
|
+
"usage": _usage_dict(result.usage),
|
|
323
|
+
"by_model": {m: _usage_dict(u) for m, u in sorted(result.by_model.items())},
|
|
324
|
+
"detail": result.detail,
|
|
325
|
+
"tool_calls": [[name, allowed] for name, allowed in result.tool_calls],
|
|
326
|
+
},
|
|
327
|
+
"recorded_with": _versions(),
|
|
328
|
+
}
|
|
329
|
+
_write(key.path(self.root), _unhomed(scrub(document)))
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
def _load(self, key: CassetteKey) -> dict[str, Any]:
|
|
333
|
+
path = key.path(self.root)
|
|
334
|
+
try:
|
|
335
|
+
document: dict[str, Any] = json.loads(path.read_bytes())
|
|
336
|
+
except FileNotFoundError:
|
|
337
|
+
raise MissingCassetteError(
|
|
338
|
+
f"no cassette for {key.prompt_version} on {key.model}, input "
|
|
339
|
+
f"{key.input_sha256[:12]}: expected {path}; record it and commit it"
|
|
340
|
+
) from None
|
|
341
|
+
except (OSError, ValueError) as exc:
|
|
342
|
+
raise CassetteError(f"{path}: cannot be read: {exc}") from None
|
|
343
|
+
if document.get("cassette") != CASSETTE_VERSION or document.get("key") != key.as_dict():
|
|
344
|
+
raise CassetteError(f"{path}: not a version {CASSETTE_VERSION} cassette for this call")
|
|
345
|
+
return document
|
|
346
|
+
|
|
347
|
+
async def _replay(self, request: AgentRequest, on_progress: OnProgress | None) -> AgentResult:
|
|
348
|
+
ids = Ids()
|
|
349
|
+
document = self._load(CassetteKey.of(request, ids))
|
|
350
|
+
recorded_ids: list[str] = document["ids"]
|
|
351
|
+
tools = {t.name: t for t in request.tools}
|
|
352
|
+
|
|
353
|
+
def mapping() -> dict[str, str]:
|
|
354
|
+
return dict(zip(recorded_ids, ids.seen, strict=False))
|
|
355
|
+
|
|
356
|
+
for entry in document["transcript"]:
|
|
357
|
+
if "progress" in entry:
|
|
358
|
+
step = entry["progress"]
|
|
359
|
+
if on_progress is not None:
|
|
360
|
+
detail = step["detail"]
|
|
361
|
+
on_progress(
|
|
362
|
+
Progress(
|
|
363
|
+
step["state"],
|
|
364
|
+
int(step["turn"]),
|
|
365
|
+
step["tool"],
|
|
366
|
+
_translate(detail, mapping()) if detail is not None else None,
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
await _call_again(entry["tool"], tools, ids, mapping())
|
|
371
|
+
final = mapping()
|
|
372
|
+
result = document["result"]
|
|
373
|
+
structured = result["structured"]
|
|
374
|
+
return AgentResult(
|
|
375
|
+
outcome=result["outcome"],
|
|
376
|
+
structured=_translate_json(structured, final) if structured is not None else None,
|
|
377
|
+
text=_translate(result["text"], final),
|
|
378
|
+
turns=int(result["turns"]),
|
|
379
|
+
usage=_usage(result["usage"]),
|
|
380
|
+
detail=_translate(result["detail"], final),
|
|
381
|
+
tool_calls=tuple((str(name), bool(allowed)) for name, allowed in result["tool_calls"]),
|
|
382
|
+
by_model={model: _usage(usage) for model, usage in result["by_model"].items()},
|
|
383
|
+
replayed=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _budget(request: AgentRequest) -> dict[str, float]:
|
|
388
|
+
budget = request.budget
|
|
389
|
+
return {
|
|
390
|
+
"max_turns": budget.max_turns,
|
|
391
|
+
"max_budget_usd": budget.max_budget_usd,
|
|
392
|
+
"timeout_s": budget.timeout_s,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
async def _call_again(
|
|
397
|
+
call: Mapping[str, Any], tools: Mapping[str, ReproTool], ids: Ids, mapping: Mapping[str, str]
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Makes a recorded tool call again; it must return what it returned then."""
|
|
400
|
+
name = str(call["name"])
|
|
401
|
+
tool = tools.get(name)
|
|
402
|
+
if tool is None:
|
|
403
|
+
raise StaleCassetteError(f"the recording called {name}, which this call does not have")
|
|
404
|
+
arguments = _translate_json(call["input"], mapping)
|
|
405
|
+
try:
|
|
406
|
+
output, error = await tool.handler(arguments), False
|
|
407
|
+
except Exception as exc: # noqa: BLE001 - compared with what was recorded
|
|
408
|
+
output, error = str(exc), True
|
|
409
|
+
if error != bool(call["error"]) or _sha256(ids.normalise(output)) != call["output_sha256"]:
|
|
410
|
+
raise StaleCassetteError(
|
|
411
|
+
f"{name}({json.dumps(arguments, ensure_ascii=False)[:200]}) no longer returns what "
|
|
412
|
+
"the model saw when the call was recorded: record the call again"
|
|
413
|
+
)
|
repro/agents/cost.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""What a run's agent calls cost, and the most they may spend together (G02).
|
|
2
|
+
|
|
3
|
+
Costs are the client-side estimates the CLI reports, never billing data (research/04 §1.8). A
|
|
4
|
+
run's ledger adds up every call's estimate; with a limit, it also keeps the calls within it.
|
|
5
|
+
Before a call starts it reserves what it may spend, its own ``max_budget_usd`` cut down to
|
|
6
|
+
what is left, so calls running at once cannot together pass the limit; when it ends, the
|
|
7
|
+
reservation is replaced by its estimate. A call that finds nothing left does not start.
|
|
8
|
+
|
|
9
|
+
The SDK can overshoot ``max_budget_usd`` (research/04 §1.8), so the limit bounds what calls
|
|
10
|
+
are allowed, not what they can cost to the cent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
|
|
16
|
+
from repro.agents.backend import Budget, Usage
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Reservation:
|
|
21
|
+
"""What one call was allowed: its budget, with ``max_budget_usd`` within the run's limit."""
|
|
22
|
+
|
|
23
|
+
budget: Budget
|
|
24
|
+
held_usd: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunLedger:
|
|
28
|
+
"""A run's estimated agent spending, and its limit (None: no limit). Thread-safe."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, limit_usd: float | None = None) -> None:
|
|
31
|
+
if limit_usd is not None and limit_usd <= 0:
|
|
32
|
+
raise ValueError("a run's agent budget must be positive")
|
|
33
|
+
self.limit_usd = limit_usd
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._spent = 0.0
|
|
36
|
+
self._held = 0.0
|
|
37
|
+
self._calls = 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def spent_usd(self) -> float:
|
|
41
|
+
with self._lock:
|
|
42
|
+
return self._spent
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def calls(self) -> int:
|
|
46
|
+
with self._lock:
|
|
47
|
+
return self._calls
|
|
48
|
+
|
|
49
|
+
def reserve(self, budget: Budget) -> Reservation | None:
|
|
50
|
+
"""Holds what a call may spend, or None when the run's limit leaves nothing."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
if self.limit_usd is None:
|
|
53
|
+
return Reservation(budget, 0.0)
|
|
54
|
+
left = self.limit_usd - self._spent - self._held
|
|
55
|
+
if left <= 0:
|
|
56
|
+
return None
|
|
57
|
+
allowed = min(budget.max_budget_usd, left)
|
|
58
|
+
self._held += allowed
|
|
59
|
+
return Reservation(replace(budget, max_budget_usd=allowed), allowed)
|
|
60
|
+
|
|
61
|
+
def settle(self, reservation: Reservation, usage: Usage) -> None:
|
|
62
|
+
"""Replaces a call's reservation with its estimated cost."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._held = max(0.0, self._held - reservation.held_usd)
|
|
65
|
+
self._spent += usage.cost_usd
|
|
66
|
+
self._calls += 1
|
repro/agents/dispatch.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""How many agents run at once, and in which of the cockpit's dispatch slots (G01).
|
|
2
|
+
|
|
3
|
+
Each Agent SDK call is a Claude Code process of about 1 GiB (research/04 §1.10), so a run starts
|
|
4
|
+
at most ``min(12, free RAM in GiB)`` at once, and at least one (ARCHITECTURE §10.5). Free memory
|
|
5
|
+
is the memory available to new processes without swapping: ``ullAvailPhys`` from
|
|
6
|
+
``GlobalMemoryStatusEx`` on Windows, ``MemAvailable`` from ``/proc/meminfo`` on Linux (both per
|
|
7
|
+
their documentation: Microsoft Learn, MEMORYSTATUSEX; the Linux kernel's proc(5)); elsewhere it
|
|
8
|
+
is unknown, and one agent runs at a time.
|
|
9
|
+
|
|
10
|
+
The cockpit always shows 12 dispatch slots. An agent first takes the lowest free slot, where it
|
|
11
|
+
waits until it may start; an agent queued behind 12 others has no slot yet, and so no events.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Final
|
|
20
|
+
|
|
21
|
+
#: Dispatch slots the cockpit shows (DESIGN.md, the agents panel: 2 columns by 6 rows).
|
|
22
|
+
SLOTS: Final = 12
|
|
23
|
+
#: The most agents one run can name (``AgentId``: ``a`` and two or three digits).
|
|
24
|
+
MAX_AGENTS: Final = 999
|
|
25
|
+
GIB: Final = 1024**3
|
|
26
|
+
POLL_S: Final = 0.05
|
|
27
|
+
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
import ctypes
|
|
30
|
+
|
|
31
|
+
class _MemoryStatus(ctypes.Structure):
|
|
32
|
+
_fields_ = (
|
|
33
|
+
("dwLength", ctypes.c_uint32),
|
|
34
|
+
("dwMemoryLoad", ctypes.c_uint32),
|
|
35
|
+
("ullTotalPhys", ctypes.c_uint64),
|
|
36
|
+
("ullAvailPhys", ctypes.c_uint64),
|
|
37
|
+
("ullTotalPageFile", ctypes.c_uint64),
|
|
38
|
+
("ullAvailPageFile", ctypes.c_uint64),
|
|
39
|
+
("ullTotalVirtual", ctypes.c_uint64),
|
|
40
|
+
("ullAvailVirtual", ctypes.c_uint64),
|
|
41
|
+
("ullAvailExtendedVirtual", ctypes.c_uint64),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def _available_bytes() -> int | None:
|
|
45
|
+
status = _MemoryStatus()
|
|
46
|
+
status.dwLength = ctypes.sizeof(_MemoryStatus)
|
|
47
|
+
if not ctypes.WinDLL("kernel32").GlobalMemoryStatusEx(ctypes.byref(status)):
|
|
48
|
+
return None
|
|
49
|
+
return int(status.ullAvailPhys)
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
|
|
53
|
+
def _available_bytes() -> int | None:
|
|
54
|
+
try:
|
|
55
|
+
lines = Path("/proc/meminfo").read_text(encoding="ascii").splitlines()
|
|
56
|
+
except OSError:
|
|
57
|
+
return None
|
|
58
|
+
for line in lines:
|
|
59
|
+
name, _, value = line.partition(":")
|
|
60
|
+
if name == "MemAvailable" and value.split()[1:] == ["kB"]:
|
|
61
|
+
return int(value.split()[0]) * 1024
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def free_memory_gib() -> float | None:
|
|
66
|
+
"""The memory available to new processes, in GiB; None where it cannot be read."""
|
|
67
|
+
available = _available_bytes()
|
|
68
|
+
return None if available is None else available / GIB
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def concurrency_for(free_gib: float | None) -> int:
|
|
72
|
+
"""``min(12, free RAM in GiB)``, at least 1; 1 when free memory is unknown."""
|
|
73
|
+
if free_gib is None:
|
|
74
|
+
return 1
|
|
75
|
+
return max(1, min(SLOTS, int(free_gib)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def default_concurrency() -> int:
|
|
79
|
+
return concurrency_for(free_memory_gib())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Dispatch:
|
|
83
|
+
"""A run's agents: their ids, their dispatch slots, and how many run at once.
|
|
84
|
+
|
|
85
|
+
Thread-safe: the jobs that run agents each run in a thread of their own, with an event loop
|
|
86
|
+
of their own; waiting polls, so it neither blocks a loop nor ties the jobs to one loop.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, concurrency: int) -> None:
|
|
90
|
+
if not 1 <= concurrency <= SLOTS:
|
|
91
|
+
raise ValueError(f"concurrency must be between 1 and {SLOTS}")
|
|
92
|
+
self.concurrency = concurrency
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
self._seated = [False] * SLOTS
|
|
95
|
+
self._running = 0
|
|
96
|
+
self._named = 0
|
|
97
|
+
|
|
98
|
+
def agent_id(self) -> str:
|
|
99
|
+
"""The next agent's id: ``a01``, ``a02``, ... (``a100`` onwards past 99)."""
|
|
100
|
+
with self._lock:
|
|
101
|
+
if self._named == MAX_AGENTS:
|
|
102
|
+
raise RuntimeError(f"a run has at most {MAX_AGENTS} agents")
|
|
103
|
+
self._named += 1
|
|
104
|
+
return f"a{self._named:02d}"
|
|
105
|
+
|
|
106
|
+
def try_seat(self) -> int | None:
|
|
107
|
+
"""Takes the lowest free slot, or None if all 12 are taken."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
for slot, seated in enumerate(self._seated):
|
|
110
|
+
if not seated:
|
|
111
|
+
self._seated[slot] = True
|
|
112
|
+
return slot
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def try_start(self) -> bool:
|
|
116
|
+
"""Takes one of the ``concurrency`` places to run, if one is free."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._running == self.concurrency:
|
|
119
|
+
return False
|
|
120
|
+
self._running += 1
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
def stop(self) -> None:
|
|
124
|
+
"""Gives back a place to run."""
|
|
125
|
+
with self._lock:
|
|
126
|
+
if self._running == 0:
|
|
127
|
+
raise RuntimeError("no agent is running")
|
|
128
|
+
self._running -= 1
|
|
129
|
+
|
|
130
|
+
def unseat(self, slot: int) -> None:
|
|
131
|
+
"""Frees a slot for the next agent."""
|
|
132
|
+
with self._lock:
|
|
133
|
+
if not self._seated[slot]:
|
|
134
|
+
raise RuntimeError(f"slot {slot} is free")
|
|
135
|
+
self._seated[slot] = False
|
|
136
|
+
|
|
137
|
+
async def seat(self, check: Callable[[], None]) -> int:
|
|
138
|
+
"""Waits for a slot; ``check`` raises to give up (the job was cancelled)."""
|
|
139
|
+
while (slot := self.try_seat()) is None:
|
|
140
|
+
check()
|
|
141
|
+
await asyncio.sleep(POLL_S)
|
|
142
|
+
return slot
|
|
143
|
+
|
|
144
|
+
async def start(self, check: Callable[[], None]) -> None:
|
|
145
|
+
"""Waits for a place to run; ``check`` raises to give up."""
|
|
146
|
+
while not self.try_start():
|
|
147
|
+
check()
|
|
148
|
+
await asyncio.sleep(POLL_S)
|