Context budgeting for the openai-api provider¶
The failure¶
Asking Rig-E "what can you tell me about the rig?" from the phone returned a red
ERROR — upstream status 502, rendered directly beneath a green banner reading "GPU brain is
ready — model loaded and serving."
Both panels were correct. The pod was RUNNING, the model was loaded, and the request was
rejected for being one token too long.
14:06:31 OpenAI API call turn=0, messages=4
14:06:54 OpenAI tool call: get_api_health
14:06:54 OpenAI tool call: get_api_agents
14:06:54 OpenAI tool call: get_api_streams_status
14:06:54 OpenAI tool call: get_api_stuck-issues
14:06:54 OpenAI tool call: get_api_issues
14:06:55 OpenAI tool call: get_api_events_recent
14:07:01 OpenAI API call turn=1, messages=13
14:07:02 400 This model's maximum context length is 16384 tokens ... at least 16385 input tokens
It is deterministic, not cumulative¶
This was first described as something that bites "a few questions into a conversation", as though
context accumulated. It does not. The turn loop appended each tool result with
JSON.stringify(result) and no bound whatsoever, then posted the lot on the next call.
What separates a working question from a broken one is fan-out, not history:
| question | tool calls | outcome |
|---|---|---|
| "Are you awake?" | 1-2 | answers fine |
| "What can you tell me about the rig?" | 6 | 502, on the first attempt |
The same broad question failed identically twice, fifteen minutes apart, in different pod states (2026-09-10 21:53Z and 2026-09-11 14:07Z). Both landed on exactly 16385 tokens against a 16384 ceiling, because vLLM reports the first count past the limit rather than the true total — so the real overshoot is unknown and larger than one token.
The consequence is worth stating plainly: the single most natural question to ask this agent was the one guaranteed to fail.
The fix¶
src/agent/providers/context-budget.js trims tool results to fit before the call, rather than
trying to recover from the rejection. There is nothing to recover: vLLM refuses the whole request,
so no partial answer exists, and a retry posts the same payload.
Largest-first. The biggest payload is cut to the size of the next largest, then the total is re-measured. With six results where one is enormous, shaving all six equally would destroy five small useful ones to save the outlier.
Measured from inside the cluster on 2026-09-11, that is not a hypothetical distribution:
| endpoint | bytes | ~tokens | share |
|---|---|---|---|
/api/issues |
316,876 | ~90,536 | 97.0% |
/api/agents |
7,738 | ~2,210 | 2.4% |
/api/events/recent |
789 | ~225 | 0.2% |
/api/stuck-issues |
702 | ~200 | 0.2% |
/api/streams/status |
406 | ~116 | 0.1% |
/api/health |
57 | ~16 | 0.0% |
| total | 326,568 | ~93,305 | 5.7× the window |
One payload is 97% of the fan-out and the other five together fit with room to spare. Proportional shaving would have destroyed five useful results to save the only one that is actually the problem.
A correction worth keeping. The first draft of this document asserted that
get_api_events_recentwas probably the largest, because it took 8.2 s and 6.8 s in the two incidents while everything else returned in milliseconds. It is 0.2%. Payload size was inferred from response duration, and the two are unrelated:/api/events/recentis slow and tiny,/api/issuesis fast and enormous. One command overturned it. Measure the thing; do not infer it from a number that happens to be nearby.
Pessimistic estimation. CHARS_PER_TOKEN = 3.5, below the ~4 of English prose, because tool
output is JSON — dense with braces, quotes and ISO timestamps, which tokenize poorly. Estimating
low on chars-per-token means estimating high on tokens. That asymmetry is deliberate: over-estimating
trims a little more than needed and costs some detail, while under-estimating reproduces the bug.
Output headroom. max_model_len bounds input plus output, so DEFAULT_RESERVE_OUTPUT_TOKENS
is withheld. Budgeting the whole window for input would fit the request and leave the reply no room
to finish.
The tools schema counts. Six tool definitions are sent on every call. Measuring only the messages would under-count by a meaningful margin.
In-band truncation markers. A trimmed payload says so inside its own content, and tells the model the data is incomplete rather than to infer the remainder. A silently shortened result is worse than an error, because the model will answer confidently from half the evidence.
A floor. Below MIN_TOOL_RESULT_CHARS a payload stops being evidence and becomes something to
hallucinate around. If everything is at the floor and it still does not fit, the module reports
fits: false and gives up rather than looping — a hung chat reply is worse than an oversized
request that gets a clean 400 back.
Why the overflow is answered in words¶
chat.js deliberately returns a generic 502 {error: 'the agent provider failed'} for provider
failures, because provider error text routinely carries env var names, credential paths and prompt
fragments, and that response crosses a network boundary to a phone. That rule is right and stays.
A context overflow is a narrow, principled exception, and it does not weaken the rule:
- The sentence sent is a constant in
chat.js, never the provider's text. Nothing can ride out on it. - It is a 200 with a normal assistant reply, not an error, because it is a conversational outcome rather than an infrastructure fault. The brain is up; it was asked for something that does not fit.
- It is the one 4xx here an operator can act on — by asking a narrower question — so it is the one worth spending words on.
The reply names the cause, states that the GPU is healthy, and suggests the narrower question that will work.
That contradiction between panels is most of what made this expensive. A red 502 under a green
"ready" banner tells the operator the brain is down when it is running and healthy, and on
2026-09-10 that reading cost a full misdiagnosis of the GPU.
Configuration¶
| Value | Default | Meaning |
|---|---|---|
llm.maxContextTokens |
16384 |
Must match vLLM's --max-model-len for the served pod |
llm.reserveOutputTokens |
2048 |
Withheld from the input budget for the reply |
If the pod's --max-model-len changes, change llm.maxContextTokens with it. They are two
statements of the same number in different places, and the failure when they disagree is this bug.
Raising --max-model-len on the pod is not an alternative to this module. It costs VRAM for the
KV cache and only moves the ceiling, so the same question against a busier rig reaches it again. At
5.7× over, it would also need to roughly six-fold — which the served AWQ pod cannot give.
What this does NOT fix¶
Trimming 90,536 tokens down to the ~13,000 that remain means the model sees roughly 14% of the issues. It is told, in-band, that the rest is missing, so it will say so rather than invent it — but the answer's ceiling is set by what the endpoint sends, not by what trimming can salvage.
/api/issues returns every issue with its full markdown body. Dropping body from that default
projection is the actual cure, tracked at dashecorp/rig-conductor#2080. This module stops the
502; it does not make the answer good, and the two should not be confused when this closes.
Source¶
| File | Role |
|---|---|
src/agent/providers/context-budget.js |
estimateTokens, fitToolResults, isContextOverflowError |
src/agent/providers/context-budget.test.js |
The real six-tool failure shape, largest-first, the floor, the tools overhead |
src/agent/providers/openai-api.js |
Budgets each turn; classifies the overflow 400 |
src/chat.js |
CONTEXT_OVERFLOW_REPLY — the words sent instead of a 502 |
Issue: #696. Related: #685 (model-loading vs pod-asleep), #695 (the wake reply that reported
time since the last message rather than wake duration).