---
url: 'https://adk.nht.io/the-loop/behavioral-rails.md'
description: >-
  A system prompt is a request; a gate is a mechanism. The doctrine of bounded
  gates, own-voice nudges, and the planner book-end that keeps a small model
  on-contract turn to turn.
---

# Behavioral Rails

## LLM summary — Behavioral Rails

* A system prompt is a *request*; this build's effective-2B reliably ignored it under context pressure. A
  **gate** is a *mechanism*: a deterministic or near-deterministic check on the model's actual output that decides
  whether the turn may end, independent of whether the model felt like complying.
* **Own-voice nudges**: audited across this build's gates, the correction that landed was the one written in the
  model's own first-person voice, not an external directive. Dual-channel: a `[SYSTEM DIRECTIVE]`-style message
  plus a first-person `__nudge:` [`Thought`](https://adk.nht.io/api/@nhtio/adk/common/classes/Thought) the model reads as its own prior reasoning. Receipt: removing one
  contradictory directive took a turn from 19 dispatches to 7 on the same model, same weights.
* **Gates are TWO CLASSES, not one uniform shape.** *Bounded* gates (leaked-envelope, incomplete-answer,
  retrieval-abstention) correct a failure the model might genuinely be unable to fix — formatting, length, a
  passage that may not cover the question — so they carry a counter, a limit, and a `gracefulFallback` exit that
  never commits the rejected content. *Unbounded invariants* (the unread-handle gate, definitive false-abstention,
  unverified-claim) guard a correct action that is provably available on *every* iteration — the handle sitting
  right there, the tool result already in context — so they reject every time, with no counter and no give-up
  branch, because accepting a fabricated value after N tries isn't a fix, it's laundering the fabrication.
* **Terminal vs. re-looping branches.** A branch that ends the turn (ack, commit, done) carries no nudge — there
  is no next generation to read it. A branch that re-loops always carries one (dual-channel, own-voice).
* **Mutual exclusion between gate classes.** `TOOL_REQUIRING_GATES` (call a tool) and `PROSE_ONLY_GATES` (answer
  in plain prose, no tool) can never both be active — emitting one clears any active gate from the other class.
  Otherwise this build's effective-2B's context can hold two opposite instructions at once and wedge. Same-class
  coexistence is fine; hard-stop/observability gates in neither set never cross-clear.
* **The planner book-end.** A dedicated `make_plan` dispatch runs at turn-open, sees the real tool catalog (can't
  commit to a fabricated tool), and produces an evaluatable plan `Thought` re-checked against the live catalog on
  every iteration. A conformance gate at turn-close refuses to ack until the answer matches the plan's
  `answer_kind`. The planner fails open — a flaky planner never blocks a turn; only a GPU OOM rethrows.
* Deep links: the [gate cascade](/showcase/punching-above-its-weights#the-gate-cascade-owns-the-turn) and
  [planner book-end](/showcase/punching-above-its-weights#the-planner-book-end) sections of the showcase; the
  full implementation on [the source companion page](/showcase/punching-above-its-weights-source).

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`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner) iteration loop — each iteration is
one **dispatch**: one model call and its response — that a [`TurnRunner`](https://adk.nht.io/api/@nhtio/adk/turn_runner/classes/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).

::: tip 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-loop/read-the-wire) — 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`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/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`](https://adk.nht.io/api/@nhtio/adk/forge/classes/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](/showcase/punching-above-its-weights#the-gate-cascade-owns-the-turn); the
planner book-end gets the same treatment at
[the planner book-end section](/showcase/punching-above-its-weights#the-planner-book-end). 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](/showcase/punching-above-its-weights-source).

For the discipline that decides *what* ends up in the window these gates operate on, see
[Token Thrift](/the-loop/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](/the-loop/read-the-wire).
