brickwright 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.
- brickwright/__init__.py +5 -0
- brickwright/__main__.py +6 -0
- brickwright/_build_template.py +25 -0
- brickwright/cli.py +446 -0
- brickwright/config.py +104 -0
- brickwright/driver/__init__.py +7 -0
- brickwright/driver/completion.py +89 -0
- brickwright/driver/critique.py +133 -0
- brickwright/driver/external_critique.py +114 -0
- brickwright/driver/gallery.py +289 -0
- brickwright/driver/loop.py +377 -0
- brickwright/driver/messages.py +168 -0
- brickwright/driver/narration.py +320 -0
- brickwright/driver/report.py +52 -0
- brickwright/driver/tracking.py +217 -0
- brickwright/driver/transcript.py +99 -0
- brickwright/driver/viewpoint.py +242 -0
- brickwright/prompts/__init__.py +3 -0
- brickwright/prompts/compilation.py +110 -0
- brickwright/prompts/dispatch_guidance.md +6 -0
- brickwright/prompts/general_instructions.md +68 -0
- brickwright/prompts/internal_agent_instructions.md +29 -0
- brickwright/prompts/internal_subagent_instructions.md +8 -0
- brickwright/prompts/render_guidance.md +4 -0
- brickwright/py.typed +0 -0
- brickwright/record.py +620 -0
- brickwright/tools/__init__.py +69 -0
- brickwright/tools/build_execution.py +113 -0
- brickwright/tools/connectivity_diagnostics.py +118 -0
- brickwright/tools/editing.py +314 -0
- brickwright/tools/exploration.py +85 -0
- brickwright/tools/finalization.py +74 -0
- brickwright/tools/registry.py +241 -0
- brickwright/tools/rendering.py +249 -0
- brickwright/tools/schemas.py +203 -0
- brickwright/tools/software_rendering.py +780 -0
- brickwright/tools/subagents.py +109 -0
- brickwright/tools/testing.py +128 -0
- brickwright/tools/validation.py +172 -0
- brickwright-0.1.0.dist-info/METADATA +89 -0
- brickwright-0.1.0.dist-info/RECORD +44 -0
- brickwright-0.1.0.dist-info/WHEEL +4 -0
- brickwright-0.1.0.dist-info/entry_points.txt +3 -0
- brickwright-0.1.0.dist-info/licenses/LICENSE +21 -0
brickwright/__init__.py
ADDED
brickwright/__main__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import bricklayer as bl
|
|
2
|
+
|
|
3
|
+
from brickwright import TestContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_build():
|
|
7
|
+
"""Return the finished :class:`bricklayer.LegoBuild`."""
|
|
8
|
+
build = bl.LegoBuild()
|
|
9
|
+
# TODO: assemble the requested build
|
|
10
|
+
build.add(
|
|
11
|
+
bl.brick(2, 2, color=bl.Color.RED), bl.Position(0, 0, 0), bl.Rotation.FRONT
|
|
12
|
+
)
|
|
13
|
+
return build
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_tests():
|
|
17
|
+
"""Create the build and return a report of which checks passed."""
|
|
18
|
+
build = create_build()
|
|
19
|
+
tests = TestContext(build)
|
|
20
|
+
tests.check_no_floating()
|
|
21
|
+
tests.check_no_collisions()
|
|
22
|
+
tests.check_connected(expected_components=1)
|
|
23
|
+
# Add a custom check for any measurable requirement, e.g.:
|
|
24
|
+
# tests.check("over 100 bricks", build.brick_count > 100)
|
|
25
|
+
return tests.report()
|
brickwright/cli.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Command-line interface to generate LEGO builds using BrickWright.
|
|
2
|
+
|
|
3
|
+
Top-level commands are for a human orchestrating runs; the ``external`` group
|
|
4
|
+
holds the operations an external agent (e.g. Claude Code pointed at the
|
|
5
|
+
package) drives to iterate on a build.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Generator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from .config import (
|
|
19
|
+
DEFAULT_RECORDS_DIR,
|
|
20
|
+
RECORDS_DIR_ENV,
|
|
21
|
+
Config,
|
|
22
|
+
CritiqueMode,
|
|
23
|
+
Effort,
|
|
24
|
+
)
|
|
25
|
+
from .driver import run_agent
|
|
26
|
+
from .driver.external_critique import (
|
|
27
|
+
DEFAULT_CRITIQUE_ROUNDS,
|
|
28
|
+
external_critique_instructions,
|
|
29
|
+
)
|
|
30
|
+
from .driver.gallery import print_gallery
|
|
31
|
+
from .driver.tracking import tally_failure
|
|
32
|
+
from .prompts import system_prompt
|
|
33
|
+
from .record import Record, Timing, ValidationFailures, list_records
|
|
34
|
+
from .tools import FinalizationReport, RenderReport, ValidationReport
|
|
35
|
+
from .tools import finalize as finalize_record
|
|
36
|
+
from .tools import render as render_record
|
|
37
|
+
from .tools import render_angle as render_angle_record
|
|
38
|
+
from .tools import validate as validate_record
|
|
39
|
+
from .tools.software_rendering import MAX_ELEVATION
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
name="brickwright",
|
|
43
|
+
help=__doc__,
|
|
44
|
+
add_completion=False,
|
|
45
|
+
no_args_is_help=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
external_app = typer.Typer(
|
|
49
|
+
help="Operations meant for an external agent.",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
)
|
|
52
|
+
app.add_typer(external_app, name="external")
|
|
53
|
+
|
|
54
|
+
RecordsDir = Annotated[
|
|
55
|
+
Path,
|
|
56
|
+
typer.Option(
|
|
57
|
+
"--records-dir", envvar=RECORDS_DIR_ENV, help="where record directories live"
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
Prompt = Annotated[str | None, typer.Argument(help="text prompt")]
|
|
61
|
+
RecordId = Annotated[str, typer.Argument(help="record id")]
|
|
62
|
+
Image = Annotated[Path | None, typer.Option(help="image prompt")]
|
|
63
|
+
|
|
64
|
+
# Defaults for the Config-backed options, so Typer can display them in --help.
|
|
65
|
+
_DEFAULTS = Config()
|
|
66
|
+
Model = Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option(envvar="BRICKWRIGHT_GENERATION_MODEL", help="generation model id"),
|
|
69
|
+
]
|
|
70
|
+
Effort_ = Annotated[Effort | None, typer.Option(help="reasoning effort")]
|
|
71
|
+
MaxValidations = Annotated[
|
|
72
|
+
int, typer.Option(min=0, help="cap on validate calls (0: unlimited)")
|
|
73
|
+
]
|
|
74
|
+
MaxTurns = Annotated[int, typer.Option(min=0, help="cap on model turns (0: unlimited)")]
|
|
75
|
+
MaxCost = Annotated[float, typer.Option(help="cap on USD spent per run")]
|
|
76
|
+
BuildTimeout = Annotated[float, typer.Option(help="max seconds per build")]
|
|
77
|
+
RenderTimeout = Annotated[float, typer.Option(help="max seconds per render")]
|
|
78
|
+
RequestTimeout = Annotated[
|
|
79
|
+
float | None,
|
|
80
|
+
typer.Option(help="max seconds per LLM request (unset: the client's own default)"),
|
|
81
|
+
]
|
|
82
|
+
Subagents = Annotated[
|
|
83
|
+
bool,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--use-subagents/--no-use-subagents",
|
|
86
|
+
help="allow dispatching subagents to complete subtasks in parallel",
|
|
87
|
+
),
|
|
88
|
+
]
|
|
89
|
+
MaxParallelSubagents = Annotated[
|
|
90
|
+
int, typer.Option(help="cap on concurrently running subagents")
|
|
91
|
+
]
|
|
92
|
+
SubagentMaxTurns = Annotated[int, typer.Option(help="cap on model turns per subagent")]
|
|
93
|
+
SubagentMaxCost = Annotated[float, typer.Option(help="cap on USD spent per subagent")]
|
|
94
|
+
CritiqueRounds = Annotated[
|
|
95
|
+
int, typer.Option(help="outer visual-critique rounds (0 disables the loop)")
|
|
96
|
+
]
|
|
97
|
+
CritiqueMode_ = Annotated[
|
|
98
|
+
CritiqueMode, typer.Option(help="how the builder re-enters after a critique")
|
|
99
|
+
]
|
|
100
|
+
CritiqueModel = Annotated[
|
|
101
|
+
str | None, typer.Option(help="critic model id (defaults to --model)")
|
|
102
|
+
]
|
|
103
|
+
CritiqueEffort = Annotated[
|
|
104
|
+
Effort | None, typer.Option(help="critic reasoning effort (defaults to --effort)")
|
|
105
|
+
]
|
|
106
|
+
ViewpointIterations = Annotated[
|
|
107
|
+
int,
|
|
108
|
+
typer.Option(
|
|
109
|
+
help="viewpoint-matching iterations per critique round (0 disables the critique)"
|
|
110
|
+
),
|
|
111
|
+
]
|
|
112
|
+
ViewpointModel = Annotated[
|
|
113
|
+
str | None, typer.Option(help="viewpoint agent model id (defaults to --model)")
|
|
114
|
+
]
|
|
115
|
+
ViewpointEffort = Annotated[
|
|
116
|
+
Effort | None, typer.Option(help="viewpoint agent effort (defaults to --effort)")
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
FromId = Annotated[
|
|
120
|
+
str | None, typer.Option("--from", help="fork a new record from this record id")
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
Azimuth = Annotated[
|
|
124
|
+
float,
|
|
125
|
+
typer.Option(help="horizontal camera angle in degrees (-180..180; 0 = front)"),
|
|
126
|
+
]
|
|
127
|
+
Elevation = Annotated[
|
|
128
|
+
float,
|
|
129
|
+
typer.Option(
|
|
130
|
+
help=(
|
|
131
|
+
f"vertical camera angle in degrees (-{MAX_ELEVATION:g}..{MAX_ELEVATION:g};"
|
|
132
|
+
" 0 = level)"
|
|
133
|
+
)
|
|
134
|
+
),
|
|
135
|
+
]
|
|
136
|
+
AngleOut = Annotated[
|
|
137
|
+
Path | None,
|
|
138
|
+
typer.Option("--out", help="output PNG path (default: the record's viewpoint.png)"),
|
|
139
|
+
]
|
|
140
|
+
CritiqueInstructionsRounds = Annotated[
|
|
141
|
+
int,
|
|
142
|
+
typer.Option(
|
|
143
|
+
"--critique-rounds", help="rounds the printed critique playbook advises"
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.command()
|
|
149
|
+
def generate(
|
|
150
|
+
prompt: Prompt = None,
|
|
151
|
+
image: Image = None,
|
|
152
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
153
|
+
model: Model = _DEFAULTS.model,
|
|
154
|
+
effort: Effort_ = _DEFAULTS.effort,
|
|
155
|
+
max_validations: MaxValidations = _DEFAULTS.max_validations,
|
|
156
|
+
max_turns: MaxTurns = _DEFAULTS.max_turns,
|
|
157
|
+
max_cost: MaxCost = _DEFAULTS.max_cost,
|
|
158
|
+
build_timeout: BuildTimeout = _DEFAULTS.build_timeout,
|
|
159
|
+
render_timeout: RenderTimeout = _DEFAULTS.render_timeout,
|
|
160
|
+
request_timeout: RequestTimeout = _DEFAULTS.request_timeout,
|
|
161
|
+
use_subagents: Subagents = _DEFAULTS.subagents_enabled,
|
|
162
|
+
max_parallel_subagents: MaxParallelSubagents = _DEFAULTS.max_parallel_subagents,
|
|
163
|
+
subagent_max_turns: SubagentMaxTurns = _DEFAULTS.subagent_max_turns,
|
|
164
|
+
subagent_max_cost: SubagentMaxCost = _DEFAULTS.subagent_max_cost,
|
|
165
|
+
critique_rounds: CritiqueRounds = _DEFAULTS.max_critique_rounds,
|
|
166
|
+
critique_mode: CritiqueMode_ = _DEFAULTS.critique_mode,
|
|
167
|
+
critique_model: CritiqueModel = _DEFAULTS.critique_model,
|
|
168
|
+
critique_effort: CritiqueEffort = _DEFAULTS.critique_effort,
|
|
169
|
+
viewpoint_iterations: ViewpointIterations = _DEFAULTS.max_viewpoint_iterations,
|
|
170
|
+
viewpoint_model: ViewpointModel = _DEFAULTS.viewpoint_model,
|
|
171
|
+
viewpoint_effort: ViewpointEffort = _DEFAULTS.viewpoint_effort,
|
|
172
|
+
from_id: FromId = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Generate a new LEGO build record from a text or image prompt."""
|
|
175
|
+
config = Config(
|
|
176
|
+
records_dir=records_dir,
|
|
177
|
+
model=model,
|
|
178
|
+
effort=effort,
|
|
179
|
+
max_validations=max_validations,
|
|
180
|
+
max_turns=max_turns,
|
|
181
|
+
max_cost=max_cost,
|
|
182
|
+
build_timeout=build_timeout,
|
|
183
|
+
render_timeout=render_timeout,
|
|
184
|
+
request_timeout=request_timeout,
|
|
185
|
+
subagents_enabled=use_subagents,
|
|
186
|
+
max_parallel_subagents=max_parallel_subagents,
|
|
187
|
+
subagent_max_turns=subagent_max_turns,
|
|
188
|
+
subagent_max_cost=subagent_max_cost,
|
|
189
|
+
max_critique_rounds=critique_rounds,
|
|
190
|
+
critique_mode=critique_mode,
|
|
191
|
+
critique_model=critique_model,
|
|
192
|
+
critique_effort=critique_effort,
|
|
193
|
+
max_viewpoint_iterations=viewpoint_iterations,
|
|
194
|
+
viewpoint_model=viewpoint_model,
|
|
195
|
+
viewpoint_effort=viewpoint_effort,
|
|
196
|
+
)
|
|
197
|
+
rec = _create_record(config, prompt=prompt, image=image, from_id=from_id)
|
|
198
|
+
if not run_agent(rec, config).succeeded:
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def show(
|
|
204
|
+
record_id: RecordId, records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR)
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Print a record's metadata."""
|
|
207
|
+
meta = Record.load(Config(records_dir=records_dir), record_id).read_meta()
|
|
208
|
+
Console().print_json(meta.model_dump_json(indent=2))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def ls(records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR)) -> None:
|
|
213
|
+
"""List records."""
|
|
214
|
+
console = Console()
|
|
215
|
+
for rec in list_records(Config(records_dir=records_dir)):
|
|
216
|
+
try:
|
|
217
|
+
meta = rec.read_meta()
|
|
218
|
+
except (OSError, ValueError):
|
|
219
|
+
console.print(f"{rec.id}\tunreadable", markup=False, soft_wrap=True)
|
|
220
|
+
continue
|
|
221
|
+
summary = (meta.prompt or "").partition("\n")[0][:48]
|
|
222
|
+
console.print(
|
|
223
|
+
f"{rec.id}\t{meta.status.value}\t{summary}",
|
|
224
|
+
markup=False, # don't break if summary text has special characters
|
|
225
|
+
soft_wrap=True, # one line per record even with long records
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command(hidden=True)
|
|
230
|
+
def gallery() -> None:
|
|
231
|
+
"""Print a style guide of every narration output (for testing purposes)."""
|
|
232
|
+
print_gallery()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@external_app.command()
|
|
236
|
+
def init(
|
|
237
|
+
prompt: Prompt = None,
|
|
238
|
+
image: Image = None,
|
|
239
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
240
|
+
model: Model = None,
|
|
241
|
+
effort: Effort_ = None,
|
|
242
|
+
from_id: FromId = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Initialize a new record.
|
|
245
|
+
|
|
246
|
+
Prints the new record's id and path to the build.py file to be edited.
|
|
247
|
+
An external agent should edit the build.py file directly, then run
|
|
248
|
+
``brickwright external validate <record_id>`` to validate it.
|
|
249
|
+
"""
|
|
250
|
+
config = Config(records_dir=records_dir, model=model, effort=effort)
|
|
251
|
+
rec = _create_record(
|
|
252
|
+
config, prompt=prompt, image=image, from_id=from_id, external=True
|
|
253
|
+
)
|
|
254
|
+
print(f"record_id={rec.id}")
|
|
255
|
+
print(f"build_path={rec.build_path.absolute()}")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@external_app.command()
|
|
259
|
+
def instructions() -> None:
|
|
260
|
+
print(system_prompt(external=True))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@external_app.command()
|
|
264
|
+
def validate(
|
|
265
|
+
record_id: RecordId,
|
|
266
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
267
|
+
timeout: BuildTimeout = _DEFAULTS.build_timeout,
|
|
268
|
+
max_validations: MaxValidations = _DEFAULTS.max_validations,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Verify a record's correctness."""
|
|
271
|
+
config = Config(
|
|
272
|
+
records_dir=records_dir,
|
|
273
|
+
build_timeout=timeout,
|
|
274
|
+
max_validations=max_validations,
|
|
275
|
+
)
|
|
276
|
+
rec = Record.load(config, record_id)
|
|
277
|
+
|
|
278
|
+
validations = rec.read_meta().validations
|
|
279
|
+
# Each call is one validation; refuse once the budget is spent.
|
|
280
|
+
if config.max_validations and validations >= config.max_validations:
|
|
281
|
+
typer.echo(
|
|
282
|
+
f"validation budget of {config.max_validations} reached; stop validating",
|
|
283
|
+
err=True,
|
|
284
|
+
)
|
|
285
|
+
raise typer.Exit(1)
|
|
286
|
+
with _track_command(rec, "validate"):
|
|
287
|
+
report = validate_record(rec, config)
|
|
288
|
+
failures = rec.read_meta().validation_failures or ValidationFailures()
|
|
289
|
+
rec.update_meta(
|
|
290
|
+
validations=validations + 1,
|
|
291
|
+
validation_failures=tally_failure(failures, report),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
_print_report(report)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@external_app.command()
|
|
298
|
+
def finalize(
|
|
299
|
+
record_id: RecordId,
|
|
300
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
301
|
+
timeout: RenderTimeout = _DEFAULTS.render_timeout,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Finalize a record.
|
|
304
|
+
|
|
305
|
+
Run this once the build is valid (per ``validate``) and you are finished.
|
|
306
|
+
The finished build is compiled into an LDraw file and, when a parts library
|
|
307
|
+
is available, rendered to a multi-view PNG.
|
|
308
|
+
"""
|
|
309
|
+
config = Config(records_dir=records_dir, render_timeout=timeout)
|
|
310
|
+
rec = Record.load(config, record_id)
|
|
311
|
+
|
|
312
|
+
with _track_command(rec, "finalize"):
|
|
313
|
+
report = finalize_record(rec, config)
|
|
314
|
+
|
|
315
|
+
# Close out the run: total wall-clock from creation, remainder attributed to llm.
|
|
316
|
+
meta = rec.read_meta()
|
|
317
|
+
created = meta.created
|
|
318
|
+
if created is not None:
|
|
319
|
+
timing = meta.timing or Timing()
|
|
320
|
+
total = round((datetime.now(created.tzinfo) - created).total_seconds(), 3)
|
|
321
|
+
llm = round(max(0.0, total - sum(timing.tools.values())), 3)
|
|
322
|
+
rec.update_meta(timing=timing.model_copy(update={"total": total, "llm": llm}))
|
|
323
|
+
|
|
324
|
+
_print_report(report)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@external_app.command()
|
|
328
|
+
def render(
|
|
329
|
+
record_id: RecordId,
|
|
330
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
331
|
+
timeout: RenderTimeout = _DEFAULTS.render_timeout,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Render a record's current build to a multi-view image.
|
|
334
|
+
|
|
335
|
+
Writes ``out/build.png`` and prints its path. Open that file to see how the
|
|
336
|
+
build currently looks (front, side, top, and isometric views).
|
|
337
|
+
"""
|
|
338
|
+
config = Config(records_dir=records_dir, render_timeout=timeout)
|
|
339
|
+
rec = Record.load(config, record_id)
|
|
340
|
+
|
|
341
|
+
with _track_command(rec, "render"):
|
|
342
|
+
report = render_record(rec, config)
|
|
343
|
+
_print_image_and_report(report)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@external_app.command(name="render-angle")
|
|
347
|
+
def render_angle(
|
|
348
|
+
record_id: RecordId,
|
|
349
|
+
azimuth: Azimuth = 0.0,
|
|
350
|
+
elevation: Elevation = 0.0,
|
|
351
|
+
out: AngleOut = None,
|
|
352
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
353
|
+
timeout: RenderTimeout = _DEFAULTS.render_timeout,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Render the current build from a single camera angle.
|
|
356
|
+
|
|
357
|
+
Writes the image (default: the record's ``out/viewpoint.png``) and prints its path.
|
|
358
|
+
"""
|
|
359
|
+
config = Config(records_dir=records_dir, render_timeout=timeout)
|
|
360
|
+
rec = Record.load(config, record_id)
|
|
361
|
+
|
|
362
|
+
with _track_command(rec, "render-angle"):
|
|
363
|
+
report = render_angle_record(rec, azimuth, elevation, config, out_path=out)
|
|
364
|
+
_print_image_and_report(report)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@external_app.command(name="critique-instructions")
|
|
368
|
+
def critique_instructions(
|
|
369
|
+
record_id: RecordId,
|
|
370
|
+
records_dir: RecordsDir = Path(DEFAULT_RECORDS_DIR),
|
|
371
|
+
critique_rounds: CritiqueInstructionsRounds = DEFAULT_CRITIQUE_ROUNDS,
|
|
372
|
+
) -> None:
|
|
373
|
+
"""Print the visual-critique playbook for this record.
|
|
374
|
+
|
|
375
|
+
The external counterpart of the driver's ``viewpoint`` critique loop. It tells you to
|
|
376
|
+
dispatch two fresh subagents -- one to match the camera viewpoint (via
|
|
377
|
+
``render-angle``), one to critique the render against the reference image -- each with
|
|
378
|
+
the same system prompt the driver path uses, then to revise ``build.py`` from the
|
|
379
|
+
critic's verdict. Requires the record to have a prompt image.
|
|
380
|
+
"""
|
|
381
|
+
rec = Record.load(Config(records_dir=records_dir), record_id)
|
|
382
|
+
try:
|
|
383
|
+
print(external_critique_instructions(rec, rounds=critique_rounds))
|
|
384
|
+
except ValueError as exc:
|
|
385
|
+
typer.echo(str(exc), err=True)
|
|
386
|
+
raise typer.Exit(1) from exc
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _create_record(
|
|
390
|
+
config: Config,
|
|
391
|
+
*,
|
|
392
|
+
prompt: str | None,
|
|
393
|
+
image: Path | None,
|
|
394
|
+
from_id: str | None,
|
|
395
|
+
external: bool = False,
|
|
396
|
+
) -> Record:
|
|
397
|
+
if not prompt and image is None and from_id is None:
|
|
398
|
+
raise typer.BadParameter("provide a prompt, --image, or --from")
|
|
399
|
+
return Record.create(
|
|
400
|
+
config, prompt=prompt or "", image=image, from_id=from_id, external=external
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@contextmanager
|
|
405
|
+
def _track_command(rec: Record, name: str) -> Generator[None]:
|
|
406
|
+
"""Time the wrapped command and tally it on the record's meta.
|
|
407
|
+
|
|
408
|
+
The wall-clock is added to this command's entry in the timing breakdown, and
|
|
409
|
+
the call is counted toward ``tool_calls`` under ``name``.
|
|
410
|
+
"""
|
|
411
|
+
start = time.perf_counter()
|
|
412
|
+
try:
|
|
413
|
+
yield
|
|
414
|
+
finally:
|
|
415
|
+
dt = time.perf_counter() - start
|
|
416
|
+
meta = rec.read_meta()
|
|
417
|
+
timing = meta.timing or Timing()
|
|
418
|
+
tools = {**timing.tools, name: round(timing.tools.get(name, 0.0) + dt, 3)}
|
|
419
|
+
rec.update_meta(
|
|
420
|
+
timing=timing.model_copy(update={"tools": tools}),
|
|
421
|
+
tool_calls={**meta.tool_calls, name: meta.tool_calls.get(name, 0) + 1},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _print_image_and_report(report: RenderReport) -> None:
|
|
426
|
+
"""Print the rendered image's path (when there is one), then the report."""
|
|
427
|
+
if report.ok and report.image_path is not None:
|
|
428
|
+
print(str(report.image_path.absolute()))
|
|
429
|
+
_print_report(report)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _print_report(
|
|
433
|
+
report: ValidationReport | FinalizationReport | RenderReport,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Print the report as JSON and exit nonzero if the operation failed."""
|
|
436
|
+
print(report.model_dump_json(indent=2))
|
|
437
|
+
if not report.ok:
|
|
438
|
+
raise typer.Exit(1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def main(argv: list[str] | None = None) -> None:
|
|
442
|
+
app(args=argv)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
if __name__ == "__main__":
|
|
446
|
+
main()
|
brickwright/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Harness configuration: where records live and how the agent is driven."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
# Where records live by default, and the env var that overrides it.
|
|
11
|
+
DEFAULT_RECORDS_DIR = "records"
|
|
12
|
+
RECORDS_DIR_ENV = "BRICKWRIGHT_RECORDS_DIR"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_records_dir() -> Path:
|
|
16
|
+
return Path(os.environ.get(RECORDS_DIR_ENV, DEFAULT_RECORDS_DIR))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Effort(str, Enum):
|
|
20
|
+
"""How much reasoning effort to request from the model."""
|
|
21
|
+
|
|
22
|
+
NONE = "none"
|
|
23
|
+
MINIMAL = "minimal"
|
|
24
|
+
LOW = "low"
|
|
25
|
+
MEDIUM = "medium"
|
|
26
|
+
HIGH = "high"
|
|
27
|
+
XHIGH = "xhigh"
|
|
28
|
+
MAX = "max"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Subagent model tiers per provider; an absent provider uses the main model.
|
|
32
|
+
DEFAULT_SUBAGENT_TIERS: dict[str, dict[str, str]] = {
|
|
33
|
+
"openai": {
|
|
34
|
+
"smart": "openai/gpt-6-astra",
|
|
35
|
+
"balanced": "openai/gpt-6-sol",
|
|
36
|
+
"fast": "openai/gpt-6-luna",
|
|
37
|
+
},
|
|
38
|
+
"anthropic": {
|
|
39
|
+
"smart": "anthropic/claude-opus-4-8",
|
|
40
|
+
"balanced": "anthropic/claude-sonnet-5",
|
|
41
|
+
"fast": "anthropic/claude-haiku-4-5",
|
|
42
|
+
},
|
|
43
|
+
"google": {
|
|
44
|
+
"smart": "gemini/gemini-pro-latest",
|
|
45
|
+
"balanced": "gemini/gemini-flash-latest",
|
|
46
|
+
"fast": "gemini/gemini-flash-lite-latest",
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CritiqueMode(str, Enum):
|
|
52
|
+
"""How the builder re-enters after a visual critique asks for changes."""
|
|
53
|
+
|
|
54
|
+
CONTINUE = "continue" # resume the builder's existing conversation
|
|
55
|
+
FRESH = "fresh" # re-seed a new conversation each round
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Config(BaseModel):
|
|
59
|
+
"""Main configuration class for BrickWright."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(frozen=True)
|
|
62
|
+
|
|
63
|
+
records_dir: Path = Field(default_factory=default_records_dir)
|
|
64
|
+
model: str | None = None
|
|
65
|
+
effort: Effort | None = Effort.HIGH
|
|
66
|
+
max_validations: int = Field(default=50, ge=0) # 0 disables the validation cap
|
|
67
|
+
max_turns: int = Field(default=200, ge=0) # 0 disables the turn cap
|
|
68
|
+
max_cost: float = 5.0 # cap on USD spent per run
|
|
69
|
+
build_timeout: float = 30.0
|
|
70
|
+
render_timeout: float = 60.0
|
|
71
|
+
# Seconds allowed for one LLM request; ``None`` leaves it to the client.
|
|
72
|
+
request_timeout: float | None = None
|
|
73
|
+
subagents_enabled: bool = False
|
|
74
|
+
is_subagent: bool = False # internal: this run is a subagent
|
|
75
|
+
subagent_tiers: dict[str, dict[str, str]] = Field(
|
|
76
|
+
default_factory=lambda: deepcopy(DEFAULT_SUBAGENT_TIERS)
|
|
77
|
+
)
|
|
78
|
+
max_parallel_subagents: int = 4
|
|
79
|
+
subagent_max_turns: int = 100
|
|
80
|
+
subagent_max_cost: float = 1.0 # USD
|
|
81
|
+
max_critique_rounds: int = 0 # outer visual-critique rounds (0 disables the loop)
|
|
82
|
+
critique_mode: CritiqueMode = CritiqueMode.CONTINUE # how the builder re-enters
|
|
83
|
+
critique_model: str | None = None # critic model id (None -> use ``model``)
|
|
84
|
+
critique_effort: Effort | None = None # critic effort (None -> use ``effort``)
|
|
85
|
+
# Viewpoint-matching agent: picks a camera angle whose render matches the
|
|
86
|
+
# reference image's viewpoint. 0 disables it, and with it the critique.
|
|
87
|
+
max_viewpoint_iterations: int = 6 # max render/adjust turns the agent gets
|
|
88
|
+
viewpoint_model: str | None = None # viewpoint agent model (None -> use ``model``)
|
|
89
|
+
viewpoint_effort: Effort | None = None # viewpoint effort (None -> use ``effort``)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def can_dispatch_subagents(self) -> bool:
|
|
93
|
+
"""Whether this run may hand subtasks to subagents."""
|
|
94
|
+
return self.subagents_enabled and not self.is_subagent
|
|
95
|
+
|
|
96
|
+
def agent_model_and_effort(self, prefix: str) -> tuple[str | None, Effort]:
|
|
97
|
+
"""The model and effort for a helper agent, falling back to the builder's.
|
|
98
|
+
|
|
99
|
+
``prefix`` names the agent's option pair, e.g. ``"critique"`` reads
|
|
100
|
+
``critique_model`` and ``critique_effort``.
|
|
101
|
+
"""
|
|
102
|
+
model = getattr(self, f"{prefix}_model") or self.model
|
|
103
|
+
effort = getattr(self, f"{prefix}_effort") or self.effort or Effort.NONE
|
|
104
|
+
return model, effort
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The litellm completion seam, shared by the builder loop and the critic.
|
|
2
|
+
|
|
3
|
+
Kept in its own module so the loop and the critique step can each bind (and tests
|
|
4
|
+
can each patch) ``litellm_complete`` independently, without an import cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def litellm_complete() -> Callable[..., Any]:
|
|
12
|
+
# Imported lazily so tests can monkeypatch sys.modules["litellm"] before this
|
|
13
|
+
# runs; a module-level import binds the real module too early for that to work.
|
|
14
|
+
import litellm
|
|
15
|
+
|
|
16
|
+
# Sanitize tool calls; see https://docs.litellm.ai/docs/completion/message_sanitization
|
|
17
|
+
litellm.modify_params = True
|
|
18
|
+
|
|
19
|
+
def complete(**kwargs: Any) -> Any:
|
|
20
|
+
routed = _route(kwargs)
|
|
21
|
+
response = litellm.completion(**routed)
|
|
22
|
+
if (routed.get("model") or "").startswith("openai/responses/"):
|
|
23
|
+
return _merge_choices(response)
|
|
24
|
+
return response
|
|
25
|
+
|
|
26
|
+
return complete
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bad_request_error() -> type[Exception]:
|
|
30
|
+
import litellm
|
|
31
|
+
|
|
32
|
+
return litellm.BadRequestError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _route(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
"""Configure Claude prefix caching and OpenAI's Responses API bridge.
|
|
37
|
+
|
|
38
|
+
OpenAI rejects function tools alongside reasoning for gpt-6 on
|
|
39
|
+
``/v1/chat/completions``, and litellm doesn't list ``reasoning_effort`` among
|
|
40
|
+
gpt-6's params, so it has to be allowed explicitly. The Responses API caches
|
|
41
|
+
prompts automatically, and litellm's injected cache breakpoints defeat that.
|
|
42
|
+
"""
|
|
43
|
+
model = kwargs.get("model") or ""
|
|
44
|
+
if model.removeprefix("anthropic/").startswith("claude-"):
|
|
45
|
+
# Keep the static prefix reusable across builds, and cache the growing
|
|
46
|
+
# conversation through the latest tool result or rendered image.
|
|
47
|
+
return {
|
|
48
|
+
**kwargs,
|
|
49
|
+
"cache_control_injection_points": [
|
|
50
|
+
{"location": "message", "role": "system"},
|
|
51
|
+
{"location": "message", "index": -1},
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
name = model.removeprefix("openai/")
|
|
55
|
+
if not name.startswith("gpt-6"):
|
|
56
|
+
return kwargs
|
|
57
|
+
routed = {**kwargs, "model": f"openai/responses/{name}"}
|
|
58
|
+
routed.pop("cache_control_injection_points", None)
|
|
59
|
+
if "reasoning_effort" in kwargs:
|
|
60
|
+
routed["allowed_openai_params"] = ["reasoning_effort"]
|
|
61
|
+
return routed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _merge_choices(response: Any) -> Any:
|
|
65
|
+
"""Fold the Responses API bridge's choices back into the one callers read.
|
|
66
|
+
|
|
67
|
+
The bridge makes each output message (such as a preamble before tool calls) its
|
|
68
|
+
own choice and puts every tool call in a trailing one, so ``choices[0]`` alone
|
|
69
|
+
looks like a final text reply.
|
|
70
|
+
"""
|
|
71
|
+
if len(response.choices) < 2:
|
|
72
|
+
return response
|
|
73
|
+
merged = response.choices[0]
|
|
74
|
+
messages = [c.message for c in response.choices]
|
|
75
|
+
# litellm's Message drops unset fields rather than holding None.
|
|
76
|
+
merged.message.content = _join(getattr(m, "content", None) for m in messages)
|
|
77
|
+
merged.message.reasoning_content = _join(
|
|
78
|
+
getattr(m, "reasoning_content", None) for m in messages
|
|
79
|
+
)
|
|
80
|
+
merged.message.tool_calls = [
|
|
81
|
+
tc for m in messages for tc in (getattr(m, "tool_calls", None) or [])
|
|
82
|
+
] or None
|
|
83
|
+
merged.finish_reason = response.choices[-1].finish_reason
|
|
84
|
+
response.choices = [merged]
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _join(texts: Iterable[str | None]) -> str | None:
|
|
89
|
+
return "\n\n".join(t for t in texts if t) or None
|