Skip to content

fix(chat): keep the chat usable when it runs out of context - #30

Merged
andinux merged 2 commits into
mainfrom
fix/chat-context-full-recoverable
Aug 24, 2026
Merged

fix(chat): keep the chat usable when it runs out of context#30
andinux merged 2 commits into
mainfrom
fix/chat-context-full-recoverable

Conversation

@andinux

@andinux andinux commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The recoverable half of #28. Companion to #29 — that PR makes a small context_size actually take effect, which makes this path far easier to reach.

The bug

Running a chat out of context didn't just fail the turn, it corrupted the conversation. Every later turn failed too, and the failure got worse each time:

turn 11: Context size exceeded (256, 266)
turn 12: Context size exceeded (256, 276)
turn 13: Context size exceeded (256, 286)
turn 14: Context size exceeded (256, 296)

llm_chat_run() appends the user message to the history before generating, then bails straight out of the token loop on failure — never reaching llm_chat_save_response(). So the user turn is stranded with no assistant reply and prev_len is never advanced, which makes the template delta for the next turn re-include the stranded message. Hence the requirement climbing by a turn's worth of tokens on every retry, and a chat that can never be used again.

The fix

Commit the partial turn before reporting the failure. The streaming cursor path already does exactly this in xClose; the non-streaming path disagreeing with it was the bug.

Same 14-turn run, after:

turn 11: Context size exceeded (256, 267)
turn 12: Context size exceeded (256, 267)
turn 13: Context size exceeded (256, 267)
turn 14: Context size exceeded (256, 267)

Stable. And the saved history has an assistant row for every user row instead of trailing orphans — 28 rows vs 24.

Also fixes the guard that decides this. llama_memory_seq_pos_max() returns the highest position, so occupancy is that + 1. Treating it as a count let exactly one over-large batch through for llama_decode() to reject with could not find a KV slot — which is why the failure used to surface from the decode rather than from the guard meant to prevent it. That's the 266 → 267 difference above. The comparison also promoted int32_t to uint32_t, so an empty cache (seq_pos_max == -1) with a zero-token batch tripped it falsely.

What is deliberately not in this PR

The turn still returns an error, not the partial reply plus a stop reason. That half of #28 changes the contract — llm_chat_respond() would start returning short strings where it used to raise — and deserves its own decision. #28 stays open for it.

Worth knowing about the current behaviour: when the guard trips before anything is decoded (a single prompt larger than the whole context), the committed turn is an empty assistant message. That keeps prev_len and the history mutually consistent, which is the goal here, but a rollback of the user turn would be more precise. Doing it properly means distinguishing "nothing entered the KV cache" from "generation stopped part-way", which fits naturally with the stop-reason work rather than ahead of it.

Testing

test_chat_context_full_is_recoverable talks until the context fills, then keeps going, and asserts:

  1. the guard reports it (Context size exceeded), not llama_decode;
  2. the requirement does not grow across retries — the crisp signal that nothing is being stranded;
  3. the saved history does not end on a user turn with no reply.

Verified to fail against a pre-fix build:

[chat_context_full_is_recoverable] failure grew across retries:
1 C test(s) failed. First failure: chat_context_full_is_recoverable

make test: 44/44 pass.

Merge order: this PR must go in before #29

Not a preference — the release job runs on every push to main and gates on
SQLITE_AI_VERSION differing from the latest published release:

if: github.ref == 'refs/heads/main'
VERSION=$(make version)
LATEST=$(... releases/latest | jq -r '.name')
if [[ "$VERSION" != "$LATEST" ]]; then ... release ... else ... warn ... fi

main and the latest release are both 1.0.5, and #29 carries the bump to 1.0.6.

The version bump stays in #29 only.

🤖 Generated with Claude Code

https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt

andinux and others added 2 commits August 24, 2026 13:21
Running a chat out of context did not just fail the turn, it corrupted the
conversation: every later turn failed too, and the failure got worse each time.

    turn 11: Context size exceeded (256, 266)
    turn 12: Context size exceeded (256, 276)
    turn 13: Context size exceeded (256, 286)
    turn 14: Context size exceeded (256, 296)

llm_chat_run() appends the user message to the history before generating, then
bails straight out of the token loop on failure - never reaching
llm_chat_save_response(). So the user turn is stranded with no assistant reply
and prev_len is never advanced, which makes the template delta for the next turn
re-include the stranded message. Hence the number climbing by a turn's worth of
tokens on every retry, and a chat that can never be used again.

