quirework 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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
"""Harness adapters for the prose workshop; procedure: docs/workflows.md.
|
|
2
|
+
|
|
3
|
+
Adapters transport a file-based assignment, not a fixed writing instrument.
|
|
4
|
+
Credentials are used at launch and never serialized into experiment records.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from quirework.project import current_project
|
|
18
|
+
from quirework.providers import blind, claude_login, quota, replies
|
|
19
|
+
|
|
20
|
+
HARNESSES = ("codex", "claude", "agy")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_json(path):
|
|
24
|
+
return json.loads(Path(path).read_text())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def json_rows(path):
|
|
28
|
+
"""Parse complete records. A truncated last record is a visible failure."""
|
|
29
|
+
return [
|
|
30
|
+
json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# A plan names a model family (`opus`, `terra`, `gemini-pro`) and preparation
|
|
35
|
+
# picks the newest member the harness's own catalog lists, so a release needs
|
|
36
|
+
# no change here and a plan written today picks up next month's model. The
|
|
37
|
+
# resolved id is frozen with the family recorded beside it; a name with a
|
|
38
|
+
# version number in it pins instead. Keep no list of model versions in this
|
|
39
|
+
# module: where a harness has no catalog, as Claude Code has none, its own
|
|
40
|
+
# alias passes through and the receipt verifies what was served.
|
|
41
|
+
CLAUDE_ALIASES = ("fable", "opus", "sonnet", "haiku")
|
|
42
|
+
AGY_EFFORTS = ("low", "medium", "high")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tokens(name):
|
|
46
|
+
return [t for t in re.split(r"[-_\s]+", str(name).strip().lower()) if t]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _words(name):
|
|
50
|
+
"""The family words of a model name: every token that is not a version."""
|
|
51
|
+
return [t for t in _tokens(name) if not t[0].isdigit()]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _version(name):
|
|
55
|
+
return tuple(
|
|
56
|
+
int(n) for t in _tokens(name) if t[0].isdigit() for n in re.findall(r"\d+", t)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_family(name):
|
|
61
|
+
"""Whether a model name names a family rather than a version: no digit."""
|
|
62
|
+
return bool(_tokens(name)) and not re.search(r"\d", str(name))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def families(slugs):
|
|
66
|
+
"""The family names a catalog offers, for a refusal to list."""
|
|
67
|
+
return sorted({"-".join(_words(s)) for s in slugs if _words(s)})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def newest_in_family(family, slugs):
|
|
71
|
+
"""The newest catalog slug in ``family``, or None when nothing matches.
|
|
72
|
+
|
|
73
|
+
A slug is in the family when every family word is one of its words.
|
|
74
|
+
Among those, the members with the fewest words win, so ``terra`` means
|
|
75
|
+
``gpt-5.6-terra`` and not a later ``gpt-6-terra-mini``. Two different
|
|
76
|
+
word sets left tied are ambiguous, as ``gemini`` is between Flash and
|
|
77
|
+
Pro, and the refusal names both. Inside the family the highest version
|
|
78
|
+
wins, compared as numbers, so 5.10 is newer than 5.4.
|
|
79
|
+
"""
|
|
80
|
+
wanted = set(_words(family))
|
|
81
|
+
members = [s for s in slugs if wanted <= set(_words(s))]
|
|
82
|
+
if not members:
|
|
83
|
+
return None
|
|
84
|
+
fewest = min(len(_words(s)) for s in members)
|
|
85
|
+
members = [s for s in members if len(_words(s)) == fewest]
|
|
86
|
+
shapes = families(members)
|
|
87
|
+
if len(shapes) > 1:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"model {family!r} names more than one family ({', '.join(shapes)}); "
|
|
90
|
+
"name one of them"
|
|
91
|
+
)
|
|
92
|
+
return max(members, key=lambda s: (_version(s), s))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def model_text(config):
|
|
96
|
+
"""A seat's model as status prints it: the id, and the family it came from."""
|
|
97
|
+
asked = config.get("requested_model")
|
|
98
|
+
model = config.get("model")
|
|
99
|
+
if asked and model and str(asked).strip().lower() != model:
|
|
100
|
+
return f"{model} (from {asked})"
|
|
101
|
+
return model
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_claude(model):
|
|
105
|
+
"""What a person calls a Claude model, as Claude Code accepts it.
|
|
106
|
+
|
|
107
|
+
``opus-5.5`` is what a person says and ``claude-opus-5-5`` is what the
|
|
108
|
+
CLI takes: Claude ids spell versions with dashes, never dots. A family
|
|
109
|
+
alias passes through, because the CLI resolves it to its latest model
|
|
110
|
+
and the receipt records which one answered. Anything that is neither
|
|
111
|
+
is refused here rather than at launch, where it would come back as
|
|
112
|
+
``unrecognized_model`` after the jail was already built.
|
|
113
|
+
"""
|
|
114
|
+
text = str(model).strip().lower()
|
|
115
|
+
if not text:
|
|
116
|
+
raise ValueError("claude model must not be empty")
|
|
117
|
+
bare = text.removeprefix("claude-").replace(".", "-")
|
|
118
|
+
if bare in CLAUDE_ALIASES:
|
|
119
|
+
return bare
|
|
120
|
+
if not is_family(bare) and (
|
|
121
|
+
text.startswith("claude-") or bare.split("-")[0] in CLAUDE_ALIASES
|
|
122
|
+
):
|
|
123
|
+
return f"claude-{bare}"
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"claude model {model!r} is not a name Claude Code accepts. Use a family "
|
|
126
|
+
f"alias ({', '.join(CLAUDE_ALIASES)}), which the CLI resolves to its "
|
|
127
|
+
"latest model, or a version such as opus-5.5 or claude-opus-5-5."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def harness_for(model):
|
|
132
|
+
"""The harness a model name belongs to when the operator names none.
|
|
133
|
+
|
|
134
|
+
Only Claude names can be recognized without a catalog: its family
|
|
135
|
+
aliases and its ``claude-`` ids. Everything else goes to Codex, whose
|
|
136
|
+
catalog then resolves the family or refuses it and lists the families
|
|
137
|
+
it offers. Antigravity is never inferred, because its effort is part of
|
|
138
|
+
the model id and has to be chosen rather than defaulted.
|
|
139
|
+
"""
|
|
140
|
+
text = str(model).strip().lower()
|
|
141
|
+
bare = text.removeprefix("claude-").replace(".", "-")
|
|
142
|
+
if text.startswith("claude-") or bare.split("-")[0] in CLAUDE_ALIASES:
|
|
143
|
+
return "claude"
|
|
144
|
+
return "codex"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
CODEX_CACHE = ".codex/models_cache.json"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _codex_live():
|
|
151
|
+
"""``(models, None)`` from ``codex debug models``, or ``(None, why)``."""
|
|
152
|
+
try:
|
|
153
|
+
p = subprocess.run(
|
|
154
|
+
["codex", "debug", "models"],
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
timeout=90,
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
161
|
+
return None, str(exc)
|
|
162
|
+
if p.returncode:
|
|
163
|
+
return None, (p.stderr.strip() or f"exit status {p.returncode}")[-300:]
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(p.stdout).get("models", []), None
|
|
166
|
+
except (json.JSONDecodeError, AttributeError) as exc:
|
|
167
|
+
return None, f"unreadable output: {exc}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def codex_catalog():
|
|
171
|
+
"""Codex's model list, and a line saying where it was read.
|
|
172
|
+
|
|
173
|
+
A catalog the project configures as ``providers.codex.catalog`` is used
|
|
174
|
+
as given: that is a pinned choice. Otherwise Codex is asked, because the
|
|
175
|
+
file it keeps at ``~/.codex/models_cache.json`` is written only by a
|
|
176
|
+
real Codex session and can trail the provider by days. Resolved against
|
|
177
|
+
that file, an exact slug the provider already served was refused, and
|
|
178
|
+
its family resolved to the previous generation without a word: a model
|
|
179
|
+
substitution at launch. ``codex debug models`` refreshes when its copy
|
|
180
|
+
is stale and prints the result, though it does not write the file back.
|
|
181
|
+
The file is the fallback when Codex cannot be asked, and the source line
|
|
182
|
+
then says how old it is, because an old catalog is how a newer model
|
|
183
|
+
goes unseen.
|
|
184
|
+
"""
|
|
185
|
+
project = current_project()
|
|
186
|
+
if project.get("providers.codex.catalog") is not None:
|
|
187
|
+
path = project.path("providers.codex.catalog")
|
|
188
|
+
if not path.exists():
|
|
189
|
+
raise ValueError(f"the configured Codex catalog {path} does not exist")
|
|
190
|
+
return read_json(path).get("models", []), f"{path} (configured)"
|
|
191
|
+
models, failure = _codex_live()
|
|
192
|
+
if models is not None:
|
|
193
|
+
return models, "codex debug models"
|
|
194
|
+
cache = Path.home() / CODEX_CACHE
|
|
195
|
+
if not cache.exists():
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"codex debug models failed ({failure}) and there is no cached "
|
|
198
|
+
f"catalog at {cache}; check that Codex is logged in with "
|
|
199
|
+
"`codex login status`"
|
|
200
|
+
)
|
|
201
|
+
data = read_json(cache)
|
|
202
|
+
source = (
|
|
203
|
+
f"{cache}, fetched {data.get('fetched_at') or 'at an unrecorded time'}, "
|
|
204
|
+
f"because codex debug models failed: {failure}"
|
|
205
|
+
)
|
|
206
|
+
print(f"models: resolving against {source}", file=sys.stderr)
|
|
207
|
+
return data.get("models", []), source
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve(harness, requested, effort, resolved_model=None, declared=None):
|
|
211
|
+
if harness not in HARNESSES:
|
|
212
|
+
raise ValueError(f"unknown harness: {harness}")
|
|
213
|
+
if not shutil.which(harness):
|
|
214
|
+
raise ValueError(f"harness not installed: {harness}")
|
|
215
|
+
model = str(resolved_model or requested).strip().lower()
|
|
216
|
+
evidence = {"requested_model": requested, "requested_effort": effort}
|
|
217
|
+
if harness == "codex":
|
|
218
|
+
model = re.sub(r"^(gpt-\d+)-(\d+)", r"\1.\2", model)
|
|
219
|
+
models, source = codex_catalog()
|
|
220
|
+
evidence["catalog_source"] = source
|
|
221
|
+
item = next((m for m in models if m.get("slug") == model), None)
|
|
222
|
+
# Hidden entries are Codex's own internals; a family never lands on one.
|
|
223
|
+
listed = [
|
|
224
|
+
m["slug"] for m in models if m.get("slug") and m.get("visibility") != "hide"
|
|
225
|
+
]
|
|
226
|
+
if item is None and is_family(model):
|
|
227
|
+
slug = newest_in_family(model, listed)
|
|
228
|
+
item = next((m for m in models if m.get("slug") == slug), None)
|
|
229
|
+
if item is None:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Codex catalog does not contain {model!r}; it offers the "
|
|
232
|
+
f"families {', '.join(families(listed))}, which resolve to "
|
|
233
|
+
"their newest version, or an exact slug"
|
|
234
|
+
)
|
|
235
|
+
model = item["slug"]
|
|
236
|
+
levels = [r["effort"] for r in item.get("supported_reasoning_levels", [])]
|
|
237
|
+
if effort not in levels:
|
|
238
|
+
raise ValueError(f"{model} supports {levels}, not {effort!r}")
|
|
239
|
+
evidence["context_window"] = item.get("context_window")
|
|
240
|
+
# `context_window` is only the default a session opens with. The
|
|
241
|
+
# ceiling a run may raise it to is `max_context_window`, of which the
|
|
242
|
+
# provider hands back `effective_context_window_percent`. Carry all
|
|
243
|
+
# three so a plan asks for the largest window this model actually has
|
|
244
|
+
# instead of a number someone wrote down once.
|
|
245
|
+
evidence["max_context_window"] = item.get("max_context_window")
|
|
246
|
+
evidence["effective_context_window_percent"] = item.get(
|
|
247
|
+
"effective_context_window_percent"
|
|
248
|
+
)
|
|
249
|
+
evidence["catalog_entry"] = {
|
|
250
|
+
k: item.get(k)
|
|
251
|
+
for k in (
|
|
252
|
+
"slug",
|
|
253
|
+
"supported_reasoning_levels",
|
|
254
|
+
"context_window",
|
|
255
|
+
"max_context_window",
|
|
256
|
+
"effective_context_window_percent",
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
elif harness == "agy":
|
|
260
|
+
if effort not in AGY_EFFORTS:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"agy supports low, medium, high; explicitly map the requested effort"
|
|
263
|
+
)
|
|
264
|
+
p = subprocess.run(
|
|
265
|
+
["agy", "models"], capture_output=True, text=True, timeout=45, check=False
|
|
266
|
+
)
|
|
267
|
+
if p.returncode:
|
|
268
|
+
raise ValueError(f"agy model lookup failed: {p.stderr[-500:]}")
|
|
269
|
+
available = [
|
|
270
|
+
line.split()[0]
|
|
271
|
+
for line in p.stdout.splitlines()
|
|
272
|
+
if line.startswith(("gemini-", "claude-", "gpt-"))
|
|
273
|
+
]
|
|
274
|
+
# A slug carries its effort as a suffix (`gemini-3.1-pro-high`); a
|
|
275
|
+
# family is resolved on the model with the suffix taken off, and the
|
|
276
|
+
# seat's effort then completes the slug.
|
|
277
|
+
suffix = re.compile(r"-(" + "|".join(AGY_EFFORTS) + r")$")
|
|
278
|
+
bases = sorted({suffix.sub("", s) for s in available})
|
|
279
|
+
if model not in available and is_family(model):
|
|
280
|
+
model = newest_in_family(model, bases) or model
|
|
281
|
+
if model not in available and f"{model}-{effort}" in available:
|
|
282
|
+
model = f"{model}-{effort}"
|
|
283
|
+
if model not in available:
|
|
284
|
+
offered = sorted(
|
|
285
|
+
m.group(1)
|
|
286
|
+
for s in available
|
|
287
|
+
if (m := suffix.search(s)) and suffix.sub("", s) == model
|
|
288
|
+
)
|
|
289
|
+
if offered:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"agy offers {model} at efforts {', '.join(offered)}, "
|
|
292
|
+
f"not {effort!r}"
|
|
293
|
+
)
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"agy model unavailable: {model!r}; it offers the families "
|
|
296
|
+
f"{', '.join(families(bases))}, which resolve to their newest "
|
|
297
|
+
"version, or an exact slug from `agy models`"
|
|
298
|
+
)
|
|
299
|
+
evidence["catalog_entry"] = model
|
|
300
|
+
else:
|
|
301
|
+
# A model the project declares (``providers.claude.models``) is
|
|
302
|
+
# accepted verbatim: a gateway id is neither a family alias nor a
|
|
303
|
+
# claude version, so the name validation below would refuse the
|
|
304
|
+
# whole route. The receipt still verifies what was served, the way
|
|
305
|
+
# it does for an explicit version.
|
|
306
|
+
if model in {str(m).strip().lower() for m in (declared or ())}:
|
|
307
|
+
evidence["catalog_entry"] = (
|
|
308
|
+
"operator-declared model; the receipt verifies the served identity"
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
model = resolve_claude(model)
|
|
312
|
+
evidence["catalog_entry"] = (
|
|
313
|
+
"family alias; the CLI picks its latest, and the receipt records "
|
|
314
|
+
"the version served"
|
|
315
|
+
if model in CLAUDE_ALIASES
|
|
316
|
+
else "explicit model; verify served identity after launch"
|
|
317
|
+
)
|
|
318
|
+
if effort not in ("low", "medium", "high", "xhigh", "max"):
|
|
319
|
+
raise ValueError(f"invalid Claude effort: {effort}")
|
|
320
|
+
evidence.update(harness=harness, model=model, effort=effort)
|
|
321
|
+
return evidence
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# Claude publishes no catalog, so its ceiling is a constant. Kept equal to
|
|
325
|
+
# runs/engine.py's CLAUDE_MAX_WINDOW rather than imported, because that module
|
|
326
|
+
# pulls the whole run engine in behind it and this one is reached from the
|
|
327
|
+
# jail.
|
|
328
|
+
CLAUDE_MAX_WINDOW = 1_000_000
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def window_flags(config):
|
|
332
|
+
"""Harness flags pinning a run to its model's whole window, no compaction.
|
|
333
|
+
|
|
334
|
+
Two settings doing one job. `model_context_window` lifts the session off
|
|
335
|
+
the default the catalog calls `context_window` -- 272,000 for gpt-6-astra
|
|
336
|
+
-- to the ceiling it calls `max_context_window`, which is 872,000. Setting
|
|
337
|
+
the auto-compact limit to the same number means a run that outgrows its
|
|
338
|
+
context fails loudly at the ceiling instead of quietly compacting below
|
|
339
|
+
it, which is what `providers/harness.py` has always done and what the blind
|
|
340
|
+
drivers do with their own constant.
|
|
341
|
+
|
|
342
|
+
Neither setting was here or in `review.py`, so every workshop and review
|
|
343
|
+
run took the session default and could compact freely; `_no_compaction`
|
|
344
|
+
then caught it after the fact, when the spend was already made. A review
|
|
345
|
+
resume compacted for exactly this reason on 2026-09-10. The resume is the
|
|
346
|
+
turn that needs the ceiling most, because it inherits the whole prior
|
|
347
|
+
transcript before the new delta is added, so the run most likely to
|
|
348
|
+
compact was the one configured lowest.
|
|
349
|
+
|
|
350
|
+
`docs/workflows.md` makes compaction a red flag for every
|
|
351
|
+
harness-invoking SOP, and a red flag that can only be raised afterwards is
|
|
352
|
+
a worse instrument than one that prevents the event.
|
|
353
|
+
"""
|
|
354
|
+
harness = config["harness"]
|
|
355
|
+
if harness == "claude":
|
|
356
|
+
return ["--autocompact", str(CLAUDE_MAX_WINDOW)]
|
|
357
|
+
if harness != "codex":
|
|
358
|
+
return []
|
|
359
|
+
# The ceiling only. `context_window` is the session default this helper
|
|
360
|
+
# exists to escape, so accepting it as a substitute emits the very number
|
|
361
|
+
# the guard refuses: a catalog entry carrying only `context_window`
|
|
362
|
+
# silently produced 272,000 for both settings.
|
|
363
|
+
window = config.get("max_context_window")
|
|
364
|
+
if not window:
|
|
365
|
+
raise ValueError(
|
|
366
|
+
f"no context ceiling resolved for {config.get('model')!r}; refresh "
|
|
367
|
+
"the Codex catalog rather than letting the run take the session "
|
|
368
|
+
"default (docs/workflows.md, 'Compaction is a major design "
|
|
369
|
+
"warning')"
|
|
370
|
+
)
|
|
371
|
+
return [
|
|
372
|
+
"-c",
|
|
373
|
+
f"model_context_window={window}",
|
|
374
|
+
"-c",
|
|
375
|
+
f"model_auto_compact_token_limit={window}",
|
|
376
|
+
# THE SETTING THAT MAKES THE LIMIT ABOVE MEAN ANYTHING. Without a
|
|
377
|
+
# scope, Codex applies the total-mode 90 percent threshold clamp, so
|
|
378
|
+
# an auto-compact limit set to the window still fires at nine tenths
|
|
379
|
+
# of it and compaction is not disabled at all: for an 872,000 ceiling
|
|
380
|
+
# at 95 percent usable, the trigger sits at 784,800 rather than
|
|
381
|
+
# 828,400. `body_after_prefix` reads the explicit limit directly. The
|
|
382
|
+
# operator's own ~/.codex/config.toml carries this, researched and
|
|
383
|
+
# cited against the Codex source, and every launcher here passes
|
|
384
|
+
# --ignore-user-config, so all of that was being discarded and none
|
|
385
|
+
# of it re-supplied. Version-specific behaviour, not an off switch:
|
|
386
|
+
# recheck on upgrades (established for 0.153.4; 0.154.0 installed).
|
|
387
|
+
"-c",
|
|
388
|
+
'model_auto_compact_token_limit_scope="body_after_prefix"',
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# Harnesses whose compaction behaviour a run can actually pin. Antigravity is
|
|
393
|
+
# absent because its CLI exposes no context or compaction setting at all: not
|
|
394
|
+
# a ceiling, not a trigger, not an off switch. A seat there takes the session
|
|
395
|
+
# default, and the only guard available is the one that reads the stream
|
|
396
|
+
# afterwards and reports that it happened. Named here rather than left as a
|
|
397
|
+
# silent `return []`, because that same empty list otherwise means both "this
|
|
398
|
+
# harness needs no flags" and "this harness cannot be steered", and an
|
|
399
|
+
# operator reading a mixed room's evidence has to tell those apart.
|
|
400
|
+
PINNED_WINDOW = ("claude", "codex")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# What a seat may do beyond reading its packet. `write` is the floor,
|
|
404
|
+
# `research` adds the web, and `workbench` adds a shell to that.
|
|
405
|
+
TOOL_POLICIES = ("write", "research", "workbench")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def window_assurance(harness):
|
|
409
|
+
"""Whether a run can prevent compaction here, or only detect it after."""
|
|
410
|
+
return "enforced" if harness in PINNED_WINDOW else "detected"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def command(config, workspace, prompt, *, manual=False, resume=None):
|
|
414
|
+
"""Return argv and stdin bytes; prompts never pass through a shell.
|
|
415
|
+
|
|
416
|
+
``resume`` names a session to continue instead of opening a new one. It
|
|
417
|
+
exists for the quota case: a writer stopped by a provider limit has its
|
|
418
|
+
reading, its reasoning and its half-written draft inside that session, and
|
|
419
|
+
a fresh writer would pay for all of it again. The caller checks the
|
|
420
|
+
returned session id against ``resume``, because a harness that silently
|
|
421
|
+
started a new conversation produces evidence that looks continuous and
|
|
422
|
+
is not.
|
|
423
|
+
"""
|
|
424
|
+
harness, model, effort = (config[k] for k in ("harness", "model", "effort"))
|
|
425
|
+
# A resumed session names the version it began on, never the family: an
|
|
426
|
+
# alias on resume follows a release, so the seat would change model
|
|
427
|
+
# between turns while its receipt still read as the same seat.
|
|
428
|
+
model = config.get("model_pinned") or model
|
|
429
|
+
tools = config.get("tools", "write")
|
|
430
|
+
if tools not in TOOL_POLICIES:
|
|
431
|
+
raise ValueError(f"tools must be one of {TOOL_POLICIES}, not {tools!r}")
|
|
432
|
+
# `workbench` is `research` plus a shell: the roundtable's seats speak
|
|
433
|
+
# through a command in their workspace, and a seat that cannot run it
|
|
434
|
+
# cannot post. It is also the one policy Antigravity can honour, because
|
|
435
|
+
# it confines nothing that agy could otherwise have used.
|
|
436
|
+
workbench = tools == "workbench"
|
|
437
|
+
research = tools == "research" or workbench
|
|
438
|
+
if resume and manual:
|
|
439
|
+
raise ValueError("resume the writer automatically or relaunch it by hand")
|
|
440
|
+
if tools == "research" and harness == "agy":
|
|
441
|
+
raise ValueError("agy cannot confine a seat to a tool set")
|
|
442
|
+
if harness == "codex":
|
|
443
|
+
args = ["codex"] + (
|
|
444
|
+
[]
|
|
445
|
+
if manual
|
|
446
|
+
else [
|
|
447
|
+
"exec",
|
|
448
|
+
*(quota.resume_argv("codex", resume) if resume else []),
|
|
449
|
+
"--ignore-user-config",
|
|
450
|
+
"--ignore-rules",
|
|
451
|
+
]
|
|
452
|
+
)
|
|
453
|
+
args += window_flags(config)
|
|
454
|
+
args += [
|
|
455
|
+
"-m",
|
|
456
|
+
model,
|
|
457
|
+
"-c",
|
|
458
|
+
f'model_reasoning_effort="{effort}"',
|
|
459
|
+
"-c",
|
|
460
|
+
'personality="none"',
|
|
461
|
+
"-c",
|
|
462
|
+
'model_verbosity="high"',
|
|
463
|
+
"-c",
|
|
464
|
+
'web_search="live"' if research else 'web_search="disabled"',
|
|
465
|
+
# Codex's own sandbox cuts a shell command off the network even
|
|
466
|
+
# where the jail around it does not. A workbench seat was
|
|
467
|
+
# promised the network, not only the built-in search tool.
|
|
468
|
+
*(
|
|
469
|
+
["-c", "sandbox_workspace_write.network_access=true"]
|
|
470
|
+
if workbench
|
|
471
|
+
else []
|
|
472
|
+
),
|
|
473
|
+
"-c",
|
|
474
|
+
"project_doc_max_bytes=0",
|
|
475
|
+
"-c",
|
|
476
|
+
"features.multi_agent=false",
|
|
477
|
+
*blind.CODEX_SEAT,
|
|
478
|
+
]
|
|
479
|
+
# `codex exec resume` accepts neither `-s` nor `-C`: it refuses the
|
|
480
|
+
# argument outright and the turn produces no events at all, which
|
|
481
|
+
# surfaces later as "no unique completed turn" rather than as the
|
|
482
|
+
# argument error it is. The sandbox still travels as config, and the
|
|
483
|
+
# working directory needs no flag because the jail has already chdir'd
|
|
484
|
+
# the process into the workspace.
|
|
485
|
+
if resume and not manual:
|
|
486
|
+
args += ["-c", 'sandbox_mode="workspace-write"']
|
|
487
|
+
else:
|
|
488
|
+
args += ["-s", "workspace-write", "-C", str(workspace)]
|
|
489
|
+
if manual:
|
|
490
|
+
return args + [prompt], None
|
|
491
|
+
return args + ["--skip-git-repo-check", "--json", "-"], prompt.encode()
|
|
492
|
+
if harness == "claude":
|
|
493
|
+
tools = (
|
|
494
|
+
"Read,Write,Edit,Glob,Grep"
|
|
495
|
+
+ (",Bash" if workbench else "")
|
|
496
|
+
+ (",WebSearch,WebFetch" if research else "")
|
|
497
|
+
)
|
|
498
|
+
args = [
|
|
499
|
+
"claude",
|
|
500
|
+
*(quota.resume_argv("claude", resume) if resume else []),
|
|
501
|
+
"--model",
|
|
502
|
+
model,
|
|
503
|
+
"--effort",
|
|
504
|
+
effort,
|
|
505
|
+
"--tools",
|
|
506
|
+
tools,
|
|
507
|
+
"--allowedTools",
|
|
508
|
+
tools,
|
|
509
|
+
"--disable-slash-commands",
|
|
510
|
+
*window_flags(config),
|
|
511
|
+
*blind.BLIND_SETTINGS,
|
|
512
|
+
*blind.NO_SETTING_SOURCES,
|
|
513
|
+
*blind.NO_MCP,
|
|
514
|
+
]
|
|
515
|
+
if manual:
|
|
516
|
+
return args + [prompt], None
|
|
517
|
+
return args + [
|
|
518
|
+
"-p",
|
|
519
|
+
"--output-format",
|
|
520
|
+
"stream-json",
|
|
521
|
+
"--verbose",
|
|
522
|
+
], prompt.encode()
|
|
523
|
+
if harness == "agy":
|
|
524
|
+
args = [
|
|
525
|
+
"agy",
|
|
526
|
+
*(quota.resume_argv("agy", resume) if resume else []),
|
|
527
|
+
"--model",
|
|
528
|
+
model,
|
|
529
|
+
"--effort",
|
|
530
|
+
effort,
|
|
531
|
+
"--disable-slash-commands",
|
|
532
|
+
"--dangerously-skip-permissions",
|
|
533
|
+
]
|
|
534
|
+
if manual:
|
|
535
|
+
return args + ["--prompt-interactive", prompt], None
|
|
536
|
+
return args + [
|
|
537
|
+
"--print",
|
|
538
|
+
prompt,
|
|
539
|
+
"--output-format",
|
|
540
|
+
"stream-json",
|
|
541
|
+
"--print-timeout",
|
|
542
|
+
"60m",
|
|
543
|
+
], b""
|
|
544
|
+
raise ValueError(f"unknown harness: {harness}")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def canary(config, label):
|
|
548
|
+
"""Prove a writer's or a room seat's flags blind on the installed runner.
|
|
549
|
+
|
|
550
|
+
The twin is ``command`` itself, with the canary's prompt, model and
|
|
551
|
+
effort, the seat's tool policy, and the canary's directory as its
|
|
552
|
+
workspace. A seat run by hand is proven under the automatic form of the
|
|
553
|
+
same command, the only one a canary can drive.
|
|
554
|
+
"""
|
|
555
|
+
from quirework.providers import canary as canaries
|
|
556
|
+
|
|
557
|
+
harness = config["harness"]
|
|
558
|
+
tools = config.get("tools", "write")
|
|
559
|
+
|
|
560
|
+
def build(prompt, model, workdir, session):
|
|
561
|
+
twin = {
|
|
562
|
+
"harness": harness,
|
|
563
|
+
"model": model,
|
|
564
|
+
"effort": canaries.EFFORT,
|
|
565
|
+
"tools": tools,
|
|
566
|
+
"max_context_window": canaries.WINDOW,
|
|
567
|
+
}
|
|
568
|
+
args, payload = command(twin, workdir, prompt)
|
|
569
|
+
return canaries.Twin(args, payload)
|
|
570
|
+
|
|
571
|
+
# agy keeps its web tools under any flags; the writer's transcript is read
|
|
572
|
+
# afterwards and a run that used them outside a workbench is refused.
|
|
573
|
+
contract = canaries.Contract(
|
|
574
|
+
web=tools in ("research", "workbench"), web_audited=harness == "agy"
|
|
575
|
+
)
|
|
576
|
+
return canaries.require(label, harness, build, contract)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def launch_context(config, workspace, bus=None, canary=None):
|
|
580
|
+
"""Use private runtime state and a read-only packet in the shared jail.
|
|
581
|
+
|
|
582
|
+
``bus`` is the filtered session bus a runner such as agy needs, opened by
|
|
583
|
+
the caller with ``blind.session_bus`` so it outlives this call and is
|
|
584
|
+
handed to the process through ``blind.popen``. ``canary`` is the record
|
|
585
|
+
``canary`` above returned for this seat.
|
|
586
|
+
"""
|
|
587
|
+
harness = config["harness"]
|
|
588
|
+
if error := blind.backend_error():
|
|
589
|
+
raise RuntimeError(error)
|
|
590
|
+
_, missing = blind.runner_binds([harness])
|
|
591
|
+
if missing:
|
|
592
|
+
raise RuntimeError("missing harness prerequisites: " + "; ".join(missing))
|
|
593
|
+
allowed = (
|
|
594
|
+
"PATH",
|
|
595
|
+
"LANG",
|
|
596
|
+
"LC_ALL",
|
|
597
|
+
"TERM",
|
|
598
|
+
"COLORTERM",
|
|
599
|
+
"TZ",
|
|
600
|
+
"HTTPS_PROXY",
|
|
601
|
+
"HTTP_PROXY",
|
|
602
|
+
"ALL_PROXY",
|
|
603
|
+
"NO_PROXY",
|
|
604
|
+
"SSL_CERT_FILE",
|
|
605
|
+
"SSL_CERT_DIR",
|
|
606
|
+
)
|
|
607
|
+
env = {k: os.environ[k] for k in allowed if k in os.environ}
|
|
608
|
+
env.update(
|
|
609
|
+
HOME=str(Path.home()),
|
|
610
|
+
USER=os.environ.get("USER", ""),
|
|
611
|
+
DISABLE_AUTOUPDATER="1",
|
|
612
|
+
DISABLE_COMPACT="1",
|
|
613
|
+
)
|
|
614
|
+
if harness == "claude":
|
|
615
|
+
# The access token alone, with LAUNCH_MARGIN of life left, so the host
|
|
616
|
+
# does not renew the login out from under the writer (``claude_login``).
|
|
617
|
+
env["CLAUDE_CODE_OAUTH_TOKEN"], _ = claude_login.host_login()
|
|
618
|
+
jail = blind.jail_argv(
|
|
619
|
+
workspace,
|
|
620
|
+
runners=[harness],
|
|
621
|
+
bind_credentials=harness != "claude",
|
|
622
|
+
bus=bus,
|
|
623
|
+
canary=canary,
|
|
624
|
+
)
|
|
625
|
+
private = blind.state_root(workspace) / harness
|
|
626
|
+
if harness == "claude":
|
|
627
|
+
runtime = private / "runtime-config.json"
|
|
628
|
+
if not runtime.exists():
|
|
629
|
+
runtime.write_text(
|
|
630
|
+
json.dumps({"hasCompletedOnboarding": True, "theme": "dark"}) + "\n"
|
|
631
|
+
)
|
|
632
|
+
jail += ["--bind", str(runtime), str(Path.home() / ".claude.json")]
|
|
633
|
+
# Put the read-only mounts after the workspace mount, which is writable.
|
|
634
|
+
for name in ("input", "START.md"):
|
|
635
|
+
path = workspace / name
|
|
636
|
+
jail += ["--ro-bind", str(path), str(path)]
|
|
637
|
+
return jail, env, private
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _model_matches(expected, actual):
|
|
641
|
+
"""Whether the served model id satisfies the requested one.
|
|
642
|
+
|
|
643
|
+
A family is satisfied by any served id carrying every family word, so
|
|
644
|
+
``fable`` accepts ``claude-fable-5-1``. That once failed every fable seat
|
|
645
|
+
after it had spent its quota, because only opus, sonnet and haiku were
|
|
646
|
+
known here. A version must match exactly, or with a date suffix.
|
|
647
|
+
"""
|
|
648
|
+
if not actual:
|
|
649
|
+
return False
|
|
650
|
+
if is_family(expected):
|
|
651
|
+
return set(_words(expected)) <= set(_words(actual))
|
|
652
|
+
return actual == expected or bool(
|
|
653
|
+
re.fullmatch(re.escape(expected) + r"-\d{8}", actual)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def compaction_events(rows):
|
|
658
|
+
"""The event types in a stream that report a compaction, in order.
|
|
659
|
+
|
|
660
|
+
Read from the fields that name an event, never from what it says: a
|
|
661
|
+
reviewer that reads a procedure about compaction writes the word into its
|
|
662
|
+
stream a dozen times without anything having compacted.
|
|
663
|
+
"""
|
|
664
|
+
found = []
|
|
665
|
+
for row in rows:
|
|
666
|
+
payload = row.get("payload") or {}
|
|
667
|
+
fields = (
|
|
668
|
+
row.get("type"),
|
|
669
|
+
row.get("subtype"),
|
|
670
|
+
row.get("event"),
|
|
671
|
+
payload.get("type"),
|
|
672
|
+
row.get("step_update", {}).get("step_type"),
|
|
673
|
+
)
|
|
674
|
+
label = next(
|
|
675
|
+
(str(v) for v in fields if v and "compact" in str(v).lower()), None
|
|
676
|
+
)
|
|
677
|
+
if label:
|
|
678
|
+
found.append(label)
|
|
679
|
+
return found
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _no_compaction(rows):
|
|
683
|
+
if compaction_events(rows):
|
|
684
|
+
raise RuntimeError(
|
|
685
|
+
"session compacted; preserve it as an invalid experimental attempt"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _rollouts(private, harness, sid, *, resumed=False):
|
|
690
|
+
"""Every native transcript belonging to one session.
|
|
691
|
+
|
|
692
|
+
A writer that ran once has exactly one, and anything else means the record
|
|
693
|
+
is not what it claims to be -- so the single-file requirement stays for
|
|
694
|
+
that case. A writer CONTINUED after a quota limit is the exception the
|
|
695
|
+
rule cannot cover: the second submission may land beside the first rather
|
|
696
|
+
than inside it, and both files are then the same session's evidence.
|
|
697
|
+
"""
|
|
698
|
+
base = private / ("sessions" if harness == "codex" else "projects")
|
|
699
|
+
paths = sorted(base.rglob(f"*{sid}*.jsonl"))
|
|
700
|
+
if not paths or (len(paths) != 1 and not resumed):
|
|
701
|
+
raise RuntimeError(
|
|
702
|
+
f"expected one native {harness} transcript for {sid}, found {len(paths)}"
|
|
703
|
+
)
|
|
704
|
+
return paths
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def request_size(harness, row):
|
|
708
|
+
"""Tokens one request carried, from a row of the harness's own accounting.
|
|
709
|
+
|
|
710
|
+
None for a row that reports no request. Shared by the peak measured after
|
|
711
|
+
a run and the figure `review status` shows while one is running, so the
|
|
712
|
+
two cannot disagree about what a request is.
|
|
713
|
+
"""
|
|
714
|
+
if harness == "codex":
|
|
715
|
+
payload = row.get("payload") or {}
|
|
716
|
+
if row.get("type") != "event_msg" or payload.get("type") != "token_count":
|
|
717
|
+
return None
|
|
718
|
+
last = (payload.get("info") or {}).get("last_token_usage") or {}
|
|
719
|
+
if not last:
|
|
720
|
+
return None
|
|
721
|
+
return (last.get("input_tokens") or 0) + (last.get("cached_input_tokens") or 0)
|
|
722
|
+
if harness == "claude":
|
|
723
|
+
usage = (row.get("message") or {}).get("usage") or {}
|
|
724
|
+
if not usage:
|
|
725
|
+
return None
|
|
726
|
+
# The three parts of one prompt: cached prefix, newly cached prefix,
|
|
727
|
+
# and the uncached remainder.
|
|
728
|
+
return sum(
|
|
729
|
+
usage.get(k) or 0
|
|
730
|
+
for k in (
|
|
731
|
+
"input_tokens",
|
|
732
|
+
"cache_read_input_tokens",
|
|
733
|
+
"cache_creation_input_tokens",
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
if harness == "agy":
|
|
737
|
+
# Each finished model step reports the request it made. The closing
|
|
738
|
+
# `result` row is not a request: it is a running total over every
|
|
739
|
+
# request the session has made, earlier turns included.
|
|
740
|
+
if row.get("event") != "step_update":
|
|
741
|
+
return None
|
|
742
|
+
usage = (row.get("step_update") or {}).get("usage") or {}
|
|
743
|
+
if not usage:
|
|
744
|
+
return None
|
|
745
|
+
return (usage.get("input_tokens") or 0) + (usage.get("cache_read_tokens") or 0)
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def context_peak(harness, record):
|
|
750
|
+
"""Largest single request, which is the only figure a window bounds.
|
|
751
|
+
|
|
752
|
+
A receipt's token counters are summed over every request in a session and
|
|
753
|
+
say nothing about occupancy: the highest cumulative input in one run
|
|
754
|
+
belonged to the writer that read the least. What a 200k model has to fit
|
|
755
|
+
is one prompt -- the conversation, its tool output and the prose written
|
|
756
|
+
so far -- so that is what is measured here, from the harness's own
|
|
757
|
+
per-request accounting and never from the packet's size.
|
|
758
|
+
"""
|
|
759
|
+
out = {"peak_request": None, "window": None, "evidence": "not reported"}
|
|
760
|
+
if harness == "codex":
|
|
761
|
+
peak = window = 0
|
|
762
|
+
for path in sorted((record / "transcripts").glob("*.jsonl")):
|
|
763
|
+
for row in json_rows(path):
|
|
764
|
+
payload = row.get("payload") or {}
|
|
765
|
+
if (
|
|
766
|
+
row.get("type") != "event_msg"
|
|
767
|
+
or payload.get("type") != "token_count"
|
|
768
|
+
):
|
|
769
|
+
continue
|
|
770
|
+
info = payload.get("info") or {}
|
|
771
|
+
window = info.get("model_context_window") or window
|
|
772
|
+
peak = max(peak, request_size(harness, row) or 0)
|
|
773
|
+
if peak:
|
|
774
|
+
out.update(
|
|
775
|
+
peak_request=peak,
|
|
776
|
+
window=window or None,
|
|
777
|
+
evidence="rollout token_count.last_token_usage",
|
|
778
|
+
)
|
|
779
|
+
else:
|
|
780
|
+
peak = window = 0
|
|
781
|
+
events = record / "events.jsonl"
|
|
782
|
+
for row in json_rows(events) if events.exists() else []:
|
|
783
|
+
peak = max(peak, request_size(harness, row) or 0)
|
|
784
|
+
for served in (row.get("modelUsage") or {}).values():
|
|
785
|
+
window = max(window, served.get("contextWindow") or 0)
|
|
786
|
+
if peak:
|
|
787
|
+
out.update(
|
|
788
|
+
peak_request=peak,
|
|
789
|
+
window=window or None,
|
|
790
|
+
evidence=REQUEST_EVIDENCE[harness],
|
|
791
|
+
)
|
|
792
|
+
elif harness == "agy":
|
|
793
|
+
# An agy that stops reporting its steps is unmeasured, not small.
|
|
794
|
+
out["evidence"] = "agy reported no per-request usage"
|
|
795
|
+
return out
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# Where each harness's per-request size is read from, as a receipt states it.
|
|
799
|
+
REQUEST_EVIDENCE = {
|
|
800
|
+
"codex": "rollout token_count.last_token_usage",
|
|
801
|
+
"claude": "assistant message usage",
|
|
802
|
+
"agy": "step_update usage",
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# Context windows no harness states before a turn has run. Codex lists its
|
|
807
|
+
# own in the catalog, but Claude Code reports a window only in a finished
|
|
808
|
+
# turn's result and Antigravity never does, so a procedure that must state a
|
|
809
|
+
# budget before the first turn reads the window here. Claude's figures are
|
|
810
|
+
# Anthropic's model table (cached 2026-10); Claude Code reported the same for
|
|
811
|
+
# Sonnet in the September 2026 pilots and for Haiku 5.5 in October 2026.
|
|
812
|
+
# Gemini's is Google's documented 1M-token input window for Gemini 3 models;
|
|
813
|
+
# Antigravity may cap a session lower than that and has no way to say so.
|
|
814
|
+
#
|
|
815
|
+
# A family's figure is its newest model's, because the CLI resolves a family
|
|
816
|
+
# alias to that model. An older model named by its exact id can have a smaller
|
|
817
|
+
# window than its family's newest, so it is listed under that id: Haiku 4.5
|
|
818
|
+
# kept 200,000 tokens when Haiku 5.5 brought the family to a million.
|
|
819
|
+
KNOWN_WINDOWS = {
|
|
820
|
+
"haiku": 1_000_000,
|
|
821
|
+
"sonnet": 1_000_000,
|
|
822
|
+
"opus": 1_000_000,
|
|
823
|
+
"fable": 1_000_000,
|
|
824
|
+
"mythos": 1_000_000,
|
|
825
|
+
"gemini": 1_048_576,
|
|
826
|
+
}
|
|
827
|
+
KNOWN_EXACT_WINDOWS = {
|
|
828
|
+
"claude-haiku-4-5": 200_000,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def stated_window(config):
|
|
833
|
+
"""A model's context window before it has run, and where the figure is from.
|
|
834
|
+
|
|
835
|
+
``(None, None)`` when nothing here knows it: a caller that has to state a
|
|
836
|
+
budget then asks for the window instead of guessing one.
|
|
837
|
+
"""
|
|
838
|
+
if config["harness"] == "codex":
|
|
839
|
+
window = config.get("max_context_window")
|
|
840
|
+
if not window:
|
|
841
|
+
return None, None
|
|
842
|
+
# The share of the ceiling Codex actually hands a session: 95 percent
|
|
843
|
+
# of 872,000 is the 828,400 its own token counts report.
|
|
844
|
+
share = config.get("effective_context_window_percent") or 100
|
|
845
|
+
return int(window * share / 100), "Codex catalog"
|
|
846
|
+
model = str(config["model"]).strip().lower()
|
|
847
|
+
for exact, window in KNOWN_EXACT_WINDOWS.items():
|
|
848
|
+
if model == exact or re.fullmatch(re.escape(exact) + r"-\d{8}", model):
|
|
849
|
+
return window, "documented window"
|
|
850
|
+
words = set(_words(model))
|
|
851
|
+
for family, window in KNOWN_WINDOWS.items():
|
|
852
|
+
if family in words:
|
|
853
|
+
return window, "documented window"
|
|
854
|
+
return None, None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# Characters per token when a transcript's size stands in for a count. It
|
|
858
|
+
# leans low, so the estimate leans high: retiring a seat a little early
|
|
859
|
+
# costs less than letting it run into a wall nobody measured.
|
|
860
|
+
CHARS_PER_TOKEN = 3.5
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def agy_estimate(usage, transcripts, before=None):
|
|
864
|
+
"""How full an Antigravity session is, estimated, when its steps are silent.
|
|
865
|
+
|
|
866
|
+
The fallback for an agy that reports no per-request usage. Its closing
|
|
867
|
+
usage is a running total over the whole session, so this turn's billing
|
|
868
|
+
is that total less the total after the previous turn, and divided by the
|
|
869
|
+
requests this turn made it is the turn's average context. Growth within
|
|
870
|
+
a turn is roughly even, so the end sits about half the turn's growth
|
|
871
|
+
above that average, and the growth is the transcript's new bytes.
|
|
872
|
+
``before`` is the transcript's size, request count and running billing
|
|
873
|
+
after the previous turn, which the caller keeps; the returned ``marker``
|
|
874
|
+
is the same figure after this one. A turn with no billed requests adds
|
|
875
|
+
its growth to nothing measured and is reported as unknown.
|
|
876
|
+
|
|
877
|
+
The first version divided the running total by one turn's requests. In
|
|
878
|
+
a four-round table that put two Gemini seats at three times the context
|
|
879
|
+
their own requests reported, and would have retired them early.
|
|
880
|
+
"""
|
|
881
|
+
size = responses = 0
|
|
882
|
+
for path in transcripts:
|
|
883
|
+
path = Path(path)
|
|
884
|
+
if not path.exists():
|
|
885
|
+
continue
|
|
886
|
+
size += path.stat().st_size
|
|
887
|
+
responses += sum(
|
|
888
|
+
1 for row in json_rows(path) if row.get("type") == "PLANNER_RESPONSE"
|
|
889
|
+
)
|
|
890
|
+
total = sum(
|
|
891
|
+
(usage or {}).get(k) or 0 for k in ("input_tokens", "cache_read_tokens")
|
|
892
|
+
)
|
|
893
|
+
marker = {"bytes": size, "responses": responses, "billed": total}
|
|
894
|
+
before = before or {"bytes": 0, "responses": 0}
|
|
895
|
+
requests = responses - before["responses"]
|
|
896
|
+
# A marker kept before the billing was recorded cannot be subtracted, and
|
|
897
|
+
# the old overestimate is the most that can be said then.
|
|
898
|
+
billed = total - (before.get("billed") or 0)
|
|
899
|
+
if requests <= 0 or billed <= 0:
|
|
900
|
+
return None, marker
|
|
901
|
+
growth = max(0, size - before["bytes"]) / CHARS_PER_TOKEN
|
|
902
|
+
return int(billed / requests + growth / 2), marker
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def context_now(
|
|
906
|
+
harness, record, transcripts=(), *, usage=None, before=None, estimate=False
|
|
907
|
+
):
|
|
908
|
+
"""How full a session's window is after its latest request.
|
|
909
|
+
|
|
910
|
+
Not the peak. With compaction off, the latest request carries the whole
|
|
911
|
+
conversation so far, so its size is what the next turn starts from, and
|
|
912
|
+
a seat told that figure can decide how much of the rest it can afford.
|
|
913
|
+
``transcripts`` are the native rollouts a receipt names, which is where
|
|
914
|
+
Codex keeps its per-request accounting; Claude keeps it in the stream.
|
|
915
|
+
Antigravity reports each request in its step events; where those are
|
|
916
|
+
missing it is estimated when ``estimate`` asks for it and unknown
|
|
917
|
+
otherwise, never reported as small.
|
|
918
|
+
"""
|
|
919
|
+
out = {"tokens": None, "window": None, "evidence": "not reported"}
|
|
920
|
+
if harness == "codex":
|
|
921
|
+
for path in sorted(Path(p) for p in transcripts):
|
|
922
|
+
for row in json_rows(path):
|
|
923
|
+
size = request_size(harness, row)
|
|
924
|
+
if size is None:
|
|
925
|
+
continue
|
|
926
|
+
info = (row.get("payload") or {}).get("info") or {}
|
|
927
|
+
out.update(
|
|
928
|
+
tokens=size,
|
|
929
|
+
window=info.get("model_context_window") or out["window"],
|
|
930
|
+
evidence="rollout token_count.last_token_usage",
|
|
931
|
+
)
|
|
932
|
+
elif harness == "claude":
|
|
933
|
+
events = record / "events.jsonl"
|
|
934
|
+
for row in json_rows(events) if events.exists() else []:
|
|
935
|
+
if row.get("type") == "assistant":
|
|
936
|
+
size = request_size(harness, row)
|
|
937
|
+
if size:
|
|
938
|
+
out.update(tokens=size, evidence="assistant message usage")
|
|
939
|
+
for served in (row.get("modelUsage") or {}).values():
|
|
940
|
+
out["window"] = (
|
|
941
|
+
max(out["window"] or 0, served.get("contextWindow") or 0) or None
|
|
942
|
+
)
|
|
943
|
+
elif harness == "agy":
|
|
944
|
+
events = record / "events.jsonl"
|
|
945
|
+
sizes = (
|
|
946
|
+
[request_size(harness, r) for r in json_rows(events)]
|
|
947
|
+
if (events.exists())
|
|
948
|
+
else []
|
|
949
|
+
)
|
|
950
|
+
last = next((n for n in reversed(sizes) if n is not None), None)
|
|
951
|
+
# The marker is kept even when the steps are measured, so a later
|
|
952
|
+
# turn whose steps fall silent still has a running total to subtract.
|
|
953
|
+
guess, marker = (
|
|
954
|
+
agy_estimate(usage, transcripts, before) if estimate else (None, None)
|
|
955
|
+
)
|
|
956
|
+
if last is not None:
|
|
957
|
+
out.update(tokens=last, evidence=REQUEST_EVIDENCE[harness])
|
|
958
|
+
elif estimate:
|
|
959
|
+
out.update(
|
|
960
|
+
tokens=guess,
|
|
961
|
+
evidence="estimated from billed tokens and transcript growth",
|
|
962
|
+
estimated=True,
|
|
963
|
+
)
|
|
964
|
+
else:
|
|
965
|
+
out["evidence"] = "agy reported no per-request usage"
|
|
966
|
+
if marker:
|
|
967
|
+
out["marker"] = marker
|
|
968
|
+
return out
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _check_resumed(sid, resumed):
|
|
972
|
+
"""A resume must land in the session it named, and this is checked FIRST.
|
|
973
|
+
|
|
974
|
+
Before the transcript lookups, because a resume that quietly opened a new
|
|
975
|
+
conversation fails those with a confusing message about a missing file --
|
|
976
|
+
and because the draft it returns is a cold restart wearing a
|
|
977
|
+
continuation's receipt, which nothing else would notice.
|
|
978
|
+
"""
|
|
979
|
+
if resumed and sid != resumed:
|
|
980
|
+
raise RuntimeError(
|
|
981
|
+
f"resume landed in session {sid}, not {resumed}; the writer did "
|
|
982
|
+
"not continue its own session"
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def receipt(config, record, private, *, manual=False, resumed=None):
|
|
987
|
+
"""Use harness evidence, not the writer's claim about its own identity.
|
|
988
|
+
|
|
989
|
+
``resumed`` names the session this attempt was supposed to continue. It is
|
|
990
|
+
checked against the session the harness actually reports: a resume that
|
|
991
|
+
quietly opened a new conversation is otherwise indistinguishable from one
|
|
992
|
+
that continued, and the draft it returns would be a cold restart wearing a
|
|
993
|
+
continuation's receipt.
|
|
994
|
+
"""
|
|
995
|
+
harness = config["harness"]
|
|
996
|
+
event_file = record / "events.jsonl"
|
|
997
|
+
rows = json_rows(event_file) if event_file.exists() else []
|
|
998
|
+
_no_compaction(rows)
|
|
999
|
+
native = []
|
|
1000
|
+
answer = ""
|
|
1001
|
+
# Only Claude's record says why each response stopped and marks what
|
|
1002
|
+
# the harness itself wrote; see `replies`.
|
|
1003
|
+
reply = dict(output_limit=None, harness_text=[])
|
|
1004
|
+
if harness == "codex":
|
|
1005
|
+
if manual:
|
|
1006
|
+
paths = list((private / "sessions").rglob("*.jsonl"))
|
|
1007
|
+
if len(paths) != 1:
|
|
1008
|
+
raise RuntimeError(
|
|
1009
|
+
"manual Codex session has no unique native transcript"
|
|
1010
|
+
)
|
|
1011
|
+
native_rows = json_rows(paths[0])
|
|
1012
|
+
meta = next(
|
|
1013
|
+
r["payload"] for r in native_rows if r.get("type") == "session_meta"
|
|
1014
|
+
)
|
|
1015
|
+
sid = meta["id"]
|
|
1016
|
+
else:
|
|
1017
|
+
ids = {r["thread_id"] for r in rows if r.get("type") == "thread.started"}
|
|
1018
|
+
if len(ids) != 1 or not any(
|
|
1019
|
+
r.get("type") == "turn.completed" for r in rows
|
|
1020
|
+
):
|
|
1021
|
+
raise RuntimeError(
|
|
1022
|
+
"Codex has no unique completed turn; inspect events and stderr"
|
|
1023
|
+
)
|
|
1024
|
+
sid = ids.pop()
|
|
1025
|
+
_check_resumed(sid, resumed)
|
|
1026
|
+
paths = _rollouts(private, harness, sid, resumed=bool(resumed))
|
|
1027
|
+
native_rows = [r for p in paths for r in json_rows(p)]
|
|
1028
|
+
answers = [
|
|
1029
|
+
r["item"]["text"]
|
|
1030
|
+
for r in rows
|
|
1031
|
+
if r.get("type") == "item.completed"
|
|
1032
|
+
and r.get("item", {}).get("type") == "agent_message"
|
|
1033
|
+
]
|
|
1034
|
+
answer = answers[-1] if answers else ""
|
|
1035
|
+
native = paths
|
|
1036
|
+
_no_compaction(native_rows)
|
|
1037
|
+
contexts = [
|
|
1038
|
+
r["payload"] for r in native_rows if r.get("type") == "turn_context"
|
|
1039
|
+
]
|
|
1040
|
+
models = {c.get("model") for c in contexts}
|
|
1041
|
+
efforts = {c.get("effort") for c in contexts}
|
|
1042
|
+
if models != {config["model"]} or efforts != {config["effort"]}:
|
|
1043
|
+
raise RuntimeError(f"Codex model/effort mismatch: {models}, {efforts}")
|
|
1044
|
+
if manual and not any(
|
|
1045
|
+
r.get("payload", {}).get("type") == "task_complete" for r in native_rows
|
|
1046
|
+
):
|
|
1047
|
+
raise RuntimeError("manual Codex transcript has no completed task")
|
|
1048
|
+
actual = config["model"]
|
|
1049
|
+
usage = next(
|
|
1050
|
+
(
|
|
1051
|
+
r.get("usage")
|
|
1052
|
+
for r in reversed(rows)
|
|
1053
|
+
if r.get("type") == "turn.completed"
|
|
1054
|
+
),
|
|
1055
|
+
None,
|
|
1056
|
+
)
|
|
1057
|
+
elif harness == "claude":
|
|
1058
|
+
if manual:
|
|
1059
|
+
paths = list((private / "projects").rglob("*.jsonl"))
|
|
1060
|
+
paths = [p for p in paths if "subagents" not in p.parts]
|
|
1061
|
+
if len(paths) != 1:
|
|
1062
|
+
raise RuntimeError(
|
|
1063
|
+
"manual Claude session has no unique native transcript"
|
|
1064
|
+
)
|
|
1065
|
+
sid = paths[0].stem
|
|
1066
|
+
rows = json_rows(paths[0])
|
|
1067
|
+
if not any(
|
|
1068
|
+
r.get("message", {}).get("stop_reason") == "end_turn" for r in rows
|
|
1069
|
+
):
|
|
1070
|
+
raise RuntimeError("manual Claude transcript has no completed turn")
|
|
1071
|
+
usage = None
|
|
1072
|
+
else:
|
|
1073
|
+
results = [r for r in rows if r.get("type") == "result"]
|
|
1074
|
+
if (
|
|
1075
|
+
len(results) != 1
|
|
1076
|
+
or results[0].get("is_error")
|
|
1077
|
+
or results[0].get("subtype") != "success"
|
|
1078
|
+
):
|
|
1079
|
+
# A quota stop and a crash both land here, and the harness
|
|
1080
|
+
# states which in the result text it still returns. Quote it:
|
|
1081
|
+
# an operator told only that a file is absent looks for the
|
|
1082
|
+
# wrong cause. Note that `subtype` can read "success" while
|
|
1083
|
+
# `is_error` is set.
|
|
1084
|
+
said = (results[0].get("result") or "").strip() if results else ""
|
|
1085
|
+
raise RuntimeError(
|
|
1086
|
+
"Claude has no successful completed result"
|
|
1087
|
+
+ (f": {said[:300]}" if said else "; inspect events and stderr")
|
|
1088
|
+
)
|
|
1089
|
+
result = results[0]
|
|
1090
|
+
sid, answer, usage = (
|
|
1091
|
+
result["session_id"],
|
|
1092
|
+
result.get("result", ""),
|
|
1093
|
+
result.get("modelUsage"),
|
|
1094
|
+
)
|
|
1095
|
+
_check_resumed(sid, resumed)
|
|
1096
|
+
paths = _rollouts(private, harness, sid, resumed=bool(resumed))
|
|
1097
|
+
# The result row holds only the last response, so a reply the
|
|
1098
|
+
# output ceiling cut is rebuilt from every part of it.
|
|
1099
|
+
answer, reply = replies.claude(
|
|
1100
|
+
rows, [r for p in paths for r in json_rows(p)]
|
|
1101
|
+
)
|
|
1102
|
+
native = paths
|
|
1103
|
+
for path in paths:
|
|
1104
|
+
_no_compaction(json_rows(path))
|
|
1105
|
+
models = {
|
|
1106
|
+
r.get("message", {}).get("model")
|
|
1107
|
+
for r in rows
|
|
1108
|
+
if r.get("type") == "assistant"
|
|
1109
|
+
and r.get("message", {}).get("model") not in (None, "<synthetic>")
|
|
1110
|
+
}
|
|
1111
|
+
expected = config.get("model_pinned") or config["model"]
|
|
1112
|
+
if len(models) != 1 or not _model_matches(expected, next(iter(models))):
|
|
1113
|
+
raise RuntimeError(f"Claude served model mismatch: {models}")
|
|
1114
|
+
actual = models.pop()
|
|
1115
|
+
# Effort is selected by the CLI. Do not claim a served-effort receipt
|
|
1116
|
+
# when the provider does not expose one in its stream.
|
|
1117
|
+
else:
|
|
1118
|
+
paths = list(
|
|
1119
|
+
private.glob(
|
|
1120
|
+
"antigravity-cli/brain/*/.system_generated/logs/transcript_full.jsonl"
|
|
1121
|
+
)
|
|
1122
|
+
)
|
|
1123
|
+
if manual:
|
|
1124
|
+
if len(paths) != 1:
|
|
1125
|
+
raise RuntimeError("manual agy session has no unique native transcript")
|
|
1126
|
+
sid = paths[0].parents[2].name
|
|
1127
|
+
native_rows = json_rows(paths[0])
|
|
1128
|
+
_no_compaction(native_rows)
|
|
1129
|
+
last = native_rows[-1] if native_rows else {}
|
|
1130
|
+
if (
|
|
1131
|
+
last.get("type") != "PLANNER_RESPONSE"
|
|
1132
|
+
or last.get("status") != "DONE"
|
|
1133
|
+
or not last.get("content")
|
|
1134
|
+
):
|
|
1135
|
+
raise RuntimeError(
|
|
1136
|
+
"manual agy transcript has no completed final response"
|
|
1137
|
+
)
|
|
1138
|
+
answer, usage, actual = last["content"], None, None
|
|
1139
|
+
else:
|
|
1140
|
+
results = [r["result"] for r in rows if r.get("event") == "result"]
|
|
1141
|
+
if len(results) != 1:
|
|
1142
|
+
raise RuntimeError(
|
|
1143
|
+
"agy has no unique final result; inspect events and stderr"
|
|
1144
|
+
)
|
|
1145
|
+
result = results[0]
|
|
1146
|
+
if result.get("status") not in ("SUCCESS", "COMPLETED", "DONE"):
|
|
1147
|
+
raise RuntimeError(f"agy result status: {result.get('status')}")
|
|
1148
|
+
ids = {r["conversation_id"] for r in rows if r.get("conversation_id")}
|
|
1149
|
+
if len(ids) != 1:
|
|
1150
|
+
raise RuntimeError("agy has no unique conversation ID")
|
|
1151
|
+
sid = ids.pop()
|
|
1152
|
+
_check_resumed(sid, resumed)
|
|
1153
|
+
actual = result.get("model") or next(
|
|
1154
|
+
(
|
|
1155
|
+
r.get("init", {}).get("model")
|
|
1156
|
+
for r in rows
|
|
1157
|
+
if r.get("event") == "init"
|
|
1158
|
+
),
|
|
1159
|
+
None,
|
|
1160
|
+
)
|
|
1161
|
+
if actual != config["model"]:
|
|
1162
|
+
raise RuntimeError(
|
|
1163
|
+
f"agy model mismatch or missing init receipt: {actual}"
|
|
1164
|
+
)
|
|
1165
|
+
answer, usage = result.get("response", ""), result.get("usage")
|
|
1166
|
+
paths = [p for p in paths if p.parents[2].name == sid]
|
|
1167
|
+
if len(paths) != 1:
|
|
1168
|
+
raise RuntimeError("agy native transcript is absent or ambiguous")
|
|
1169
|
+
native = paths
|
|
1170
|
+
native_rows = json_rows(paths[0])
|
|
1171
|
+
_no_compaction(native_rows)
|
|
1172
|
+
forbidden = []
|
|
1173
|
+
# agy cannot be told which tools a seat may use, so its transcript is
|
|
1174
|
+
# read afterwards instead. The web is outside a writing assignment and
|
|
1175
|
+
# inside a workbench one; the browser, MCP servers, subagents and
|
|
1176
|
+
# messaging stay outside both, because each would put something other
|
|
1177
|
+
# than this one model into the seat.
|
|
1178
|
+
outside = r"browser|mcp|invoke_subagent|send_message"
|
|
1179
|
+
if config.get("tools") != "workbench":
|
|
1180
|
+
outside += r"|search_web|url"
|
|
1181
|
+
for row in native_rows:
|
|
1182
|
+
for call in row.get("tool_calls", []):
|
|
1183
|
+
name = call.get("name", "")
|
|
1184
|
+
if re.search(outside, name):
|
|
1185
|
+
forbidden.append(name)
|
|
1186
|
+
if name == "generate_image" and "image-generation" not in config.get(
|
|
1187
|
+
"capabilities", []
|
|
1188
|
+
):
|
|
1189
|
+
forbidden.append(name)
|
|
1190
|
+
if forbidden:
|
|
1191
|
+
raise RuntimeError(
|
|
1192
|
+
f"agy used tools outside the text assignment: {forbidden}"
|
|
1193
|
+
)
|
|
1194
|
+
return {
|
|
1195
|
+
"session_id": sid,
|
|
1196
|
+
"model": actual,
|
|
1197
|
+
"resumed_from": resumed,
|
|
1198
|
+
"selected_model": config["model"],
|
|
1199
|
+
**(
|
|
1200
|
+
{"model_pinned": config["model_pinned"]}
|
|
1201
|
+
if config.get("model_pinned")
|
|
1202
|
+
else {}
|
|
1203
|
+
),
|
|
1204
|
+
"model_evidence": (
|
|
1205
|
+
"harness init" if harness == "agy" else "assistant/turn context"
|
|
1206
|
+
)
|
|
1207
|
+
if actual
|
|
1208
|
+
else "explicit CLI selection; no served model receipt",
|
|
1209
|
+
"effort": config["effort"],
|
|
1210
|
+
"effort_evidence": "turn_context"
|
|
1211
|
+
if harness == "codex"
|
|
1212
|
+
else "explicit CLI selection",
|
|
1213
|
+
"usage": usage,
|
|
1214
|
+
"native_transcripts": [str(p) for p in native],
|
|
1215
|
+
"output_limit": reply["output_limit"],
|
|
1216
|
+
"harness_text": reply["harness_text"],
|
|
1217
|
+
"red_flags": replies.flags(reply),
|
|
1218
|
+
"answer": answer,
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
# What each harness calls the same quantity, and the one place that knows it.
|
|
1223
|
+
# Three adapters report usage in three native shapes, so a caller adding up a
|
|
1224
|
+
# mixed room either restates this mapping or silently reads only the seats it
|
|
1225
|
+
# happens to understand. A first attempt at totalling a four-seat room summed
|
|
1226
|
+
# two of them and printed a figure that looked complete.
|
|
1227
|
+
#
|
|
1228
|
+
# `input` and `cache_read` are reported as the harness counts them, and the
|
|
1229
|
+
# harnesses do not agree: Codex counts cached tokens inside `input_tokens`,
|
|
1230
|
+
# Claude counts them beside it. Reconciling that here would mean inventing a
|
|
1231
|
+
# number no receipt contains, so the difference is surfaced instead.
|
|
1232
|
+
USAGE_FIELDS = {
|
|
1233
|
+
"codex": {
|
|
1234
|
+
"input": "input_tokens",
|
|
1235
|
+
"output": "output_tokens",
|
|
1236
|
+
"reasoning": "reasoning_output_tokens",
|
|
1237
|
+
"cache_read": "cached_input_tokens",
|
|
1238
|
+
},
|
|
1239
|
+
"agy": {
|
|
1240
|
+
"input": "input_tokens",
|
|
1241
|
+
"output": "output_tokens",
|
|
1242
|
+
"reasoning": "thinking_tokens",
|
|
1243
|
+
"cache_read": "cache_read_tokens",
|
|
1244
|
+
},
|
|
1245
|
+
"claude": {
|
|
1246
|
+
"input": "inputTokens",
|
|
1247
|
+
"output": "outputTokens",
|
|
1248
|
+
"reasoning": "thinkingTokens",
|
|
1249
|
+
"cache_read": "cacheReadInputTokens",
|
|
1250
|
+
"cost_usd": "costUSD",
|
|
1251
|
+
},
|
|
1252
|
+
}
|
|
1253
|
+
CACHE_INSIDE_INPUT = ("codex",)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def usage_totals(harness, usage):
|
|
1257
|
+
"""One receipt's usage in common terms, or nothing if it reported none.
|
|
1258
|
+
|
|
1259
|
+
Claude nests usage under the model that served the turn and is the only
|
|
1260
|
+
harness reporting a price, so `cost_usd` is None elsewhere rather than
|
|
1261
|
+
zero: a room whose cost is partly unknown must not read as a cheaper room.
|
|
1262
|
+
"""
|
|
1263
|
+
fields = USAGE_FIELDS.get(harness)
|
|
1264
|
+
if not fields or not isinstance(usage, dict) or not usage:
|
|
1265
|
+
return None
|
|
1266
|
+
rows = (
|
|
1267
|
+
[v for v in usage.values() if isinstance(v, dict)]
|
|
1268
|
+
if harness == "claude"
|
|
1269
|
+
else [usage]
|
|
1270
|
+
)
|
|
1271
|
+
if not rows:
|
|
1272
|
+
return None
|
|
1273
|
+
totals = {name: 0 for name in ("input", "output", "reasoning", "cache_read")}
|
|
1274
|
+
cost = 0.0 if "cost_usd" in fields else None
|
|
1275
|
+
for row in rows:
|
|
1276
|
+
for name in totals:
|
|
1277
|
+
totals[name] += row.get(fields[name]) or 0
|
|
1278
|
+
if cost is not None:
|
|
1279
|
+
cost += row.get(fields["cost_usd"]) or 0.0
|
|
1280
|
+
return {
|
|
1281
|
+
**totals,
|
|
1282
|
+
"cost_usd": cost,
|
|
1283
|
+
"cache_inside_input": harness in CACHE_INSIDE_INPUT,
|
|
1284
|
+
}
|