ADR-151: The context budget is a breakdown, not a number
- Status
-
Accepted
- Date
-
2026-08-11
- Amends
-
ADR-081 (a fifth
RunStepkind, and therefore a fifth persisted event kind) - Authors
-
Netresearch DTT GmbH
Context
ADR-107 bounds a transcript against the model's context
window, and ADR-143 extended that bound to every
configuration-driven send. Both produce a Context: how many
turns were dropped, the estimated total, the budget, whether it overflowed at
the floor.
That is the verdict. It is not the reason. An operator whose agent run keeps losing history could read "dropped 4 turns, 7100 of 6946 tokens" and had no way to learn that 4300 of those tokens were a tool schema they could switch off, or a snippet block someone attached to the configuration last week. The fit's own log lines said the same thing in the same shape.
ADR-143's Revisit when named this: a consumer needing the fit's decision as data rather than a log line.
A second gap sits next to it. ADR-144 gave injected context a
data class and built Input to fold the strictest
declaration across a configuration's snippets and skills. Nothing renders it.
The classification is visible only when the gate refuses a call — at which
point the operator learns the answer by being blocked.
Decision
The fit reports where the window went, as data.
Context gains a Context: the window, the
reserved output, the safety margin, the budget, and four component lines —
transcript, tool schema, system prompt, skills — plus the estimated total and
what is left.
The lines close. The four components sum to estimated, and
contextLength - reservedOutput - safetyMargin equals budget. This is not
decoration: a breakdown a reader can subtract with must not drift from the
figure the pruning decision actually used. So the components are derived from
that one figure rather than measured a second time — the transcript line is
estimated directly and the tool-schema line is the remainder, which is exactly
the marginal cost of putting the schema block on the wire. Four independent
estimates would each carry their own rounding and would not add up.
Snippets get no line of their own, and the label says so.
Configuration composes a configuration's tag-selected
snippets INTO the effective system prompt (ADR-031) before the
caller hands it to fit. At the point the estimate is taken they are no
longer distinct text. Two options were available: change what callers pass
fit so the block arrives separately, or report one line and label it
honestly. The second was taken. The first means a ninth parameter on
fit with exactly one caller able to fill it, to split a figure whose
components would then still be summed for every decision the manager makes —
surface bought for a readout. The line reads "System prompt (incl. snippets)"
everywhere it is rendered, and a test pins that the snippet block lands there
and in no other line.
The surface is the playground inspector. It is where the run trace already
lives, where the request step already shows what went on the wire, and where
Tool already carried a note that a dedicated inspector step
was the follow-up ADR-107 wanted. The accounting is recorded as its own
Run of kind context, ahead of the request step it explains, and
recorded even when the floor overflows and the run stops — that is the run whose
operator most needs it.
A RunStep is a persisted event, so the vocabulary grows with it. Every
traced run — the playground's, an interactive one, a queued one, a resume —
goes through Run, which the
Agent wires to Agent
(ADR-081). The context step is therefore written to
tx_nrllm_agentrun_event like any other, and Agent gains a
CONTEXT case: ADR-081 requires the stored kind to be one the enum declares,
so that a reader can discriminate the payload. Recording it only for a
handle-less trace was not available as a "playground only" gate — the playground
run is a persisted run.
The classification is read from the gate's own service. The same panel shows
every source the run injects — each snippet and skill by name, with the class it
declared or none — and the effective, strictest class.
Input is now the fold over
Input, so the readout and the ADR-144 gate
answer from one list and cannot disagree. Source NAMES only, never text: the
classification exists because the text is sensitive.
The run, not the configuration. A playground run also injects the forced
snippets and skills the operator ticked, and those reach the wire exactly like
the configuration's own, so sources takes them as arguments and the
panel lists them. The gate does not see them: it asks classify, which
answers for the configuration alone. A forced source is therefore shown and
not gated, and the panel is a superset of what ADR-144 enforces rather than a
mirror of it. Widening the gate to the forced set is a decision about
enforcement and belongs to ADR-144, not to a readout.
What this does not do
It does not change the estimate. No component is measured differently, no threshold moves, no run is pruned that was not pruned before. This is a readout of arithmetic that already happened.
It does not correct the system-prompt over-count.
Context decides whether a
prompt will be prepended by looking at $messages[0], while
Message scans the whole list. A transcript
whose system message sits deeper is therefore charged for a prompt that will not
be prepended. That errs HIGH, which is the safe direction, and it stays. What
changes is that the charge is now visible — a non-zero system-prompt line next
to a transcript that already carries a system message. Making the readout of a
known imprecision the occasion to change the imprecision would ship a behaviour
change inside an observability PR.
Two of the four component lines are structurally empty on the surface this
ships to, and the panel says so. The agent loop assembles its own prompt: it
bakes the effective system prompt as message 0 before any fit, and it injects
skill prose into the transcript with
Skill. By the time
Context runs, both are inside the message list, so
missing returns 0 with
system true, and no caller on that path passes an
injected skill block at all. On the playground the system-prompt line therefore
always reads "counted in the transcript" and the skills line always reads 0 —
not because nothing is there, but because the transcript line already carries
it.
The alternative was to make the two separable at the loop's seam: stop baking
the prompt into the list and let the shaper prepend it after the fit. That
changes what the loop assembles and in which order — the bake exists because a
forced snippet system message would otherwise satisfy the manager's
"a system message already exists" guard and suppress the configuration prompt
for the run — so it is a behaviour change wearing an observability change's
clothes, which the paragraph above refuses for the same reason. The lines stay,
because they are part of the sum a reader subtracts with, and the panel states
under the table why those two are empty here. They carry real figures for a
send that injects either after the fit; fit supports that and the unit
tests pin it.
It is not RENDERED outside the playground. The context step reaches the
AgentRun event stream — see the decision above; it is stored for every traced
run — but no surface reads it back: the AgentRun module does not render the
context kind, and the Llm send-level fit (ADR-143)
produces a breakdown that nothing displays, its overflow still surfacing as a
log line. The consumer would be a run-history view, and that view does not exist
yet. What ships now is the data, in the stream it belongs to, plus the one
surface that reads it live.
It is not merged with the routing decision trace. ADR-142 raised the same surface question for routing, and ADR-143 said the two should get one answer. They still should — a single "why did this send look like this" panel covering model choice and window accounting. Both readouts are being built at once and against different data; converging them before either has a user would be designing the join from two guesses. The convergence point is named here so the next reader does not build a third.
Consequences
●● An operator can see which component fills the window, per round, and act on the one that is theirs to change.
● The data-class declaration finally has a reader that is not a refusal. An operator can see that a configuration carries a CONFIDENTIAL snippet before a call is blocked for it.
◐ Context grew a required constructor argument — a BREAKING
change for anyone constructing the result themselves, marked as such in the
CHANGELOG. Tests/Unit/Api/api-surface.txt does not catch it: the
snapshot records properties and methods and has never recorded a constructor,
so what it pins is the new breakdown property, not the signature break.
The CHANGELOG entry is the only place the break is stated. No default is
offered, because the breakdown restates budget and
estimated: a defaulted one would permit a Context
whose halves contradict each other, and every surface would then report "no
accounting" for a fit that was measured. Construction is the manager's alone in
production, and the closure rule made Context
@api too.
◐ One extra estimator pass per fit, over the list that is about to cross a network. The fit already makes at least two.
◐ One extra recorded step per round on every traced run, not only the
playground's. A recorded step costs what ADR-081/103/104 make it cost: one
indexed findRun read for the cancellation probe, one guarded lease-renewal
update for a leased segment, and one event row. That is roughly a third to a
half more rows in tx_nrllm_agentrun_event per run. It is accepted rather
than gated because the step boundaries it adds are the same ones the loop
already has, the row is small, and a run whose accounting is missing from the
persisted stream would make the eventual run-history view a second estimator —
which is exactly what the closure rule above refuses.
✕ The four lines are estimates scaled by the manager's calibration factor, not tokenizer counts. ADR-107's limits are unchanged: a breakdown that adds up is not a breakdown that is exact.
Revisit when
A run-history surface exists that needs the same accounting after the fact, or the routing readout has enough users to make the joined panel a real design rather than a guess at one.