Skip to content
14 min read · 2,818 words

Behavioral Rails

We wrote "always cite your sources" into the system prompt. Three turns later, the model didn't cite anything. Here is what we found: a system prompt is a request — the model can read it, even agree with it, and still not act on it, because nothing in the turn checks whether it did. The model wasn't broken — the enforcement was missing. "Asking nicely" in a block of text competing for attention with everything else in the window didn't hold, on this build, on this model. An effective-2B under context pressure drops instructions the way anyone does: the ones that aren't backed by consequence.

A gate is the consequence — a check the harness runs on the model's actual output, after it generates, before the turn is allowed to end, and it decides, not suggests. If the output doesn't satisfy the gate, the turn does not end. Concretely, this happens inside the DispatchRunner iteration loop — each iteration is one dispatch: one model call and its response — that a TurnRunner threads a turn through, generation after generation, until something tells it to stop. This page is the doctrine behind that mechanism: why it works, its shape, and the receipts that prove it.

Why rails, not retries

The naive fix for a misbehaving small model is "tell it again, harder." Add a stronger sentence to the system prompt. Repeat the instruction closer to the end of the context, where recency bias might help. Both are still requests — now there are just two of them, sometimes contradicting each other by the time the window gets trimmed.

The insight this build's gate cascade is built on: on the Gemma-family effective-2B/4B this agent runs, the model followed through on its own voice better than on an external order. Tell it "you must cite your sources" and it's an instruction from outside — one more directive competing with the system prompt, the user's message, and whatever else survived the subtractive pass. Show it its own prior reasoning saying "I need to cite this before I answer" and it continues that thought — on this model, completing its own reasoning proved a much stronger behavioral pull than obeying a new command layered on top.

That's the own-voice nudge — the correction a gate injects to make the model try again: every gate that re-loops emits two channels at once — a [SYSTEM DIRECTIVE] message stating the correction plainly, and a first-person __nudge: Thought written as if the model itself just realized the problem ("I added a fake <cite> tag that is not a real citation. I will rewrite my answer without any invented citation markup…"). The directive covers the case where the model reads instructions literally; the Thought covers the case where it reasons from its own prior state. Converging both channels on the same correction is what took a real turn from 19 dispatches down to 7 — same model, same weights, one contradictory directive removed. That receipt didn't come from a stronger prompt. It came from making sure the model was never asked to do two incompatible things in the same breath (more on that below).

A gate is a mechanism, not a suggestion

