Repository navigation
llm_chat_respond() errors and corrupts the chat when the context fills, instead of stopping #28
Description
Summary
When a chat fills its context, llm_chat_respond() returns a SQL error, discards the text it already generated, and leaves the conversation in a state where every subsequent turn also fails. A full context is a normal terminal condition for a turn, not an error, and should be reported the way every other LLM API reports it: return what was generated, plus a reason.
Found while stabilising CI (#27), where this surfaced as flaky test failures across arm64 and Vulkan jobs.
Reproduction
.load ./dist/ai
SELECT llm_model_load('tests/models/unsloth/gemma-3-270m-it-GGUF/gemma-3-270m-it-UD-IQ2_M.gguf');
SELECT llm_context_create_chat('context_size=64');
SELECT llm_chat_create();
SELECT 'turn1=[' || llm_chat_respond('Write a long detailed essay about the history of computing.') || ']';
SELECT 'turn2=[' || llm_chat_respond('Hi') || ']';Error near line 5: Failed to decode prompt batch (1: could not find a KV slot for the batch, context is full)
Error near line 6: Context size exceeded (256, 280)
Note turn 2 fails too, with a different error, on a prompt of two characters.
Current behaviour
Two exits in llm_chat_generate_response() (src/sqlite-ai.c:1805), both return false:
A — the guard (:1812)
uint32_t n_ctx = llama_n_ctx(ctx);
int32_t n_ctx_used = llama_memory_seq_pos_max(llama_get_memory(ctx), 0);
if (n_ctx_used + batch.n_tokens > n_ctx) {
sqlite_common_set_error(..., "Context size exceeded (%d, %d)", ...);
return false;
}B — the decode (:1820)
int32_t drc = llama_decode(ctx, batch);
if (drc != 0) { ... "Failed to decode prompt batch (%d: %s)" ... return false; }They are the same condition. Which one fires is decided by a bug in A:
llama_memory_seq_pos_max()returns the highest position; occupancy ispos_max + 1. The guard treats a position as a count, so it permits exactly one token past capacity — andllama_decodecatches what it let through, returning1. That is why the reproduction hits B on a fresh turn.- The comparison is also
int32_tagainstuint32_t, so the left side converts to unsigned. On an empty cacheseq_pos_maxreturns-1; with a zero-token batch that becomes0xFFFFFFFFand the guard trips falsely. Latent today (batches are ≥ 1 token), but a trap.
Why the failure is sticky
In llm_chat_run() (:1975):
while (1) {
if (!llm_chat_generate_response(ai, NULL, &is_eog)) return false; // bails
if (is_eog) break;
}
if (llm_chat_save_response(ai, messages, template) == false) return false;- The generated tokens sit in
ai->chat.responsebutsqlite3_result_text()never runs — the caller gets an error instead of the partial reply. - The user message was appended at
:1922, before generation.llm_chat_save_response()never runs, so no assistant turn is appended andprev_lenis not advanced. The history keeps an orphan user turn. - Because
prev_lenis stale, the next turn's template delta re-includes the orphan and is re-fed into a KV cache that is still full — so it fails too. The chat is permanently unusable, not just that one turn. That isturn2above: 280 tokens against a 256 context, for the prompt'Hi'.
There is also an inconsistency: the streaming vtab path does keep the partial reply. llm_chat_cursor_next() returns SQLITE_ERROR, but xClose still calls llm_chat_save_response(). Streaming and non-streaming disagree about what a full context means.
Proposed fix
Treat "context is full" as a terminal condition of the turn, in the same class as EOG.
-
Make the guard exact, so the condition is decided in one place:
int32_t n_ctx = (int32_t)llama_n_ctx(ctx); int32_t n_ctx_used = llama_memory_seq_pos_max(llama_get_memory(ctx), 0) + 1; // pos -> count if (n_ctx_used + batch.n_tokens > n_ctx) { /* stop */ }
Both operands signed; the
+1fixes the off-by-one.llama_decodethen never has to catch an over-large batch, so a non-zero return goes back to meaning a genuine error (2,-1,< -1) and keeps erroring. -
Stop instead of erroring. Where the guard trips, set
*is_eog/c->is_eog = trueandreturn truewithout setting an error. -
Record why —
ai->chat.stop_reason∈{ STOP_EOG, STOP_CONTEXT_FULL }. -
Let the normal path finish.
llm_chat_run()breaks out of its loop,llm_chat_save_response()runs, the partial reply is appended to history,prev_lenadvances, andllm_chat_respond()returns the text generated so far. The sticky corruption disappears. -
Expose the reason —
llm_chat_stop_reason()returning'eog'or'context_full'. Not optional: without it, step 4 turns a loud error into a silent truncation, which is worse for anyone storing the output from a trigger.
This matches OpenAI's finish_reason: "length" and Anthropic's stop_reason: "max_tokens" — partial text plus a flag — and makes the non-streaming path agree with what the vtab already does.
Non-goals
- Multi-turn is not fixed. After a truncated turn the context is still full, so the next turn stops almost immediately with a near-empty reply. Fixing that needs context shifting (evicting the oldest turns and re-basing positions), which is a feature, not a bug fix. This makes the wall graceful, not absent.
- This is a behaviour change. Callers relying on a SQL error to detect overflow will get a short string instead. That is why step 5 should land in the same commit.
Regression test
test_chat_context_full_stops_gracefully in tests/c/unittest.c:
load model
llm_context_create_chat('context_size=1024') -- NOT 512, see below
install a seeded sampler -- install_seeded_chat_sampler(), from #27
llm_chat_create()
turn 1: a prompt long enough to exhaust the context
assert llm_chat_respond() succeeds (no SQL error)
assert the returned text is non-empty
assert llm_chat_stop_reason() = 'context_full'
assert ai_chat_messages contains BOTH the user turn and the assistant turn
(today the assistant turn is missing)
turn 2: llm_chat_respond('Hi') on the same connection
assert it does not return "Context size exceeded" — the chat is still usable
(this is the part that fails today, and the real point of the fix)
control: a short prompt on a fresh chat
assert llm_chat_stop_reason() = 'eog'
The turn-2 assertion is the important one: turns 1 and 3 only check the new surface, but turn 2 is what proves the state corruption is gone.
Use context_size=1024, not 512 — see below.
Related: context_size=512 is silently ignored
Not the same bug, but it made this one much worse and should probably be fixed alongside. src/sqlite-ai.c:2736:
struct llama_context_params defaults = llama_context_default_params();
if (ai->model && ctx_params.n_ctx == defaults.n_ctx) {
ctx_params.n_ctx = 0; // 0 = "use the model's n_ctx_train"
}The intent is "auto-size when the caller did not set context_size", but it detects that by comparing the value against llama's default, which is 512. So an explicit context_size=512 is indistinguishable from unset:
| requested | actual llm_context_size() |
|---|---|
| 64 | 256 |
| 512 | 32768 (the model's n_ctx_train) |
| 1000 | 1024 |
| 1024 | 1024 |
Same on llm_context_create(), llm_context_create_chat() and llm_context_create_textgen().
Consequence: anyone asking for a deliberately small 512-token context gets the model's full window instead — 64× more KV cache than requested. It is also why a runaway generation in the test suite cost minutes instead of failing fast, and the likely explanation for the ~16-minute arm64 jobs in #27.
Fix: track explicitly whether the key was present in the options string (a bool context_size_set in llm_options, or a sentinel of 0/UINT32_MAX that a caller cannot plausibly pass) rather than inferring it from the value.
Activity
ci: fix the Vulkan build, and make the test suite deterministic (#27)
fix(chat): keep the chat usable when it runs out of context (#30)
fix(context): honour an explicit context_size of 512 (#29)
andinux commentedon Aug 25, 2026
Status update — parts of this shipped in 1.0.6 and 1.0.7
The two bugs described here are fixed. The behaviour change — stopping gracefully instead of erroring, with a stop reason — is not done, and is what keeps this issue open.
Proposal 1 — make the guard exact ✅ 1.0.6 (#30)
src/sqlite-ai.c:1883is now exactly as proposed:Both the off-by-one and the signed/unsigned promotion are gone. The condition is now decided in one place, so
llama_decodereturning non-zero once again means a genuine error — and every decode/encode site reports it as(%d: %s)via a newllm_decode_error_string()instead of a bare number.The reproduction's second symptom is gone: turn 2 no longer fails with a different error at 280 tokens against a 256 context.
Proposals 2, 3, 5 — stop instead of erroring,
stop_reason,llm_chat_stop_reason()❌ not doneStill
sqlite_common_set_error(...); return false;at:1885. Nostop_reasonfield, nollm_chat_stop_reason()function. These were deliberately deferred — they are the API change, and the argument in this issue that 5 must land with 2 still holds.Proposal 4 — let the normal path finish⚠️ half
The state corruption is fixed; the return value is not.
llm_chat_run()(:2064) now commits before reporting:So the assistant turn is appended,
prev_lenadvances, and the history no longer keeps an orphan user turn — non-streaming now agrees with what the vtab already did onxClose. What is still missing is returning that text to the caller: the turn raisesContext size exceededrather than returning the partial reply, so the generated text is reachable only by readingai_chat_messages. The failure is no longer sticky, but it is still a failure.Related:
context_size=512silently ignored ✅ 1.0.6 (#29)Fixed by removing the sentinel comparison entirely rather than adding a
bool context_size_set.llm_context_create_with_options()now starts fromctx_params.n_ctx = 0(:2793) — llama's "usen_ctx_train" — and the parsed option overwrites it only when the key is present. Absent key andcontext_size=0both auto-size; every explicit value including 512 is honoured.context_size=512and was silently getting the model's full window (32768 on Gemma-3) now gets 512.Also landed in 1.0.7 (#31), adjacent to this issue
llama_decodeaborted the process on a long prompt.GGML_ASSERT(n_tokens_all <= cparams.n_batch)atllama-context.cpp:1487is an abort, not an error return.n_batchonly tracksn_ctxwhen the caller passescontext_size, so a large context with the default 2048n_batchreached it easily — and a replayed transcript is one batch of whatever length the chat has. The prompt is now chunked byn_batch(:1895), asllm_text_run()already did.llm_chat_free()clears the KV cache;llm_context_free()resetsprev_lenand the chat fields, so history is replayed correctly into a new, larger context. Documented under Recovering from a full context — start over, enlarge, or compact with SQL +llm_chat_restore().These give a user a way out of a full context, which the original report had none of. They do not remove the need for 2/3/5 — recovery is manual and the truncation is still reported as an error.
Regression tests
tests/c/unittest.c—context_size_is_honoured,chat_context_full_is_recoverable(asserts the failure does not grow across retries and that history never ends on a user turn with no reply),chat_prompt_larger_than_n_batch,chat_free_clears_kv_cache,context_free_replays_chat,chat_restore_compacts_context.What is left for this issue
ai->chat.stop_reason ∈ { STOP_EOG, STOP_CONTEXT_FULL }.*is_eog/c->is_eog = true,return true, no error — sollm_chat_respond()returns the partial reply.llm_chat_stop_reason()returning'eog'/'context_full', in the same commit.test_chat_context_full_stops_gracefullyas specified above;chat_context_full_is_recoverablewill need updating, since it currently asserts the error is raised.Context size exceeded, which stops being true.The non-goals still stand — this makes the wall graceful, not absent. Multi-turn past a full context needs context shifting, which is a feature.