Commit the partial turn before reporting the failure. The streaming cursor path
already does this in xClose for the same reason; the non-streaming path
disagreeing with it was the bug. Same run now reports a stable

    Context size exceeded (256, 267)

on turn 11 and on every attempt after it, and the saved history has an assistant
row for every user row instead of trailing orphans (28 rows vs 24).

Also fixes the guard that decides this. llama_memory_seq_pos_max() returns the
highest position in the sequence, so occupancy is that + 1; treating it as a
count let exactly one over-large batch through for llama_decode() to reject with
"could not find a KV slot" instead - which is why the failure used to surface
from the decode rather than from the guard meant to prevent it. That is the 266
-> 267 difference above. The comparison also promoted int32_t to uint32_t, so an
empty cache (seq_pos_max == -1) with a zero-token batch tripped it falsely.

This is the recoverable half of #28. The turn still returns an error rather than
the partial reply plus a stop reason; that part is deliberately left open, since
it changes the contract and deserves its own decision.

test_chat_context_full_is_recoverable talks until the context fills, then keeps
going, and asserts the guard reports it, that the requirement does not grow
across retries, and that the history does not end on a user turn with no reply.
Verified to fail against the pre-fix build with "failure grew across retries".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
The new test asserted that every failure after the context fills reports the
same requirement. That held on macOS but failed on all four Linux jobs:

    first: Context size exceeded (256, 257)
    last:  Context size exceeded (256, 269)

Both numbers are correct, and the assertion was wrong to compare them. 257 is
one token over: the guard tripped mid-generation, where the batch is a single
sampled token. 269 is thirteen over: a retry, where the batch is the whole
re-sent user message against a cache that is already full. Two different batch
sizes, so two different numbers - nothing to do with the corruption the test is
meant to catch. On macOS generation happened to stop at EOG before filling the
cache, so the first failure was also a prompt-batch one and the numbers matched.

Record every failure instead of just the first and last, and compare only from
the second onward - those are all prompt-batch retries sending the same message
against the same full cache, so they must agree. Still catches the regression;
against the pre-fix build the four failures read

    266, 276, 286, 296

which is the stranded user turn being re-sent on every retry.

Also prints all of them on failure, which is what made the CI result diagnosable
in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
@andinux
andinux merged commit d2b4a63 into main Aug 24, 2026
21 checks passed
andinux added a commit that referenced this pull request Aug 24, 2026
llm_context_create*() decided whether the caller had asked for a context size by
comparing the parsed value against llama_context_default_params().n_ctx:

    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 training window
    }

That default is 512 (llama-context.cpp:2772), a value a caller can perfectly
well pass, so an explicit context_size=512 was indistinguishable from unset and
was silently replaced by the model's full training window:

    context_size=64   -> n_ctx=256
    context_size=512  -> n_ctx=32768     <-- asked for 512, got 64x that
    context_size=513  -> n_ctx=768

n_ctx=512 hit it too, since the check looked at the resolved value rather than
at which key was written.

The intent was right, only the detection was wrong. llama.cpp already defines
n_ctx = 0 as "use the training window", so start from that sentinel instead of
inferring it after the fact: the caller's value now always survives, and 0 keeps
its documented meaning. Also stops context_size=0 driving n_batch to 0, which
llama will not accept.

Behaviour change, and a quiet one: anyone passing context_size=512 or n_ctx=512
was getting the model's whole window and now gets 512. Nothing errors - the
context simply becomes what was asked for, so a conversation that used to fit
may now reach the limit. That is the point: silently ignoring the configuration
is the bug. #30 makes reaching the limit recoverable rather than fatal to the
chat; the remaining half of #28 - returning the partial reply plus a stop reason
instead of an error - is still open.

API.md said llm_context_create_chat() and llm_context_create_textgen() were
equivalent to context_size=4096. Their presets are empty, so both inherit the
model's training window; documented as such, along with 0 on context_size/n_ctx.

test_context_size_is_honoured covers 256/512/1024 exactly (llama pads n_ctx to a
multiple of 256), both spellings, and that omitting the key - or passing 0 -
still auto-sizes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KRojTrn4Q4SaZQpqcwpAt
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant