Skip to content

ADR-0018 Architecture decision record

The instance authors Agent definitions; jr2 assembles the Harness ​

Building the first real Harness (the jr-parity exercise, 2026-07-18) landed a full harness-framework project in the instance — package.json, runtime config, Dockerfile, per-agent source (under flue, the embedded framework since retired — ADR-0027). That put jr2 mechanism in the user's hands: the ADR-0013 leash (read the Menu per Submission — forget it and the Agent is silently mute, the same footgun class ADR-0016 removed from workflows), the ADR-0004/0012 sandbox geography (working tools execute in the Harness container against /work), the dependency pin that must match the Orchestrator's wire, and the image contracts the operator imposes (numeric uid, :8080 = Ready, git present). The only content a user genuinely owns is the persona: model + instructions (+ someday tools/skills). GLOSSARY.md already promised the split — an Agent is a definition jr2 maps into the Harness, and the Instance folder holds only what a Machine cannot carry (ADR-0049/0050: the definition is one of the things it can).

Decision ​

  • An Agent is a plain-data definition carried by a Machine as an actor slot — actors: { <name>: agent({ model, instructions, … }) }, slot key = Agent name, invoked as src: "<name>" (ADR-0049). The definition may be written inline or imported from any module. Serializable persona data only; the instance never imports the Harness runtime. model is required: every Agent is independently valid, and definitions are the only models jr2 up can preflight — it collects them by walking the registered Machines. (The first cut filename-discovered agents/<name>.ts, mirroring workflows/, and a second put them in jr2.config.ts; both were Instance rosters, and a roster cannot hold two Machines' coders.)
  • The Harness image is stock and published — jr2-harness:<kitversion>, one release train with the kit (like the operator image, ADR-0019). An instance builds no Harness image. The image is @jr2/harness, jr2's own server (ADR-0027), honoring the operator's image contracts.
  • The definition rides the Turn, never the image. The admission body carries the slot's definition (ADR-0049); the server validates it loudly and re-reads model/instructions per Submission, with the Turn's Frame (prompt, cwd) and Dials beside it on the admit body (ADR-0057). No image is built anywhere for a definition edit. (A ConfigMap roster read at boot — JR2_AGENTS_JSON — was the mechanism between the codegen-at-boot cut and this one; it retired because a Machine edit already publishes the Assembly again, so the roster bought nothing a Machine-carried definition loses.)
  • The kit's server assembly carries the mechanism: the Menu leash, rooting the Working tools at the Frame's cwd, tool assembly — existing only in kit code, unforgettable by construction (symmetric with ADR-0016 making endpoint/sandbox threading unrepresentable in workflows).
  • harness is the agent-runtime section of jr2.config.ts — it declares what the instance can reach, never which model to use: a custom provider ({ api, baseUrl } plus token limits — contextWindow/maxTokens, provider-level and per-model, since a custom id has no catalog entry and unset limits resolve to 0, starving auto-compaction — e.g. an OpenAI-compatible vLLM endpoint), the gateways pi's catalog providers send their calls to (catalog, below), and any other env Agents need; the credentials Agents use are the Held secrets of the top-level secrets list (below). It moved out of sandbox deliberately: sandbox is pod-transport (it still carries this env to the Harness container), but model concerns are Harness semantics and users configure them here. jr2 up preflights every model the definitions name, from inside the cluster (ADR-0019). A model of pi's catalog whose provider no pod holds a key for fails the preflight, unless a credential in harness.env (an AWS role for Bedrock, say) reaches it. An instance-wide default model was tried and removed: it was reached through .env (JR2_MODEL), which put a design decision in the file reserved for deployment-varying values (ADR-0019) — and split one fact in two, since the endpoint's per-model token limits were already committed in jr2.config.ts keyed by the very model id .env was choosing. The honest split: harness declares reach; the definition makes the choice.
  • Credentials are held, not handed over (ADR-0059). The Agent executes code in the Harness container, so a key there is a key it can take away. The top-level secrets list declares each credential (ADR-0068). A model key is a Held secret in the pods whose Agents name a model of a provider that reads it; jr2 up gives its value to the Custodian of those pods — and, where an Effect also names it, to that Effect's Effector — and the Harness container's env holds the key's Stand-in. harness.provider.apiKey is one implicitly: the held secret JR2_PROVIDER_API_KEY, bound to baseUrl's host, so it leaves the jr2-harness-env Secret and baseUrl must be https:// when a key is set. A known model key in harness.env, or in an envFrom Secret, is refused.
  • A catalog provider can be moved to a gateway, and stays a catalog provider. harness.catalog.<id>.baseUrl replaces the base URL of every model of one pi catalog provider — a LiteLLM gateway in front of Bedrock, for anthropic/…. The catalog keeps its limits, cost and thinking levels; only the endpoint moves. It is reach, not a model choice: it says nothing about which model an Agent names.

Dials: a workflow may set two of a definition's fields for one Turn ​

One definition value may be carried by several Machines, so one persona legitimately runs at different settings in different workflows — a reviewer on a one-line diff and the same reviewer on an architecture change want identical instructions and different effort. Spread-composition (below) answers this with persona×tier files where "heavy" is a knob wearing a persona's filename, so agentRun takes two optional dials, model and thinkingLevel, layered over the definition per Submission. A model can lack a level (Claude Sonnet 5.5 has no off). The Harness does not refuse it at admission: the Turn runs, and the printed conversation names the level that the model ran at (ADR-0023).

The line that keeps this from becoming "re-specify the definition at the call site": identity vs. dial. Identity — instructions, access — is definition-only: a call site that rewrote it would make the Agent's name a lie, and access carries ADR-0028's containment claim that a read-only reviewer cannot write, which per-invocation escalation would void. ADR-0028 is therefore untouched by the dials. Dials say only how hard to run: it is still the coder, it is the coder running hot. What the Turn is about and where it works — prompt, cwd — is neither identity nor a dial but the Turn's Frame (ADR-0057): per Turn by nature, since a worktree path exists only once a run does, and no more a property of the persona than the prompt is.

Where a model is checked: two seats, each where the knowledge is. At converge, jr2 up walks the registered Machines for the definitions they carry (ADR-0049) and checks a configured provider once per distinct model they name. At admission, the Harness resolves the definition it was handed against its registry and 400s the invoke as its state is entered, rather than settling the Submission failed mid-run. The pod-boot seat retired with the roster it read: the Harness no longer knows an Agent before a Turn brings it one. A dial has only ever had the admission seat — a call-site model cannot be checked earlier, because an invoke's input is a function and is not statically recoverable. Definitions keep converge-time safety; overrides pay the later check for the flexibility.

Considered options ​

  • Thin wrapper (a jr2 identity imported inside a user-owned harness project). Rejected: shrinks the per-agent file but leaves the dependency pin, the runtime config, and the Dockerfile contracts in user space — the parts that break silently when they drift from the Orchestrator's wire and the operator's pod.
  • Instance owns the harness project (the first cut). Rejected: violates the documented Instance/Agent split, and every new agent re-copies the leash.
  • Per-instance Harness image built by a jr2 harness build (the second cut: jr2 generates the project under .jr2/harness/, the user docker-builds it). Rejected once definitions were plain data: the image content was 100% kit-owned mechanism plus a JSON's worth of persona — so the persona moves at runtime and the image joins the published release train. This also deleted a build + kind load step from every agent edit.

Consequences ​

  • The definition contract is data-only: what an Agent may do to the Workspace is part of it (access, ADR-0028); custom tools/skills/subagents have no seat yet. When someone needs them, the contract grows deliberately — there is no eject hatch to a foreign harness project (ADR-0027 deleted it).
  • Because definitions are plain data, stock ones compose with no API: a Machine carries agent(coder), and a consumer retunes it with customize(machine, { agents: { coder: { model: "…" } } }) (ADR-0049, ADR-0009).
  • An invalid definition fails at admission, loudly, naming the slot: a model no registry resolves, a malformed field. A Machine with no Agent slots is valid — a workspace() body parking a Sandbox (ADR-0012) invokes none. Readiness is binding :8080, with no boot build ahead of it (ADR-0027).
  • The kit owns version compatibility: the Harness runtime and its exact pins are kit concerns (ADR-0027) — bumping them is a kit change, never an instance chore.

The docs of Kit version 0.5.1. Each page is also at its .md URL.