The distinction is not stylistic. A system-prompt instruction is advisory: the model can ignore it and the turn proceeds. A gate is load-bearing: the turn's terminator — the code that decides a turn is over and stops generating — itself checks the gate's condition, and the turn does not end until it clears (or the gate's bound is exhausted — see below). If you find yourself writing a longer and louder system-prompt sentence to fix a recurring failure, you are reaching for a request when you need a mechanism.

The gate cascade's shape

The gate cascade runs once per iteration, after the model generates and before the harness decides whether the turn continues. It has three properties that make it survivable rather than a second place for a small model to get stuck.

Not every failure deserves the same response. Some failures the model may genuinely be unable to fix no matter how many times it tries — retrying forever just burns turns and helps nobody. Others have a correct action sitting right there, provably available, every single time — for those there is no excuse budget; refusing again is never the wrong call. That distinction is the whole reason the cascade has two shapes instead of one.

Two classes, not one uniform shape. The cascade is not a single kind of gate repeated with different pattern matches. It's two classes with genuinely different re-loop policies, and which class a gate belongs to is decided by one question: is the correct action only probably available, or provably available, on every single iteration?

Bounded gates handle the "probably" cases — failures the model might be genuinely unable to fix on demand. The leaked-envelope gate (LEAK_GATE_LIMIT), the incomplete-answer gate, and the retrieval-only false-abstention gate (RETRIEVAL_ABSTENTION_LIMIT) all carry a counter and a limit, because the thing they're asking for — clean formatting, a shorter answer, proof that a passage actually covers the question — is not something the harness can prove is achievable this turn. A model that keeps failing to produce clean output no matter how the correction is phrased falls through the limit rather than looping forever, and the fall-through path is not "give up and ship whatever's on hand" — it routes to a clean, canned gracefulFallback (a pre-written safe response, not the model's rejected output). The fall-through must never commit the content the gate just rejected. A leaked internal fence that survives every regeneration attempt does not get to become the delivered answer just because the model refused to stop producing it — that would turn the escape hatch into a second leak vector, which is exactly the failure this rule was written to close after it was observed once.

Unbounded invariants handle the "provably" cases, and the cascade ships several of them on purpose: the unread-handle gate (rejects an answer while a handle the model was explicitly given remains unread — "deterministic, unbounded" in the harness's own comment), the definitive-result false-abstention gate (a calculate or get_current_time call already returned the literal answer this turn — rejected "every time," no counter), and the unverified-claim gate (a numeric claim with no tool call behind it — "reject + re-loop, UNBOUNDED"). None of these carry a limit, and that's not an oversight. In all three cases the correct action is sitting in the model's own context on every single iteration — the handle it was handed, the tool result it already has — so there is no turn count past which "the model still hasn't done it" becomes evidence that it can't. A counter on these gates wouldn't be a safety valve; it would be a deadline after which fabricating the answer becomes acceptable. That's not a fallback, it's a laundering step, and no pathological case gets to buy a free pass by running out the clock — the fix for a model that won't read the handle is never "let it answer without reading the handle after enough tries."

The two classes share the same re-loop mechanics — the own-voice nudge, the mutual-exclusion rule below — because both are still asking the model to try again with a correction in hand. They differ only in whether trying again can run out, and that difference is deliberate, not an inconsistency to paper over.

Terminal branches carry no nudge; re-looping branches always do. A branch that ends the turn — ack (short for acknowledge: the harness accepts the output and stops), commit, done — has nothing left to correct; there is no next generation to read a nudge. A branch that keeps the turn alive always pairs with the dual-channel correction described above. Getting this backwards (nudging a terminal branch, or silently re-looping without one) either wastes tokens on advice nobody reads or repeats a failure with no signal to break the pattern.

Mutual exclusion between gate classes. This is the load-bearing rule, and it exists because of a very specific way we watched this build's effective-2B wedge: its context holding two opposite instructions at once. Nudges are sticky — they persist until their gate clears — and more than one can be active simultaneously. Without a rule, a "call search_docs_semantic now" correction from one gate and a "reply in plain prose, do NOT call a tool" correction from another could coexist in the same assembled directive: telling the model to call a tool and call nothing, in the same breath. Our fix is a straightforward partition, quoted here because it is the actual essay sitting in the harness types:

Gate CLASSES for the mutual-exclusion rule. A 2B wedges when its context holds two opposite instructions at once. Nudges are STICKY (they persist until their gate clears), and multiple can be active, so a "call search_docs_semantic now" correction and a "reply in plain prose, do NOT call a tool" correction could co-exist in the same assembled directive — telling the model to call a tool AND call nothing. These two sets are mutually exclusive: emitting a gate from one class clears any active gate from the other class, so at most one CLASS is ever active. Same-class coexistence (e.g. "search" + "read the handle") is fine — those compose. Gates in NEITHER set (duplicate-call/force-answer hard-stops, the observability kinds) never cross-clear.

The two sets, verbatim:

ts
const TOOL_REQUIRING_GATES = new Set([
  'needs-citation',    // call search / provide_answer
  'unread-handle',     // call artifact_* to read the handle
  'check-catalog',     // call tool_catalog
  'plan-violation',    // call the planned-but-uncalled tool
  'bad-tool-name',     // re-call the tool with a real name (also reused for bad-args)
  'bare-args',         // re-issue the tool call properly
  'unverified-claim',  // compute the value via a tool first
  'result-too-large',  // re-read the SAME handle with a narrower query
])
const PROSE_ONLY_GATES = new Set([
  'incomplete-answer', // re-answer shorter, in prose
  'prose-required',    // answer in plain prose, no tool
  'parroting',         // answer in your OWN words
  'false-abstention',  // don't give up — answer from the result you have
  'fake-citation',     // drop the invented markup; plain prose
  'leaked-envelope',   // re-answer clean, no XML plumbing
])

Emit a gate from either set and any active gate from the other set clears immediately. Same-class coexistence is fine — "search again" and "read the handle you already have" both ask for a tool call, so they compose without contradiction. Gates in neither set (the duplicate-call hard-stop, force-answer, the purely observational kinds) never cross-clear anything; they aren't part of the contradiction this rule guards against.

Two receipts this shape produces directly, because they're the failure modes it exists to prevent:

  • The false-abstention gate. This build's model said "I don't have that" while a tool already returned something this turn — the result is sitting in context, unread. This gate is itself split by what kind of evidence is in context: a definitive tool result (calculate, get_current_time) makes the abstention provably false, so that branch is an unbounded invariant (below); a retrieval result only proves a passage came back, not that it answers the question, so that branch is bounded (RETRIEVAL_ABSTENTION_LIMIT) and falls through once the model has honestly re-checked and still can't find the answer. Both branches are PROSE_ONLY_GATES members either way, so neither ever contradicts a simultaneously-active tool-requiring correction — the model gets one unambiguous instruction: stop abstaining, read what you already have, answer from it (or, at the bound, a graceful decline instead of a fabricated one).
  • The fake-citation gate. This build's model emitted citation markup (<cite>…</cite>) we never asked it for — almost certainly a pretraining habit, but not a form this system taught it or accepts. Deterministic detection, plain-prose correction, no silent stripping — the model has to actually produce a clean answer, not have its mistake quietly edited out from under it.

The planner book-end contract

Gates correct behavior during a turn. The planner book-end — one check at the start of a turn, a matching check at the end — sets the terms before it starts, before the RawTurnContext handed to run() has produced a single generation, and checks them after it ends: the two ends of the same contract.

Plan at open. A dedicated make_plan dispatch runs before the turn's real work begins, and — critically — it is shown the actual tool catalog for this turn's ToolRegistry. That single fact closes an entire failure class: a planner that can't see the catalog can commit the turn to a tool that doesn't exist, or to a capability that isn't wired up in this environment. Grounding the plan in the real, live catalog means every subsequent gate has something truthful to check conformance against.

Conformance at close. The plan is not just advisory context — it's evaluated. A gate refuses to ack the turn until the delivered answer's shape matches the plan's committed answer_kind (a tool_computed plan needs a tool-derived number; a doc_cited plan needs real citations; a capacity_scoped plan needs an honest decline, not a fabricated attempt). The plan is a promise the turn has to keep, and the close-side gate is what makes the promise enforceable instead of decorative.

Fail-open, not fail-closed. Fail-open means a broken planner lets the turn proceed without a plan rather than blocking it (fail-closed would be the opposite: no plan, no turn). A flaky planner dispatch — one that returns malformed output, or times out on retry — must never be allowed to block an entire turn. On a non-OOM failure the planner returns null and the turn proceeds without a committed plan rather than wedging on planning infrastructure. The one exception is a GPU out-of-memory during planning, which rethrows — that's not a planner problem to swallow, it's a resource condition the caller needs to see and handle.

The planner has its own terminator, separate from the gate cascade's, for a specific reason: the harness's default auto-ack only fires when a generation emits no tool call. But the planner's entire job is to emit exactly one tool call — make_plan — so that generic auto-ack condition never fires, and without an explicit terminator the planner dispatch loops on "call make_plan now" until the abort signal fires, burning 6 to 12 regenerations before anything else in the turn even starts. That's the specific contradiction the 19-to-7 receipt above was found in: the planner was being told to plan (an instruction it had already satisfied) while nothing told it the plan dispatch was done. The fix is one line — ack unconditionally after one planner generation, because one generation is the planner's whole contract — not a bigger prompt.

Receipts, woven in

A few more of the gate cascade's branches exist because a specific, reproducible failure demanded them. None of these are hypothetical:

  • False abstention with the answer already in context. The reproducible shape: a definitive tool (calculate, get_current_time) returns a usable result this turn, and the model still says it doesn't have the information. Because a definitive tool's output is the answer, this abstention is provably false — the gate rejects it unconditionally, every time, with no counter, and sends the model back to read what it already has. (The sibling case — a retrieval result that doesn't obviously cover the question — gets the bounded treatment instead, above, because there the model's doubt might be honest.)
  • The fake-citation invention. This build's model produced <cite> markup this system never asked it for (a pretraining habit, not a taught form), on a turn that isn't even documentation-flavored — the leak showed up on a conversational turn. The detector is deterministic (pattern match on invented tag shapes, not a model call asking "does this look like a fake citation"), so the fix doesn't depend on a second unreliable judgment from the same unreliable model.
  • The self-echo family (parroting guards). A model re-emits its own earlier answer from this turn and the cascade briefly misclassified that as parroting a tool result. Proven on the wire, not assumed: 26 of 28 parroting matches traced back to the model's own prior prose this turn, zero to RAG content — which is why the parroting check now exempts a tool_computed readback against the tool's own result preview, and treats self-echo as its own case rather than folding it into the generic parroting gate. And separately, on the self-apology loop specifically: bisected directly on the real model at temperature 1.0, three accumulated apologies in context produced a fourth apology in 3 of 6 runs; stripped down to just the newest apology, 0 of 6 (and a follow-up run at 8 of 8) produced a real answer instead. The fix wasn't a smarter gate — it was recognizing that apology accumulation was the poison, and that the correction has to prevent re-feeding stale corrections back into the model's own context.

Where this lives

The gate cascade and its full branch set are walked line by line on the showcase's gate-cascade section; the planner book-end gets the same treatment at the planner book-end section. The complete implementation — agent_gate_cascade.ts, agent_planner.ts, and the harness types the mutual-exclusion essay lives in — is walked end to end on the source companion page.

For the discipline that decides what ends up in the window these gates operate on, see Token Thrift. For how to find the next gate you need — by reading what the model actually produced instead of guessing — see Read the Wire.