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.
Files changed (44) hide show
  1. brickwright/__init__.py +5 -0
  2. brickwright/__main__.py +6 -0
  3. brickwright/_build_template.py +25 -0
  4. brickwright/cli.py +446 -0
  5. brickwright/config.py +104 -0
  6. brickwright/driver/__init__.py +7 -0
  7. brickwright/driver/completion.py +89 -0
  8. brickwright/driver/critique.py +133 -0
  9. brickwright/driver/external_critique.py +114 -0
  10. brickwright/driver/gallery.py +289 -0
  11. brickwright/driver/loop.py +377 -0
  12. brickwright/driver/messages.py +168 -0
  13. brickwright/driver/narration.py +320 -0
  14. brickwright/driver/report.py +52 -0
  15. brickwright/driver/tracking.py +217 -0
  16. brickwright/driver/transcript.py +99 -0
  17. brickwright/driver/viewpoint.py +242 -0
  18. brickwright/prompts/__init__.py +3 -0
  19. brickwright/prompts/compilation.py +110 -0
  20. brickwright/prompts/dispatch_guidance.md +6 -0
  21. brickwright/prompts/general_instructions.md +68 -0
  22. brickwright/prompts/internal_agent_instructions.md +29 -0
  23. brickwright/prompts/internal_subagent_instructions.md +8 -0
  24. brickwright/prompts/render_guidance.md +4 -0
  25. brickwright/py.typed +0 -0
  26. brickwright/record.py +620 -0
  27. brickwright/tools/__init__.py +69 -0
  28. brickwright/tools/build_execution.py +113 -0
  29. brickwright/tools/connectivity_diagnostics.py +118 -0
  30. brickwright/tools/editing.py +314 -0
  31. brickwright/tools/exploration.py +85 -0
  32. brickwright/tools/finalization.py +74 -0
  33. brickwright/tools/registry.py +241 -0
  34. brickwright/tools/rendering.py +249 -0
  35. brickwright/tools/schemas.py +203 -0
  36. brickwright/tools/software_rendering.py +780 -0
  37. brickwright/tools/subagents.py +109 -0
  38. brickwright/tools/testing.py +128 -0
  39. brickwright/tools/validation.py +172 -0
  40. brickwright-0.1.0.dist-info/METADATA +89 -0
  41. brickwright-0.1.0.dist-info/RECORD +44 -0
  42. brickwright-0.1.0.dist-info/WHEEL +4 -0
  43. brickwright-0.1.0.dist-info/entry_points.txt +3 -0
  44. brickwright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """BrickWright: an agent harness for building LEGO models with BrickLayer."""
2
+
3
+ from .tools.testing import TestContext
4
+
5
+ __all__ = ["TestContext"]
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m brickwright`` as an alias for the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,7 @@
1
+ """The litellm-backed autonomous driver over the harness primitives."""
2
+
3
+ from .loop import run_agent
4
+ from .narration import Narrator
5
+ from .report import RunReport
6
+
7
+ __all__ = ["Narrator", "RunReport", "run_agent"]
@@ -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