Keep single-shot prompts intact when max_tokens eats the context window#4124
Conversation
Prompts built in one shot (Noodle timeline refreshes, summarizers, other one-off builders) mark no message with contextKind "history", because none of their content is history. fitMessagesToContext treated that absence as permission to delete: findOldestRemovableConversationBlock skips only system messages and the final message, so the first user message — the entire prompt body — was the first thing removed. What reached the model was the system rules plus the trailing output-format instruction, with every account, character card, lorebook entry and timeline line gone, and no error raised anywhere. The trigger is an output budget large enough to squeeze the input budget below the prompt size. A connection storing max_tokens 32768 against a ~36k context window leaves roughly 3.3k tokens of input budget, so a 4k-token prompt is deleted rather than shortened. max_tokens is only reduced when the input budget falls to the 128-token floor, so it stayed at its full value while the prompt vanished. Reduce the output budget instead whenever a prompt carries no history to trim. Conversations are unaffected: they annotate their turns, so history trimming still runs first and a large valid response budget does not collapse.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@SpicyMarinara could you take a look at this one before it goes in? I'm not confident it's the right shape of fix for the engine as a whole, even though it should fix the reported bug. Two things I found after opening it: 1. The same bug is still live for regular chat. The last-resort loop at 2. This PR protects Noodle by accident rather than by intent. It keys off "this prompt contains no history at all". Noodle sets The assembler already has the right taxonomy (
I kept this PR narrow deliberately since it touches core trimming, but that also means it's a patch on a symptom. One more known edge: this change can push Heads up that I won't be available today, so please don't wait on me for this one. If you think the taxonomy fix is the right call, feel free to take it over, expand this PR in place, or close it in favour of your own — no need to check with me first. If you'd rather leave it, it's fine sitting here until I'm back. |
|
Also flagging separately: I'm gone for the day, so I won't be responding to anything here. Context for anyone picking it up while I'm out:
#4111 and #4109 stand on their own merits; neither should be merged as "the fix" for #4110. #4124 is the one that addresses it. |
Linked issue
Closes #4110
Why this change
A user reported Noodle generating posts with no character cards and no lorebook entries. Their captured request to the model explains why — the prompt body was never sent:
buildRefreshPromptreturns three messages: system rules, the context body (# Active Noodle Accounts,# Character Profiles,# World / Lore, the timeline), then the JSON-format instruction (noodle-public-prompt.service.ts:621). Only two arrived. The entire context body was deleted in transit, so the model received rules and an output schema with nothing to write about — and invented accounts (user_123,Kaelen_Storm) to fill the void. Those invented handles then failedvalidateNoodleGeneratedRefresh, so the refresh died two steps downstream with a "no usable response" error that pointed nowhere near the real problem.The deletion happens in
fitMessagesToContext:findOldestRemovableConversationBlock(base-provider.ts:332) skips system messages and protects only the final message (index < messages.length - 1). Everything else is fair game.contextKind: "history", the fallback pass at:460removes unmarked non-system messages anyway. Noodle setscontextKindnowhere, so its context body is the first thing deleted and the trailing instruction is the thing kept.max_tokensis only reduced when the input budget has already fallen to the 128-token floor (:422). Between those two regimes there is a window where the budget is too small for the prompt but too large to trigger the reduction — so the output budget keeps its full value while the prompt is thrown away.Concretely, a connection storing
max_tokens: 32768against a ~36k context window leaves ~3.3k tokens of input budget. A ~4k-token prompt exceeds it, so the body is deleted andmax_tokensstays at 32768 — matching the captured request exactly, including the 1210 prompt tokens the local backend reported.This is not Noodle-specific. Any single-shot prompt builder — summarizers, one-off generators — has no history to annotate and is exposed the same way.
Why it surfaced now (it is not a recent regression)
This looked like fallout from the recent Noodle work. It isn't — both code ingredients are old:
max_tokens—a747a0cf7(2026-05-07), "Prefer trimming old history over collapsing completion budget", which introduced the exactinputBudget <= reservedInputFloorgate this PR relaxes.max_tokensrather than its own computed budget —958e5534f(2026-07-16) and the service extraction in0029d9551(2026-07-18).Neither matches the "worked in the morning, broken by evening" report, because the third ingredient is not code — it is prompt size. The body is deleted as soon as
context tokens > usableWindow − max_tokens. Withmax_tokens: 32768against a ~36k window that threshold sits near 3.3k tokens, and the Noodle context message grows on its own with normal use: the 48h recent-post window (up to 100 posts), recalled older memories, the number of invited characters, and lorebook context all add to it. No deploy is needed to cross the line — only continued use. It also explains the intermittency the reporter saw ("I refreshed a few times"): as the 48h window rolls, the prompt shrinks and grows, so the failure can appear to resolve itself and return.This is why the bug is invisible on a typical dev setup. A connection with no stored
max_tokensoverride, or a large context window, leaves an input budget of tens of thousands of tokens and never approaches the threshold. The trap is config-shaped, not version-shaped.Worth stating plainly for planning purposes: the recent Noodle features (recalled memories, character schedules, enhanced timeline writing, lorebook context) are not buggy, but each one makes the prompt richer and therefore moves affected users closer to a threshold that already existed. That argues for the "fail loudly when the prompt cannot fit" follow-up rather than against the features.
What changed
base-provider.ts— a prompt with nocontextKind: "history"message has nothing that is safe to drop, so the output budget yields instead: the existingmax_tokensreduction now also applies when there is no removable history, before any message is deleted. The later fallback passes are untouched, so a prompt that genuinely cannot fit is still handled.contextKind: "history", so history trimming still runs first and a large valid response budget does not collapse to the floor.scripts/regressions/context-fit.regression.ts(new, wired intopnpm regression:prompt) — asserts a Noodle-shaped single-shot prompt survives a 36864/32768 squeeze with its body intact and a usable reducedmax_tokens; that a roomy window changes nothing; that an annotated conversation still trims history; and that an unconfigured context window touches nothing.Validation
pnpm checkpasses locallyCONTRIBUTING.mdManual verification notes
# Character Profilesand# World / Lorepresent.[LLM context] Trimmed prompt ...for a normal Noodle refresh on that connection.Automated, run locally:
pnpm --filter @marinara-engine/server exec tsx ../../scripts/regressions/context-fit.regression.ts— passes with this change, fails without it (verified by revertingbase-provider.ts; it fails on "no message may be dropped from a single-shot prompt").pnpm check— passes (one pre-existing unrelated client lint warning, 0 errors).Docs and release impact
docs-i18nbranch updated to match, or a[docs-i18n]follow-up issue opened (see CONTRIBUTING.md § Translated documentation)UI evidence (if applicable)