Skip to content

llm_chat_respond() errors and corrupts the chat when the context fills, instead of stopping #28

Description

@andinux

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 is pos_max + 1. The guard treats a position as a count, so it permits exactly one token past capacity — and llama_decode catches what it let through, returning 1. That is why the reproduction hits B on a fresh turn.
  • The comparison is also int32_t against uint32_t, so the left side converts to unsigned. On an empty cache seq_pos_max returns -1; with a zero-token batch that becomes 0xFFFFFFFF and 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;
  1. The generated tokens sit in ai->chat.response but sqlite3_result_text() never runs — the caller gets an error instead of the partial reply.
  2. The user message was appended at :1922, before generation. llm_chat_save_response() never runs, so no assistant turn is appended and prev_len is not advanced. The history keeps an orphan user turn.
  3. Because prev_len is 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 is turn2 above: 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.

  1. 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 +1 fixes the off-by-one. llama_decode then 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.

  2. Stop instead of erroring. Where the guard trips, set *is_eog / c->is_eog = true and return true without setting an error.

  3. Record why — ai->chat.stop_reason ∈ { STOP_EOG, STOP_CONTEXT_FULL }.

  4. 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_len advances, and llm_chat_respond() returns the text generated so far. The sticky corruption disappears.

  5. 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

  1. andinux commented on Aug 25, 2026

    @andinux
    ContributorAuthor

    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:1883 is 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_decode returning non-zero once again means a genuine error — and every decode/encode site reports it as (%d: %s) via a new llm_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 done

    Still sqlite_common_set_error(...); return false; at :1885. No stop_reason field, no llm_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:

    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_len advances, and the history no longer keeps an orphan user turn — non-streaming now agrees with what the vtab already did on xClose. What is still missing is returning that text to the caller: the turn raises Context size exceeded rather than returning the partial reply, so the generated text is reachable only by reading ai_chat_messages. The failure is no longer sticky, but it is still a failure.

    Related: context_size=512 silently 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 from ctx_params.n_ctx = 0 (:2793) — llama's "use n_ctx_train" — and the parsed option overwrites it only when the key is present. Absent key and context_size=0 both auto-size; every explicit value including 512 is honoured.

    ⚠️ Behaviour change: anyone who passed context_size=512 and 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_decode aborted the process on a long prompt. GGML_ASSERT(n_tokens_all <= cparams.n_batch) at llama-context.cpp:1487 is an abort, not an error return. n_batch only tracks n_ctx when the caller passes context_size, so a large context with the default 2048 n_batch reached it easily — and a replayed transcript is one batch of whatever length the chat has. The prompt is now chunked by n_batch (:1895), as llm_text_run() already did.
    • Recovery paths now work. llm_chat_free() clears the KV cache; llm_context_free() resets prev_len and 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

    1. ai->chat.stop_reason ∈ { STOP_EOG, STOP_CONTEXT_FULL }.
    2. At the guard: set *is_eog / c->is_eog = true, return true, no error — so llm_chat_respond() returns the partial reply.
    3. llm_chat_stop_reason() returning 'eog' / 'context_full', in the same commit.
    4. test_chat_context_full_stops_gracefully as specified above; chat_context_full_is_recoverable will need updating, since it currently asserts the error is raised.
    5. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions