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.
Files changed (151) hide show
  1. repro/__init__.py +8 -0
  2. repro/__main__.py +3 -0
  3. repro/agents/__init__.py +67 -0
  4. repro/agents/backend.py +129 -0
  5. repro/agents/bias.py +109 -0
  6. repro/agents/cassette.py +413 -0
  7. repro/agents/cost.py +66 -0
  8. repro/agents/dispatch.py +148 -0
  9. repro/agents/events.py +165 -0
  10. repro/agents/evidence.py +254 -0
  11. repro/agents/gap.py +181 -0
  12. repro/agents/lockdown.py +131 -0
  13. repro/agents/openai_compat.py +474 -0
  14. repro/agents/prober.py +173 -0
  15. repro/agents/prompts/__init__.py +143 -0
  16. repro/agents/prompts/gap.md +20 -0
  17. repro/agents/prompts/lock.json +7 -0
  18. repro/agents/prompts/probe.md +22 -0
  19. repro/agents/prompts/qa.md +24 -0
  20. repro/agents/prompts/reader.md +28 -0
  21. repro/agents/prompts/reviewer.md +34 -0
  22. repro/agents/qa.py +173 -0
  23. repro/agents/reader.py +167 -0
  24. repro/agents/reviewer.py +191 -0
  25. repro/agents/sdk.py +236 -0
  26. repro/agents/setup.py +117 -0
  27. repro/agents/sources.py +102 -0
  28. repro/agents/stages.py +539 -0
  29. repro/agents/tools.py +231 -0
  30. repro/agents/untrusted.py +48 -0
  31. repro/agents/validate.py +169 -0
  32. repro/analysis/__init__.py +34 -0
  33. repro/analysis/_grimp_worker.py +66 -0
  34. repro/analysis/api.py +275 -0
  35. repro/analysis/blast.py +110 -0
  36. repro/analysis/changes.py +315 -0
  37. repro/analysis/imports.py +218 -0
  38. repro/analysis/modules.py +59 -0
  39. repro/analysis/static.py +54 -0
  40. repro/analysis/symbols.py +192 -0
  41. repro/api/__init__.py +5 -0
  42. repro/api/app.py +212 -0
  43. repro/api/cockpit/assets/JetBrainsMono-OFL.txt +93 -0
  44. repro/api/cockpit/assets/atlas.worker-CP1cHLLd.js +1 -0
  45. repro/api/cockpit/assets/demo-run-C9SGu-Og.jsonl +2334 -0
  46. repro/api/cockpit/assets/fleet-Bn3DZ83b.css +1 -0
  47. repro/api/cockpit/assets/fleet-C5arwxhB.js +1 -0
  48. repro/api/cockpit/assets/force-DkXbt76I.js +1 -0
  49. repro/api/cockpit/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  50. repro/api/cockpit/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  51. repro/api/cockpit/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  52. repro/api/cockpit/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  53. repro/api/cockpit/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  54. repro/api/cockpit/assets/jsxRuntime-B9Idad31.js +2 -0
  55. repro/api/cockpit/assets/jsxRuntime-DHHyguPz.css +1 -0
  56. repro/api/cockpit/assets/layout.worker-DLYbolgN.js +1 -0
  57. repro/api/cockpit/assets/main-BbRvaUOf.css +1 -0
  58. repro/api/cockpit/assets/main-CpPW1Zab.js +4376 -0
  59. repro/api/cockpit/assets/stress-run-TCZCEJwc.jsonl +18542 -0
  60. repro/api/cockpit/fleet.html +15 -0
  61. repro/api/cockpit/index.html +15 -0
  62. repro/api/stream.py +195 -0
  63. repro/api/token.py +48 -0
  64. repro/api/ui.py +17 -0
  65. repro/bus.py +150 -0
  66. repro/card/__init__.py +30 -0
  67. repro/card/assemble.py +224 -0
  68. repro/card/exports.py +329 -0
  69. repro/card/job.py +76 -0
  70. repro/card/markdown.py +175 -0
  71. repro/cli.py +923 -0
  72. repro/clock.py +15 -0
  73. repro/config/__init__.py +25 -0
  74. repro/config/settings.py +297 -0
  75. repro/doctor.py +461 -0
  76. repro/gitops/__init__.py +36 -0
  77. repro/gitops/changes.py +186 -0
  78. repro/gitops/git.py +121 -0
  79. repro/gitops/snapshot.py +398 -0
  80. repro/gitops/worktree.py +94 -0
  81. repro/ids.py +85 -0
  82. repro/integrations/__init__.py +1 -0
  83. repro/integrations/github/__init__.py +72 -0
  84. repro/integrations/github/action.py +183 -0
  85. repro/integrations/github/api.py +190 -0
  86. repro/integrations/github/mirror.py +104 -0
  87. repro/integrations/github/pr.py +127 -0
  88. repro/integrations/github/report.py +256 -0
  89. repro/integrations/mcp_server.py +224 -0
  90. repro/jsts.py +271 -0
  91. repro/logs.py +144 -0
  92. repro/orchestrator/__init__.py +43 -0
  93. repro/orchestrator/jobs.py +207 -0
  94. repro/orchestrator/runner.py +449 -0
  95. repro/paths.py +101 -0
  96. repro/pipeline.py +288 -0
  97. repro/policy/__init__.py +23 -0
  98. repro/policy/gap.py +173 -0
  99. repro/policy/invariants.py +379 -0
  100. repro/policy/loader.py +330 -0
  101. repro/policy/repro-yml.schema.json +100 -0
  102. repro/policy/stage.py +160 -0
  103. repro/policy/status.py +263 -0
  104. repro/provision/__init__.py +32 -0
  105. repro/provision/deps.py +274 -0
  106. repro/provision/env.py +541 -0
  107. repro/provision/project.py +416 -0
  108. repro/py.typed +0 -0
  109. repro/readable.py +73 -0
  110. repro/run_stage.py +1024 -0
  111. repro/runner/__init__.py +25 -0
  112. repro/runner/behaviour.py +341 -0
  113. repro/runner/observations.py +648 -0
  114. repro/runner/outcomes.py +193 -0
  115. repro/runner/perf.py +358 -0
  116. repro/runner/probes.py +681 -0
  117. repro/runner/pytest.py +549 -0
  118. repro/runner/pytest_config.py +125 -0
  119. repro/runner/selection.py +360 -0
  120. repro/runner/sides.py +63 -0
  121. repro/runner/testdiff.py +262 -0
  122. repro/runner/trace.py +195 -0
  123. repro/runner/twosided.py +435 -0
  124. repro/sandbox/__init__.py +30 -0
  125. repro/sandbox/_hook/repro_encode.py +734 -0
  126. repro/sandbox/_hook/repro_probe.py +77 -0
  127. repro/sandbox/_hook/repro_pytest.py +459 -0
  128. repro/sandbox/_hook/repro_trace.py +333 -0
  129. repro/sandbox/_hook/sitecustomize.py +199 -0
  130. repro/sandbox/_rlimits.py +55 -0
  131. repro/sandbox/detect.py +105 -0
  132. repro/sandbox/docker.py +462 -0
  133. repro/sandbox/evidence.py +68 -0
  134. repro/sandbox/image/Dockerfile +20 -0
  135. repro/sandbox/image.py +44 -0
  136. repro/sandbox/limits.py +33 -0
  137. repro/sandbox/process.py +336 -0
  138. repro/sandbox/winjob.py +242 -0
  139. repro/scrub.py +86 -0
  140. repro/store/__init__.py +24 -0
  141. repro/store/artifacts.py +145 -0
  142. repro/store/cbor.py +191 -0
  143. repro/store/db.py +118 -0
  144. repro/store/reader.py +132 -0
  145. repro/store/schema.py +165 -0
  146. repro/store/writer.py +452 -0
  147. repro/symbolic.py +288 -0
  148. repro_engine-1.2.0.dist-info/METADATA +196 -0
  149. repro_engine-1.2.0.dist-info/RECORD +151 -0
  150. repro_engine-1.2.0.dist-info/WHEEL +4 -0
  151. repro_engine-1.2.0.dist-info/entry_points.txt +3 -0
@@ -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
@@ -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)