-
Notifications
You must be signed in to change notification settings - Fork 48
Expand file tree
/
Copy pathauthor.py
More file actions
472 lines (421 loc) · 29.2 KB
/
Copy pathauthor.py
File metadata and controls
472 lines (421 loc) · 29.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
"""One-shot room authoring.
A single self-paced model pass reconstructs the room's shell materials and the wall/ceiling/floor
fixtures by editing `Room.py` in place — there is no staged survey/plan/fix orchestration. The
model is given generic Read/Edit/Write on the room directory plus a set of capability tools that
plain file-editing cannot replace: `fetch_material` (real Poly Haven PBR — diffuse + roughness +
normal, LAB-recoloured to the measured colour) and `render` / `critic` / `select_views` as an
optional self-check it may call to compare its render against the capture photos and correct
colour and placement.
uv run python -m litereality_agent.pipeline.realism_authoring.author.entrypoint --scene <scene dir>
`--scene` is the scene package the seed stage wrote (the folder holding `scene.json`); it supplies the
room, the surface references and the capture. Omit it entirely when $LR_SCENE is set or the
current directory is inside a package. The explicit spelling still works and still wins:
... -m litereality_agent.pipeline.realism_authoring.author.entrypoint \
--room <room dir> --surface-ref <dir> --scan <scan dir>
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
# Surfaces are DISCOVERED from the room, never assumed: a scan has as many walls as RoomPlan gave it
# (a one-bed flat came back with 30). A hardcoded Wall0..WallN silently drops every wall past the cap
# from the prompt AND from the stitch-coverage check below, so the run reports full coverage while
# most of the room was never referenced. `surface_ids` parses Room.py's SHELL and also excludes
# RoomPlan's sub-0.35m corner-artifact stub walls, whose stitches are useless smears.
def surfaces_for(room: Path) -> list[str]:
from litereality_agent.room_ops.surfaces import surface_ids
return surface_ids(room / "Room.py")
# capability tools exposed to the authoring model: real materials + optional self-check
CAPABILITY_TOOLS = ("fetch_material", "render", "critic", "select_views", "grid", "check_collisions")
# Shared by both profiles. Measured on two runs of the same scene, the pass spent its whole budget
# exploring and made every edit at the very end: the first reached its first edit 16.5 min in (69%
# of the session, 85 of 91 tool calls already spent), the second was still on Read/Bash/
# fetch_material after 83 calls with no edit at all. Both called `render` at most once, before any
# edit existed — so "author, render, look, correct" never actually ran a cycle. Nothing in the old
# wording forbade that, and a self-paced model under a step budget will always bank the cheap calls
# first. Hence a stated cadence rather than encouragement, and an explicit first target: the floor
# and walls fill most of every frame, so they are what makes progress visible at all.
RHYTHM = """\
HOW TO WORK — SHIP CHANGES AS YOU GO, NOT IN ONE BATCH AT THE END.
Someone may be watching this room rebuild live, and an unedited `Room.py` shows them nothing. A run
that is cut short keeps only what you have already saved.
• EDIT `Room.py` AT LEAST ONCE EVERY 20-30 TOOL CALLS. If you are nearing 30 calls since your last
edit, stop investigating: make the best change you can justify from what you already know, save
it, and keep investigating afterwards. An imperfect saved change beats a perfect unsaved one.
• BIGGEST VISIBLE WIN FIRST — the FLOOR, then the WALLS. Their material fills most of every frame,
so the floor PBR set and the wall paints change the room more than any fixture can. Work in this
order, editing at each step: floor -> walls -> ceiling -> fixtures.
• Read for your NEXT edit, not for the whole room. `Room.md` and the `SHELL` dict up front, then ONE
surface's head-on stitch at a time — measure that surface, edit it, save, move to the next.
• Every edit must leave `Room.py` compiling. Many small complete changes, never one large one.
"""
PROMPT = """\
You are reconstructing a REAL room as an editable Python program, self-paced (no fixed steps).
{rhythm}
The room is `Room.py` in your working directory: a builder + a `SHELL` dict (walls, openings,
object boxes). FIRST Read `Room.md` and `Room.py` IN FULL to learn the helper API (how shell
materials are assigned, how geometry is added). Then edit `Room.py` IN PLACE so the rebuilt room
matches the real room in the images.
Two jobs, IN THIS ORDER — do (1) fully first, then (2):
1) SHELL STRUCTURE + MATERIALS — the FOUNDATION. Get the room itself right and render-check the bare
room BEFORE you add any fixture, so fixtures land on a room that already reads correctly.
• STRUCTURE FIRST: check the room's SHAPE against the photos and fix clear scan errors in the
`SHELL` dict — a missing or spurious wall, a wall with wrong endpoints/length, a door/window at
the wrong offset/width/height/sill (or one the scan missed or hallucinated), a wrong floor/ceiling
height. Edit the numbers in `SHELL` (see Room.md "Editing the scene"). Be CONSERVATIVE and
evidence-based: correct obvious errors against the photos, keep it metric, and do NOT redesign a
room that is already right. Do NOT move/resize the furniture object boxes
(Table*/Chair*/reconstructed objects) — structure means walls / openings / floor+ceiling height only.
Keep this pass SHORT — correct the obvious errors and move on to materials; do not audit every
number before your first edit.
• THEN MATERIALS, in the order FLOOR -> WALLS -> CEILING, covering every surface
({surface_list}): base colour (hue+lightness), finish, pattern. Do the FLOOR first — it is the
single largest visible surface. Save after each surface rather than batching them all into one
edit. READ PAST anything mounted on a wall — material is the exposed wall. Don't skip the
CEILING. Don't touch fixtures until the structure is corrected and every surface has a material.
2) WALL FIXTURES the scan missed but the photos show (sockets, switches, trunking, boards, signs,
radiators, shelves, skirting, ceiling vents, rugs) — simple procedural geometry flush to the
wall, anchored to the SHELL's opening offsets / wall lengths. Never over a Door/Window opening.
Only start this once (1) is done.
GROUP EVERY FIXTURE AS ONE UNIT. A fixture is usually several boxes (a whiteboard = frame bars +
face + tray; a radiator = panel + fins + valves). Those parts MUST be bundled into a single named,
hide-as-a-unit group — never left as a loose pile of boxes. Use the helper
`group_fixture(name, category, parts)` (defined near the collection helpers in `Room.py`; if your
`Room.py` doesn't have it, add it once: an empty named `name` with `room_id`/`category` props, each
part parented to it and moved into a collection named `name`). Collect the objects your box helpers
return into a list and pass them, e.g.
parts = [_wall_box(...), _wall_box(...), ...] # all boxes of THIS one fixture
group_fixture("Whiteboard0", "whiteboard", parts)
Name each fixture like the furniture handles — `Whiteboard0`, `Radiator0`, `Shelf0`, `Socket0`,
`Trunking0`, `Vent0` (increment per instance). The whole fixture then selects/hides/toggles as one
object in the viewer, exactly like `Table0`/`Chair0`. This grouping is REQUIRED, not optional.
The DECISIVE references are the HEAD-ON STITCHES (each shows one surface square-on). Read every one:
{stitch_lines}
Raw oblique frames are in {scan} (optional context).
You have TOOLS beyond editing — use them:
- `fetch_material(query, name, color_hex, pattern_strength)` — for any PATTERNED surface (carpet,
tile, wood, brick, strong plaster) pull a REAL Poly Haven PBR set (diffuse+roughness+normal),
LAB-recoloured to your measured colour. It saves into the room's `materials/` + `textures.json`
and returns a wiring snippet — wire it into `Room.py` (real UV scale in metres). Prefer this over
a flat colour whenever the photo shows a pattern; keep flat colour+roughness only for PLAIN paint.
For the categories Poly Haven covers poorly — CARPET / RUG / FABRIC / PLASTER and patterned
TILE / BRICK / WOOD floors — you can instead use the code-native procedural materials:
`from litereality_agent.room_ops.procedural_materials import make` then `make("carpet"|"fabric"|"plaster"|
"tile"|"brick"|"wood_planks", color=(r,g,b))` returns a Blender material to assign directly in
`Room.py` (parametric, tileable, no download). Use whichever matches the photo better.
- `render(target)` — render `Room.py` for 'room' or a wall, paired with the real photo; it returns
PNG path(s). READ the returned PNG with your own eyes to check colour/placement against the photo,
then fix `Room.py`. Use this to CATCH COLOUR DRIFT especially — author, render, look, correct.
- `critic(images, goal)` — a strict pass/score verdict if you want a second opinion.
- `select_views(target)` — best frames (render auto-picks if you omit frames).
Render-check the surfaces you are least sure about, especially for COLOUR DRIFT — but never let a
render replace an edit: it is your SAVED `Room.py` that the room is rebuilt from, so save first and
check afterwards.
Constraints:
- Edit ONLY `Room.py`; it MUST stay valid Python that compiles. You MAY correct SHELL STRUCTURE
(walls, openings, floor/ceiling height) when the scan is clearly wrong vs the photos — conservatively
and metric, per job (1). But do NOT move/resize the furniture object boxes (Table*/Chair*/reconstructed
objects). Assign everything in CODE (materials via the fetched recipe so a fresh rebuild reproduces them).
When done, summarise per surface: the material (flat vs fetched PBR + which asset) and the fixtures.
"""
# "go harder" profile — same tools, but fixtures MUST be detailed multi-part geometry, render-verified.
DETAIL_PROMPT = """\
You are reconstructing a REAL room as an editable Python program, self-paced. The priority THIS run
is FIXTURE DETAIL: every mounted object must read as a real 3D thing, never a flat slab.
{rhythm}
The room is `Room.py` in your working directory: a builder + a `SHELL` dict (walls, openings, object
boxes). FIRST Read `Room.md` and `Room.py` IN FULL to learn the helper API (how materials are
assigned, how geometry/boxes are added). Then edit `Room.py` IN PLACE to match the real room.
Two jobs, IN THIS ORDER — finish (1) FIRST, then spend the bulk of the run on (2):
1) SHELL STRUCTURE + MATERIALS — the foundation. Do this FIRST and render-check the bare room BEFORE
any fixture, so fixtures land on a room that already reads correctly.
• STRUCTURE: check the room's SHAPE against the photos and fix clear scan errors in the `SHELL`
dict — a missing/spurious wall, wrong wall endpoints/length, a door/window at the wrong
offset/width/height/sill (or one the scan missed or hallucinated), a wrong floor/ceiling height.
Edit the numbers in `SHELL` (see Room.md "Editing the scene"). Be conservative, evidence-based and
metric — correct obvious errors, don't redesign a room that's already right, and do NOT move/resize
the furniture object boxes (Table*/Chair*/reconstructed objects).
• MATERIALS for every surface ({surface_list}): match colour/finish/pattern. Read
PAST anything mounted on a wall. Use `fetch_material` for genuinely patterned surfaces
(carpet/tile/wood/brick); flat colour+roughness for plain paint. Keep it efficient (don't
over-polish) but do NOT skip the CEILING. Don't start fixtures until structure is fixed and every
surface has a material.
2) WALL FIXTURES the photos show — modelled as DETAILED MULTI-PART geometry. This is where most of
your effort goes, but only AFTER (1) is done.
## THE RULE: no fixture is a single flat box. Build each from parts so its SILHOUETTE matches the stitch.
Model every non-trivial fixture as a small assembly (write a Python loop where it repeats):
- **shelving / bookshelf** → vertical uprights/standards + individual brackets per shelf + each board
as its own slab + a few representative ITEMS sitting on the boards (books/boxes as small blocks).
- **radiator** → back panel + a LOOP of vertical fins (10–20 thin slats) + top grille + end caps +
the two pipe valves at the bottom.
- **whiteboard / notice board** → frame border (4 thin bars) + recessed inset face (its own material)
+ a pen tray / bottom lip; cork boards get a visibly thicker frame.
- **AC / split unit** → main body box + a sloped/louvred front vent (a loop of thin louvre slats) +
the pipe/conduit run leaving it.
- **cable trunking / conduit** → a channel base + a proud lid (two stacked thin boxes), not one strip.
- **sockets / switches** → a faceplate plate + the raised outlet/rocker on it (2 parts min), not a dot.
- **skirting / dado** → a board with a slightly proud top lip if the photo shows a moulding profile.
Match each part's real proportions off the stitch; give parts distinct materials (metal vs plastic vs
board). 10 lines of a `for`-loop beats one box.
## MANDATORY grouping: every fixture's parts bundle into ONE named, hide-as-a-unit group.
The whole point of multi-part fixtures is defeated if the parts are a loose pile you must hide one
box at a time. So EACH fixture (all its bars/fins/slats/boards/items) MUST be wrapped into a single
named group via the helper `group_fixture(name, category, parts)` (defined near the collection
helpers in `Room.py`; if it's not already there, add it once — an empty named `name` with
`room_id`/`category` props, each part parented to it and moved into a collection named `name`):
parts = []
parts.append(_wall_box(...)) # back panel
for i in range(n_fins): # the fin loop
parts.append(_wall_box(...))
parts += [_wall_box(...), _wall_box(...)] # valves
group_fixture("Radiator0", "radiator", parts)
Every box helper returns its object — collect them and pass the list. Name each instance like the
furniture handles: `Whiteboard0`, `Radiator0`, `Shelf0`, `Socket0`, `Trunking0`, `AC0`, `Vent0`.
The grouped fixture then selects, HIDES and toggles as ONE object in the viewer / outliner / glTF,
exactly like `Table0`/`Chair0`. This is REQUIRED for every fixture — a whiteboard's frame+face+tray
hide together, a radiator's fins hide together. Do NOT leave fixture parts ungrouped in the flat
`Fixtures` bucket.
## MANDATORY render-verify per wall (this is required, not optional)
After you add the fixtures on a wall, you MUST `render(target='Wall<N>')`, then READ the returned PNG
with your own eyes and check EACH fixture's SILHOUETTE against the real photo/stitch: does the
radiator show fins? the shelf show brackets + boards + items? the whiteboard show a frame + tray?
If any fixture reads as a flat rectangle/slab, ADD the missing parts and re-render that wall. Do NOT
mark a wall done until its fixtures read as 3D assemblies, not cut-outs. Anchor everything to the
SHELL's opening offsets / wall lengths; never place a fixture over a Door/Window opening; horizontal
runs (trunking, skirting) BREAK at openings.
The DECISIVE references are the HEAD-ON STITCHES (each surface square-on) — read every one:
{stitch_lines}
Raw oblique frames are in {scan}. Beware: a stitch can be mirrored/warped — cross-check fixture
positions against a raw frame before committing.
Tools: `fetch_material`, `render` (returns PNG paths — Read them), `critic(images, goal)`,
`select_views`. Use `render` liberally this run — it is how you confirm the silhouettes.
Constraints: edit ONLY `Room.py`; it MUST compile. You MAY correct SHELL STRUCTURE (walls, openings,
floor/ceiling height) when the scan is clearly wrong vs the photos — conservatively and metric, per
job (1) — but do NOT move/resize the furniture object boxes (Table*/Chair*/reconstructed objects).
Everything in CODE so a fresh rebuild reproduces it.
When done, summarise per wall: each fixture, its GROUP NAME (e.g. `Radiator0`), the PARTS you built
it from, and note which walls you render-verified.
"""
PROFILES = {"base": PROMPT, "detail": DETAIL_PROMPT}
def room_compiles(room: Path) -> str:
""""" if `Room.py` is valid Python, else the error. A syntax check, not a build.
Deliberately cheap — `compile()`, no Blender. It runs after every edit, and the failure it
guards against is syntactic: an `Edit` applied to a file the model was mid-thought about.
"""
src = room / "Room.py"
try:
compile(src.read_text(encoding="utf-8"), str(src), "exec")
except SyntaxError as exc:
return f"line {exc.lineno}: {exc.msg}"
except OSError as exc:
return str(exc)
return ""
def checkpoint(room: Path, where: Path) -> bool:
"""Save `Room.py` as last-known-good if it compiles. Returns True when saved.
A long session that dies mid-edit used to leave whatever the last write happened to be —
possibly unparseable, which fails every downstream stage. Keeping the newest COMPILING
version means a break costs one edit, not the run.
"""
if room_compiles(room):
return False
try:
where.parent.mkdir(parents=True, exist_ok=True)
where.write_bytes((room / "Room.py").read_bytes())
return True
except OSError:
return False
# The in-process capability server and the step-budget hook are Claude Code machinery, so they
# live with that harness now (`agent/providers/claude.py`). Re-exported because this module has
# been their import site since before there was a second harness.
from litereality_agent.agent.providers.claude import build_capability_server # noqa: E402,F401
async def run(room: Path, surface_ref: Path, scan: Path, model: str, max_turns: int, profile: str = "base",
step_budget: int = 100, step_reserve: int = 15, provider: str | None = None):
from litereality_agent.agent import providers
surfaces = surfaces_for(room)
stitches = [surface_ref / f"{s}_stitched.jpg" for s in surfaces]
stitch_lines = "\n".join(f" - {s} (head-on): {p}" for s, p in zip(surfaces, stitches) if p.is_file())
prompt = PROFILES.get(profile, PROMPT).format(stitch_lines=stitch_lines, scan=scan,
surface_list=", ".join(surfaces), rhythm=RHYTHM)
# Images the model MAKES to look at are evidence; give it somewhere durable to put them.
from litereality_agent.agent import scratch
scratch_at = scratch.bind(near=room)
prompt += scratch.prompt_line()
from litereality_agent.agent.tool_narration import describe_tools_line
prompt += describe_tools_line()
# readable roots: the room, the stitches, the scan, and the resolved output tree (render PNGs +
# fetched textures land under the realpath of the output symlink) + the repo root.
from litereality_agent import REPO_ROOT as repo_root
dirs = {str(repo_root), str(os.path.realpath(room.parents[2])), str(surface_ref), str(scan),
str(os.path.realpath(surface_ref))}
if scratch_at is not None:
dirs.add(str(scratch_at))
# Step budget: a graceful landing at `step_budget` tool-calls (wind-down then stop). The Claude
# Code harness enforces it with a PreToolUse hook; Codex has no such hook and degrades to a hard
# stop (`providers.describe` says which you got). `--max-turns` is the backstop below it.
harness = providers.resolve("author", provider)
spec = providers.SessionSpec(
prompt=prompt,
cwd=room,
read_roots=tuple(Path(d) for d in dirs),
capability_tools=CAPABILITY_TOOLS,
model=model,
max_turns=max_turns,
step_budget=step_budget or 0,
step_reserve=step_reserve,
log=lambda s: print(s, flush=True),
)
# verify-reads guardrail: the stitches are the DECISIVE reference but are handed over by path,
# so the model MUST choose to Read each one. Track which stitches it actually opened and report
# per-surface coverage — a stitch that exists but was never read is a silent quality risk.
present = {s: p for s, p in zip(surfaces, stitches) if p.is_file()}
missing = [s for s in surfaces if s not in present] # surface exists in Room.py, stage 2 gave no stitch
stitch_names = {p.name: s for s, p in present.items()} # basename → surface (symlink-proof match)
read_surfaces: set[str] = set()
print(f"== one-shot authoring + capability tools [profile={profile}] ==\n room={room}\n"
f" tools=Read,Edit,Write,Glob + {list(CAPABILITY_TOOLS)}\n"
f" surfaces={len(surfaces)} stitches={len(present)}/{len(surfaces)}\n"
f" {providers.describe(harness, spec)}, max-turns={max_turns}\n",
flush=True)
t0 = time.monotonic()
result_text, cost = "", None
terminal_error = ""
# Names and inputs live on ToolUseBlock only; the narrator holds the id→call map so a
# ToolResultBlock can be attributed back. See tool_narration.py for what reading them off the
# result block cost us.
from litereality_agent.agent.tool_narration import ToolNarrator
nar = ToolNarrator()
# Structured record of the loop, so a one-shot run is documented the way staged runs were.
from litereality_agent.agent.trace import AgentTrace
tr = AgentTrace("author", room=room, scan=os.environ.get("LITEREALITY_SCAN"))
# The prompt is the other half of "what happened": a tool choice only makes sense
# against what the session was actually asked to do, and profiles change that.
tr.start(model=model, room=str(room), profile=profile, stitches=len(present),
max_turns=max_turns, scratch=str(scratch_at) if scratch_at else None, prompt=prompt)
# Kept OUTSIDE the room: the room dir is copied and scanned wholesale downstream, and a
# stray second Room-ish file in it is a trap.
last_good = room.parent / ".room_checkpoint.py"
checkpoint(room, last_good) # the seed is valid by construction — start from it
ended_early = ""
stopped = ""
try:
async for m in harness.run(spec):
# The raw sidecar wants the HARNESS's own object, not our normalised view of it.
tr.raw(getattr(m, "raw", None) if getattr(m, "raw", None) is not None else m)
for block in getattr(m, "content", []) or []:
b = type(block).__name__
if b == "TextBlock" and getattr(block, "text", "").strip():
print(f" …{block.text.strip()[:150]}", flush=True)
tr.think(block.text)
elif b == "ToolUseBlock":
print(nar.use(block), flush=True)
tr.tool(getattr(block, "name", "?"), getattr(block, "input", {}) or {},
tool_id=getattr(block, "id", "") or "")
elif b == "ToolResultBlock":
tr.result(block)
name, inp = nar.result(block)
# Stitch coverage: the model was handed its head-on references by PATH, so
# opening them is a choice. Credit the surface only once the Read comes back
# — a use block that errored is not evidence the reference was seen.
if name == "Read" and not getattr(block, "is_error", False):
surf = stitch_names.get(Path(str(inp.get("file_path") or "")).name)
if surf:
read_surfaces.add(surf)
# Safety net: an image the model made outside the run (habitually /tmp) is copied
# in now, while it still exists. Result time, not use time — a Bash that creates
# the file has not run yet when its use block arrives.
for kept in scratch.rescue(inp, getattr(block, "content", None)):
print(f" ↳ kept {kept.name}", flush=True)
failed = nar.error_line(name, block)
if failed:
print(failed, flush=True)
# Newest COMPILING Room.py wins. Cheap enough to run per edit, and it is what
# makes an interrupted session cost one edit instead of the whole run.
elif name in ("Edit", "Write", "Bash"):
checkpoint(room, last_good)
if isinstance(m, providers.SessionResult):
result_text = m.result or ""
cost = m.total_cost_usd
stopped = m.stopped
if m.is_error:
terminal_error = result_text or "provider reported a terminal error"
except Exception as exc: # noqa: BLE001 — including the SDK's turn-cap Exception
# Running out of turns is not a failed run. `Room.py` is edited IN PLACE, so by this
# point the session's work is already on disk; raising here threw it away, because
# the CLI runs authoring as a HARD stage and aborts the pipeline on a non-zero exit.
# Two hours of paid authoring were discarded for hitting a cap that means "time's up",
# not "this is broken". What actually matters is whether the room still compiles —
# checked below.
ended_early = f"{type(exc).__name__}: {exc}"
print(f"\n ⚠ session ended early — {ended_early}", flush=True)
tr.think(f"[session ended early] {ended_early}")
# A budget stop is a deliberate, graceful end, not a crash — record it the same way as a
# turn-cap so the summary and exit logic treat it as "time's up".
if stopped and not ended_early:
ended_early = stopped
print(f"\n ⏹ authoring stopped on {ended_early} "
f"({nar.calls} tool-calls) — Room.py kept as-is.", flush=True)
tr.think(f"[step budget] {ended_early}")
dt = round(time.monotonic() - t0, 1)
calls, counts = nar.calls, nar.counts
# The ONE thing that must hold when this returns: Room.py is valid Python. Every downstream
# stage (materials, refine, qc, export) builds it, so a broken file fails all of them.
broken = room_compiles(room)
if broken and last_good.is_file():
(room / "Room.py").write_bytes(last_good.read_bytes())
print(f" ⚠ Room.py did not compile ({broken}) — restored the last good version",
flush=True)
tr.think(f"[restored checkpoint] {broken}")
broken = room_compiles(room)
# WHETHER THIS ROOM WAS FINISHED OR MERELY STOPPED. A budget landing is graceful and exits 0,
# so the stage above could not tell it apart from a room the model considered done — a
# half-authored room reported as a completed stage. The distinction is only knowable here, so
# it is written down rather than left in the log for someone to notice.
try:
(room.parent / ".author_result.json").write_text(json.dumps({
"ended_early": ended_early, "calls": calls, "step_budget": step_budget,
}), encoding="utf-8")
except OSError:
pass # a summary that cannot be written is not fatal
tr.end(calls=calls, cost_usd=cost, summary=result_text)
print(f"\n== done {dt}s | calls={calls} {counts} | cost=${cost} ==\n", flush=True)
if tr.ok:
print(f" trace → {tr.path}", flush=True)
# per-surface stitch coverage — over EVERY surface in the room, not just the stitched ones, so a
# surface stage 2 failed to stitch shows up as a gap instead of vanishing from the denominator.
# Only meaningful where file reads are observable tool calls. Codex reads internally, so an
# absent Read event says nothing about whether the reference was seen — reporting it as
# "NEVER OPENED" would be a fabricated quality warning on every run.
if "read_events" not in harness.supports:
print(f"STITCH COVERAGE: not observable on the {harness.name} harness "
f"(file reads are not reported as tool calls) — "
f"{len(present)}/{len(surfaces)} surface(s) had a stitch available.", flush=True)
if missing:
print(f" ⚠ {len(missing)}/{len(surfaces)} surface(s) have NO stitch: {', '.join(missing)} — "
f"stage 2 (surface stitches) did not produce one.", flush=True)
else:
skipped = [s for s in present if s not in read_surfaces]
print("STITCH COVERAGE (head-on references the model actually opened):", flush=True)
for s in surfaces:
mark = "✓" if s in read_surfaces else ("✗ NO STITCH" if s in missing else "✗ NEVER OPENED")
print(f" {mark} {s}", flush=True)
if missing:
print(f" ⚠ {len(missing)}/{len(surfaces)} surface(s) have NO stitch: {', '.join(missing)} — "
f"stage 2 (surface stitches) did not produce one.", flush=True)
if skipped:
print(f" ⚠ {len(skipped)}/{len(present)} stitch(es) never read: {', '.join(skipped)} — "
f"those surfaces were authored WITHOUT looking at their head-on reference.", flush=True)
elif not missing:
print(f" all {len(present)} stitch(es) read ✓", flush=True)
print("\nSUMMARY:\n" + result_text[:2500], flush=True)
# Exit status = "is the room usable", not "did the session run to completion". Downstream
# stages need valid Python and nothing else; a turn cap with a compiling room is a partial
# success worth keeping, and only an unrecoverable room is worth aborting the pipeline for.
if ended_early:
print(f" note: ended early ({ended_early}) — Room.py is valid, continuing.", flush=True)
if broken:
print(f" ✗ Room.py does not compile and no checkpoint could be restored: {broken}",
flush=True)
if terminal_error:
print(f" ✗ authoring provider failed: {terminal_error}", flush=True)
return 1 if broken or terminal_error else 0