AGENTS.md · git:20260917.cddb96b · 2026-09-17 · sha256 8af53bf2ad9b800f
AGENTS.md git:20260917.cddb96bA
Immutable. This exact content never changes and is served at /api/v1/blob/8af53bf2ad9b800f.
# Kiln Engine Agent Guide
## Scope
This repository turns model-authored source into GLB assets. It includes deterministic rendering,
validation/QA, primitives, the agent tool surface, arena ranking, scene composition, a CLI, and an
Agent Plugin (MCP server + skills).
Read [README.md](./README.md) before changing exports or package contents. Runtime code is under
`src/`; tests are colocated as `*.test.ts` or under `__tests__/`. Repository-only checks live under
`scripts/`. The package intentionally ships TypeScript source through the explicit `files` and
`exports` lists in `package.json`.
## Authoring an asset is a different task from changing the engine
This guide covers changing the engine. Authoring an asset does not happen here: it happens in a
separate workspace, because the authoring skills assume a live `kiln_workspace` server, and giving a
model this implementation and the example collection alongside an asset brief changes what it
produces.
If the request is to make or refine an asset, read the `kiln-setup-workspace` skill and create a
workspace first. It is registered for a bare clone at `.claude/skills/` and `.agents/skills/`, and
the maintained copy is `skills/kiln-setup-workspace/`. Everything else in this file assumes you are
working on the engine itself.
## The tool registry is the single source of truth
`src/tools/registry.ts` owns tool names, descriptions, and schemas. Two skins consume it: the
in-process Strands tools (`src/agent/tools.ts`) and the stdio MCP server. **Both must iterate the
registry** -- never hand-write a tool definition in a skin, or the transports drift apart and the
repo's central claim stops being true. There is a test that asserts name parity; keep it passing.
One file, but **two surfaces, deliberately different sizes.** `kilnToolRegistry` is the
in-process agent loop's four tools; `createKilnProgramToolRegistry` is the host-agent MCP
surface of thirteen, which is what `docs/tools.md` documents and what a published release
advertises. "Both iterate the registry" means the definitions live in one file, not that the
two lists match.
The one name that differs is **`kiln_screenshot`, and it is merged rather than missing.** On
the in-process surface `kiln_render` returns metrics only and `kiln_screenshot` carries the
image grid -- two tools, so a cheap structural check need not pay for an image. On MCP
`kiln_render` is unified: it holds `media: screenshotMedia` and returns metrics, part paths
and images together, with six views when `capture` is omitted. A separate `kiln_screenshot`
there would be a second way to ask for the same grid. `src/mcp-parity.test.ts` asserts the
MCP surface does not carry it, so the merge stays deliberate; no capability is absent from
the MCP workflow. The two names share one implementation, `runRenderViews`: `kiln_screenshot`
takes the same `capture` (grid shape, framing, backdrop), routes through the render port and
reports `viewFidelity` exactly as `kiln_render` does. It used to be a frozen CPU-only copy
kept as the control arm of a bench that no longer exists; do not freeze it again without a
consumer that needs the freeze.
Prefer an explicit terminal submit tool over `structuredOutputSchema` in the in-process loop: the
latter's coexistence with a full tool set is provider-dependent, while a submit tool is unambiguous
everywhere. The MCP surface has no submit tool, because there the host agent writes the file itself.
## Host-injected render and cache boundaries
The engine defines the host-injected `PbrRenderPort`. Exported `captureViewsViaPort` is the **single
owner** of the deadline, renderer/PNG validation, grid composition, and never-throw CPU fallback. Do
not duplicate that degrade policy in a host or introduce network/service knowledge into the
deterministic engine paths.
The port is injected twice, for different jobs, and their deadlines must stay separate. A host calls
it once after the loop for the artifact sheet, where nothing is blocked and a long deadline is
correct. `KilnToolContext.viewRenderPort` injects it into `kiln_render` for the IN-LOOP grid, where a
slow render blocks the agent mid-thought -- that deadline is its own per-call argument and belongs far
lower. Never collapse the two onto one value.
Routing is conditional on `sceneNeedsPbrShading(root)`: bound texture or `metalness > 0`, never
material type, because `gameMaterial` and `pbrMaterial` both construct a `MeshStandardMaterial` and
are indistinguishable after construction. Host telemetry rides `onViewsRendered`, while every unified
`kiln_render` result also carries model-visible `ViewFidelityV1`; a geometry-flat CPU image must never
be treated as material evidence. Keep the input schema stable unless the active change explicitly
versions it -- the tool definition is cached, and changing it invalidates that cache.
Both producers paint one backdrop from one table. `src/views/background.ts` owns the three named
backdrops and `render-service/src/backdrops.mjs` mirrors them; `backdrop.test.ts` fails if they
drift. The default is the neutral studio grey, chosen by measuring silhouette-edge contrast over
the whole example set. The GPU service clears to the linear colour that its tone mapping turns
into exactly that value (`render-service/src/display-transform.mjs`), so a CPU fallback sheet and
a GPU sheet share the backdrop pixel for pixel. A capture may name `neutral`, `dark` or `light`; it may never pass a
free colour, because comparability across runs (arena, capture cache, reference comparison) assumes
two sheets of one GLB differ only because the asset does, and a backdrop close to the asset colour
hides the seams the model is meant to find. The chosen id is part of both capture-cache keys and is
echoed as `capture.backdrop` in every render result. `kiln_save` paints its preview on the backdrop
it is given and the manifest records it as `preview.backdrop`; a reader never guesses it.
**One render service per machine, and the socket is its registry.** Every session finds the
service on one shared port (8000, or `KILN_RENDER_SERVICE_PORT`); the first to need it starts
it and the rest join. There is no lease file: `/health` reports `instance` -- pid, the owning
session's pid, and a fingerprint of the service source -- and `src/render-service-host.ts`
acts on that in exactly one way per case. A current service is joined whoever started it. A
stale service (source fingerprint differs from `render-service/src` on disk) that is orphaned
(its owner has exited) is replaced. A stale service still owned by a running session, or
started by hand, is left alone and named, with `kiln service stop` as the way out. Something
that is not a render service on the port is reported, never joined. An on-demand service
watches its owner's pid and exits when it is gone, which is what makes a hard-killed host
safe on Windows; a hand-started service has no owner and outlives sessions. The service and
host fingerprint the source with two mirrored walks (`render-service/src/instance.mjs`,
`renderServiceSourceFingerprint`), and `render-service-lifecycle.test.ts` fails if they drift.
Do not add a second discovery path or a file that can outlive the process it describes.
**The GPU is a view producer only, never gate evidence.** `QaContext` is deliberately image-free so a
QA rule structurally cannot read a render buffer. Do not add pixels to it.
`src/views/renderer-id.ts` runs `readFileSync` at MODULE LOAD. Reach `CPU_RASTER_RENDERER_ID` through
the lazy `await import('../views')` that render paths already use; a static import puts a `node:fs`
edge, evaluated at import time, into a graph deliberately kept free of node-only dependencies. No test
catches this.
Prompt-cache transports are deliberately different. Native Anthropic and Bedrock adapters consume a
system `[TextBlock, CachePointBlock]`; OpenRouter-hosted Anthropic keeps plain system text and receives
top-level `cache_control: { type: 'ephemeral' }` because its Vercel bridge drops cache-point blocks.
Preserve that distinction and the provider usage fields when changing model routing.
## Toolchain and validation
Supported toolchain: Bun `1.4.2`; Node `22.23.2`; npm `12.0.2`. Do not use a Bun canary or implicit
latest for a release gate.
```bash
bun install --frozen-lockfile
bun run check:toolchain
bun run check:skills
bun run typecheck
bun run lint
bun run test
bun run test:render-service
bun run test:coverage
```
Fast change loop: run the nearest test file first, then `bun run typecheck && bun run lint && bun run
test`. Full offline gate: also run `bun run test:coverage`; it emits text plus `coverage/lcov.info`
and enforces the checked-in line/function ratchet in `bunfig.toml`. Raise thresholds when practical;
do not lower them without an explicit measured rationale. Live model tests are opt-in only via
`bun run test:live` and may spend money.
`bun run test` is `bun test src scripts`, so it does **not** reach `render-service/`, which is a
separate npm project. `bun run test:render-service` does; it needs `npm --prefix render-service ci
--ignore-scripts` once. All 54 of those tests are pure -- framing arithmetic, PNG readback packing,
cache identity, contract and preset validation -- so none of them needs a GPU or the native Dawn
build, which is why `--ignore-scripts` is enough. Run it whenever you change `render-service/`; CI
requires it.
The test scripts set `--timeout 20000`. Bun's 5 s default is below what a cold process spawn or a
first native-library call costs on a loaded CI runner: tests that take under a second locally have
timed out at 5 s on the Windows runner while passing on re-run. A test that legitimately needs more
than 20 s names its own budget as the third argument to `test()`, with the measured duration in a
comment; a real hang still fails, it just takes 20 s to.
Tests and CI pin `KILN_RENDER=cpu`. The coverage ratchet must not vary by whether the runner has a
GPU. It is measured over `src/` alone -- the shipped engine -- so nothing you change under
`scripts/` can move it; those tests still run, and their correctness is their own assertions' job.
Use strict test-driven development for behavior changes: add a focused failing test, observe the
expected failure, implement the smallest fix, and rerun both the focused and full gates. Preserve
determinism and do not add `Date.now()` or `Math.random()` to render/rasterizer compute paths.
## Safety
- Never print, read unnecessarily, or commit provider keys, cloud credentials, `.env*`, or secrets.
- Keep normal validation offline. Do not invoke live providers unless explicitly requested.
- Do not commit, push, force-push, create remotes/releases, or publish packages without explicit user
approval.
- Live model and arena runs spend money. Keep them manual and out of CI.