[Backend] Extend truncation rejection and token budgeting to the prose paths #338

Closed
opened 2026-08-25 20:38:57 +00:00 by claude-bot · 2 comments
Contributor

Severity: HIGH. Found in the August 2026 session lifecycle review (#319).

The defect

Three related gaps, all on the prose (non-JSON) paths that #293 never covered:

  1. Thinking is unbudgeted. chat_template_kwargs: {"enable_thinking": False} is set only inside if json_mode: (llm_service.py:232-241). _summarise_llamacpp and _qa_llamacpp build their own bodies with neither that flag nor any max_tokens. A reasoning model therefore spends unbounded tokens thinking inside a slot the prompt has already nearly filled — generation hits the ceiling mid-summary, finish_reason=length is logged, and the clipped prose is saved as the session summary. A summary cut off mid-session reads exactly like "the timeline is wrong".
  2. Output caps unchecked. _summarise_anthropic and _qa_anthropic cap output at max_tokens: 1024 (audio_service.py:2148, :2289) with no stop_reason check. A 3-6 paragraph summary is 400-900 tokens, so this clips occasionally and silently. _summarise_openai (:2174) is the same with no finish_reason check.
  3. generate_structured_text(..., json_mode=False) callers — merge_lore_entry_body, rephrase, promote, expand_backstory — all run with thinking enabled for the same reason.

Proposed fix

Extend _reject_if_truncated to every prose path. Set explicit max_tokens on llama.cpp prose calls. Disable thinking on prose paths, or budget it explicitly and account for it in the preflight estimate. Raise the Anthropic/OpenAI prose caps well above 1024 and check stop_reason/finish_reason on every response.

Acceptance criteria

  • Thinking is disabled or explicitly budgeted on all prose paths, not just json-mode
  • max_tokens set explicitly on llama.cpp prose calls
  • Anthropic and OpenAI prose caps raised and stop_reason checked
  • _reject_if_truncated covers prose responses
  • Tests assert a truncated prose response raises rather than being persisted
**Severity: HIGH.** Found in the August 2026 session lifecycle review (#319). ## The defect Three related gaps, all on the prose (non-JSON) paths that #293 never covered: 1. **Thinking is unbudgeted.** `chat_template_kwargs: {"enable_thinking": False}` is set **only inside `if json_mode:`** (`llm_service.py:232-241`). `_summarise_llamacpp` and `_qa_llamacpp` build their own bodies with neither that flag nor any `max_tokens`. A reasoning model therefore spends unbounded tokens thinking inside a slot the prompt has already nearly filled — generation hits the ceiling mid-summary, `finish_reason=length` is logged, and the clipped prose is saved as the session summary. A summary cut off mid-session reads exactly like "the timeline is wrong". 2. **Output caps unchecked.** `_summarise_anthropic` and `_qa_anthropic` cap output at `max_tokens: 1024` (`audio_service.py:2148`, `:2289`) with no `stop_reason` check. A 3-6 paragraph summary is 400-900 tokens, so this clips occasionally and silently. `_summarise_openai` (`:2174`) is the same with no `finish_reason` check. 3. `generate_structured_text(..., json_mode=False)` callers — `merge_lore_entry_body`, `rephrase`, `promote`, `expand_backstory` — all run with thinking enabled for the same reason. ## Proposed fix Extend `_reject_if_truncated` to every prose path. Set explicit `max_tokens` on llama.cpp prose calls. Disable thinking on prose paths, or budget it explicitly and account for it in the preflight estimate. Raise the Anthropic/OpenAI prose caps well above 1024 and check `stop_reason`/`finish_reason` on every response. ## Acceptance criteria - [ ] Thinking is disabled or explicitly budgeted on all prose paths, not just json-mode - [ ] `max_tokens` set explicitly on llama.cpp prose calls - [ ] Anthropic and OpenAI prose caps raised and `stop_reason` checked - [ ] `_reject_if_truncated` covers prose responses - [ ] Tests assert a truncated prose response raises rather than being persisted
Author
Contributor

Decision recorded, 2026-08-28: truncated prose is returned-but-flagged, not raised.

This issue's last acceptance criterion asks for a test that "a truncated prose response raises rather than being persisted". The shipped code does the opposite — _reject_if_truncated raises only under json_mode (llm_service.py:243-248), and test_llamacpp_truncated_prose_is_returned asserts a truncated prose response comes back. The code and the issue have been contradicting each other since this landed.

Ryan's call: return-but-flag is right. Half a summary a GM can read and correct beats no summary at all, which is what raising would produce. Structured output is different and keeps raising — a partial JSON object cannot be trusted or repaired, whereas partial prose is merely short.

So the criterion is amended rather than the code:

  • Tests assert a truncated prose response raises rather than being persisted
  • A truncated prose response is returned, and the truncation is recorded where someone can see it — not swallowed
  • The distinction between prose (flag) and structured output (raise) is stated in _reject_if_truncated so the next reader does not "fix" one to match the other

The second point is the actual remaining work. Today a truncated prose response is returned with nothing marking it — _warn_if_prompt_truncated logs, but the prose truncation path does not, so a summary that stops mid-sentence is indistinguishable from one that ended there. Flagging is what makes "return" defensible rather than merely lenient.

Related: #426 covers the /ask half of this issue's scope, which was never touched.

**Decision recorded, 2026-08-28: truncated prose is returned-but-flagged, not raised.** This issue's last acceptance criterion asks for a test that *"a truncated prose response raises rather than being persisted"*. The shipped code does the opposite — `_reject_if_truncated` raises only under `json_mode` (`llm_service.py:243-248`), and `test_llamacpp_truncated_prose_is_returned` asserts a truncated prose response comes back. The code and the issue have been contradicting each other since this landed. Ryan's call: **return-but-flag is right.** Half a summary a GM can read and correct beats no summary at all, which is what raising would produce. Structured output is different and keeps raising — a partial JSON object cannot be trusted or repaired, whereas partial prose is merely short. So the criterion is amended rather than the code: - ~~Tests assert a truncated prose response raises rather than being persisted~~ - [ ] A truncated prose response is **returned**, and the truncation is recorded where someone can see it — not swallowed - [ ] The distinction between prose (flag) and structured output (raise) is stated in `_reject_if_truncated` so the next reader does not "fix" one to match the other The second point is the actual remaining work. Today a truncated prose response is returned with *nothing* marking it — `_warn_if_prompt_truncated` logs, but the prose truncation path does not, so a summary that stops mid-sentence is indistinguishable from one that ended there. Flagging is what makes "return" defensible rather than merely lenient. Related: #426 covers the `/ask` half of this issue's scope, which was never touched.
Author
Contributor

Done in 65eebb4, against the amended criteria from the decision comment above.

Correction to my own premise in that comment

I wrote that "_warn_if_prompt_truncated logs, but the prose truncation path does not". That is wrong — _reject_if_truncated has logged a warning for prose since #293 (f3264d9), before the if json_mode: raise. The gap is narrower than stated but still real: it was only a log line, so nothing reached the GM or the persisted run.

Acceptance criteria

  • Thinking is disabled or explicitly budgeted on all prose pathschat_template_kwargs on _summarise_llamacpp.
  • max_tokens set explicitly on llama.cpp prose calls_PROSE_MAX_TOKENS, and now on Ollama too (see below).
  • Anthropic and OpenAI prose caps raised and stop_reason checked — both at _PROSE_MAX_TOKENS with the check.
  • _reject_if_truncated covers prose responses — it now genuinely does; one provider was missing (below).
  • A truncated prose response is returned, and the truncation is recorded where someone can see itsummarisation_runs.truncated, migration e6f7a8b0c1d2, surfaced through #333's API in session_beat_service.py:107.
  • The prose/structured distinction is stated in _reject_if_truncatedllm_service.py:240-280, with your reasoning and an explicit warning not to make the two halves consistent.

_summarise_ollama checked nothing at all

Worth flagging because it undercuts the issue's framing that this was about raising the caps. The other three prose providers called _reject_if_truncated; Ollama did not call it at all, and sent no output cap either — so it took whatever num_predict default the operator's version happens to have and returned a clipped summary with no signal whatsoever. On a self-hosted-first product that is the backend most likely to be doing it.

Both fixed. options is now merged rather than assigned so num_ctx and num_predict coexist — the same bug test_ollama_greedy_decoding_does_not_clobber_num_ctx already pins down on the structured path, which would have silently eaten one of the two.

How the flag travels

_reject_if_truncated returns whether it fired; _dispatch_prose threads a SummarisationRecord to every provider function. Any prose call in a run sets it — single-shot, a window note, the reduce step, the compose step. Not per-call by design: a GM asking "is what I am reading cut short" does not care which call it happened in, and the map step is the easiest to lose because its output is consumed by the reduce step rather than shown.

_persist_summarisation_run previously returned early whenever a run had no beats, so a prose-fallback run persisted nothing — meaning the exact runs most likely to be truncated were the ones that could not record it. It now writes a row when there are beats or a truncation. The general rule is unchanged: an empty row otherwise reads as "this run found no events".

On the tests

The persistence tests build a record by hand, so they would keep passing if nothing ever set the flag — the failure mode you have been bitten by three times. Three more tests in test_summarise_chunking patch the HTTP layer and drive a real summarise(), including one that truncates only the first window's note so the reduce step completes normally and nothing downstream looks wrong.

Verified by mutation rather than assumption: neutering the record threading inside _dispatch_prose fails both truncation tests and leaves the negative control passing.

Migration applied and rolled back against a real database; the full chain applies cleanly from empty to e6f7a8b0c1d2.

/ask untouched — #426.

1,286 → 1,296 passing, lint clean.

**Done in `65eebb4`**, against the amended criteria from the decision comment above. ## Correction to my own premise in that comment I wrote that *"`_warn_if_prompt_truncated` logs, but the prose truncation path does not"*. That is wrong — `_reject_if_truncated` has logged a warning for prose since #293 (`f3264d9`), before the `if json_mode: raise`. The gap is narrower than stated but still real: it was *only* a log line, so nothing reached the GM or the persisted run. ## Acceptance criteria - [x] **Thinking is disabled or explicitly budgeted on all prose paths** — `chat_template_kwargs` on [`_summarise_llamacpp`](webapp/backend/app/services/audio_service.py#L4024). - [x] **`max_tokens` set explicitly on llama.cpp prose calls** — `_PROSE_MAX_TOKENS`, and now on Ollama too (see below). - [x] **Anthropic and OpenAI prose caps raised and `stop_reason` checked** — both at `_PROSE_MAX_TOKENS` with the check. - [x] **`_reject_if_truncated` covers prose responses** — it now genuinely does; one provider was missing (below). - [x] **A truncated prose response is returned, and the truncation is recorded where someone can see it** — `summarisation_runs.truncated`, migration `e6f7a8b0c1d2`, surfaced through #333's API in [session_beat_service.py:107](webapp/backend/app/services/session_beat_service.py#L107). - [x] **The prose/structured distinction is stated in `_reject_if_truncated`** — [llm_service.py:240-280](webapp/backend/app/services/llm_service.py#L240-L280), with your reasoning and an explicit warning not to make the two halves consistent. ## `_summarise_ollama` checked nothing at all Worth flagging because it undercuts the issue's framing that this was about *raising the caps*. The other three prose providers called `_reject_if_truncated`; **Ollama did not call it at all**, and sent no output cap either — so it took whatever `num_predict` default the operator's version happens to have and returned a clipped summary with no signal whatsoever. On a self-hosted-first product that is the backend most likely to be doing it. Both fixed. `options` is now merged rather than assigned so `num_ctx` and `num_predict` coexist — the same bug `test_ollama_greedy_decoding_does_not_clobber_num_ctx` already pins down on the structured path, which would have silently eaten one of the two. ## How the flag travels `_reject_if_truncated` returns whether it fired; `_dispatch_prose` threads a `SummarisationRecord` to every provider function. **Any** prose call in a run sets it — single-shot, a window note, the reduce step, the compose step. Not per-call by design: a GM asking "is what I am reading cut short" does not care which call it happened in, and the map step is the easiest to lose because its output is consumed by the reduce step rather than shown. `_persist_summarisation_run` previously returned early whenever a run had no beats, so a **prose-fallback run persisted nothing** — meaning the exact runs most likely to be truncated were the ones that could not record it. It now writes a row when there are beats *or* a truncation. The general rule is unchanged: an empty row otherwise reads as "this run found no events". ## On the tests The persistence tests build a record by hand, so they would keep passing if nothing ever *set* the flag — the failure mode you have been bitten by three times. Three more tests in `test_summarise_chunking` patch the HTTP layer and drive a real `summarise()`, including one that truncates only the first window's note so the reduce step completes normally and nothing downstream looks wrong. Verified by mutation rather than assumption: neutering the record threading inside `_dispatch_prose` fails both truncation tests and leaves the negative control passing. Migration applied and rolled back against a real database; the full chain applies cleanly from empty to `e6f7a8b0c1d2`. `/ask` untouched — #426. 1,286 → 1,296 passing, lint clean.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#338
No description provided.