Repository navigation
llm_chat_respond() errors and corrupts the chat when the context fills, instead of stopping #28
Description
Activity
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: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; if (n_ctx_used + batch.n_tokens > n_ctx) { ... }
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
⚠️ halfThe state corruption is fixed; the return value is not.
llm_chat_run()(:2064) now commits before reporting:if (!llm_chat_generate_response (ai, NULL, &is_eog)) { // The turn failed part-way. The user message is already in the history and its // tokens are already in the KV cache, so commit whatever was generated before // reporting: leaving the turn half-applied strands the user message with no // assistant reply and leaves prev_len stale, which makes the delta for the // *next* turn re-include it and fail too. llm_chat_save_response(ai, messages, template); return false; }
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.⚠️ Behaviour change: anyone who passedcontext_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.- Recovery paths now work.
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 }.- At the guard: set
*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.- API.md: the "Recovering from a full context" section says the turn returns
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.
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
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), bothreturn false:A — the guard (
:1812)B — the decode (
:1820)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.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):ai->chat.responsebutsqlite3_result_text()never runs — the caller gets an error instead of the partial reply.: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.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()returnsSQLITE_ERROR, butxClosestill callsllm_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:
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'sstop_reason: "max_tokens"— partial text plus a flag — and makes the non-streaming path agree with what the vtab already does.Non-goals
Regression test
test_chat_context_full_stops_gracefullyintests/c/unittest.c: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=512is silently ignoredNot the same bug, but it made this one much worse and should probably be fixed alongside.
src/sqlite-ai.c:2736: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 explicitcontext_size=512is indistinguishable from unset:llm_context_size()n_ctx_train)Same on
llm_context_create(),llm_context_create_chat()andllm_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_setinllm_options, or a sentinel of0/UINT32_MAXthat a caller cannot plausibly pass) rather than inferring it from the value.