project-nomad/admin/app/utils/context_budget.ts
jakeaturner a013eb6394 fix(chat): stop capping answers at 1024 tokens and surface truncation
planPrompt() returned the response reserve as num_predict, and the
reserve was clamped by MAX_RESPONSE_RESERVE = 1024. Chat answers
stopped at 1024 output tokens regardless of window size, often
mid-sentence, and the normalized stream chunks dropped Ollama's
done_reason, so neither the UI nor API clients could discern between
a cut-off answer and a finished one.

- Decouple the generation cap from the reserve. num_predict is now the
  space left in the window after the prompt, less a 5% (min 64 token)
  margin for estimator error, with the reserve as its floor. Raise
  MAX_RESPONSE_RESERVE to 8192 so it only bounds prompt hold-back.
- Carry done_reason through all four OllamaService chat paths. Native
  forwards Ollama's done_reason; compat maps OpenAI's finish_reason.
- Log length-limited stops in OllamaController with num_predict,
  num_ctx and tokens generated.
- Show a truncation notice on cut-off answers, with a Continue button
  on the latest one that sends a normal follow-up turn.

Generation evals before and after this change are not length
comparable.

Closes #1342
2026-09-29 15:54:41 -07:00

322 lines
12 KiB
TypeScript

/**
* Decides what actually goes into the prompt, and what gets left out.
*
* Before this existed, nothing trimmed anything: the browser posted the whole
* session, the pipeline copied it verbatim, and overflow was left to the
* backend. That is the worst possible arrangement, because backend truncation is
* *silent* and *positional* — llama.cpp drops from the middle of the window, so
* what gets discarded is conversation history and retrieved knowledge-base
* context, while the model gives no indication anything is missing. Measured on
* a live Ollama, a 7,000-token prompt came back having processed 2,060 tokens,
* and the model confabulated an answer from the fragment that survived.
*
* So the rule here is: never hand the backend more than it can hold. Decide
* explicitly what to drop, drop it in a way the model can reason about, and
* record what happened so the eval harness can see it.
*
* Allocation order, most protected first:
* 1. Response reserve — a floor held back for the answer itself. The actual
* generation cap is whatever the window has left once
* the prompt is assembled, which is usually far more.
* 2. Fixed blocks — system prompts and the current question. Never dropped.
* 3. Retrieved context — whole chunks, best-first, until its share is spent.
* 4. History — whole turns, newest-first, into whatever remains.
*
* Pure and dependency-free so it runs under bare `node --test`, the same
* convention as `rag_prompt.ts`.
*/
import { estimateMessagesTokens, estimateTokens } from './token_estimate.js'
export type BudgetRole = 'system' | 'user' | 'assistant'
export type BudgetMessage = { role: BudgetRole; content: string }
/** A retrieved chunk, already ranked — index 0 is the best match. */
export type BudgetChunk = { text: string; metadata?: Record<string, any> }
/**
* Where the per-turn retrieved-context block goes.
*
* `tail` places it immediately before the current question, leaving
* [system][history] as a byte-identical prefix across turns so the backend can
* reuse its KV cache. `front` is the historical behaviour: a system message
* ahead of all history, which changes content every turn and therefore
* invalidates the cached prefix for the entire conversation behind it.
*
* The common advice to "inject RAG into the system prompt" assumes the context
* is fixed for the session (an attached document), where a fixed position does
* preserve the prefix. When context is re-retrieved every turn, the opposite is
* true. Both are kept so `eval:generation` can measure the difference rather
* than the choice resting on argument alone.
*/
export type RagPlacement = 'tail' | 'front'
export type BudgetInputs = {
/** Stable system blocks (NOMAD.md, formatting rules), in final order. */
systemBlocks: BudgetMessage[]
/** Prior conversation, oldest first, excluding the current question. */
history: BudgetMessage[]
/** The current user message. Never dropped. */
query: BudgetMessage
/** Retrieved chunks, best first. May be empty. */
ragChunks: BudgetChunk[]
/** Renders surviving chunks into the final context message. */
renderRagBlock: (chunks: BudgetChunk[]) => string
/** The resolved context window, in tokens. */
contextWindow: number
/** Learned per-model token-estimator correction. */
ratio?: number
ragPlacement?: RagPlacement
/** Overrides for the response reserve and RAG share; mostly for tests. */
responseReserve?: number
ragShare?: number
}
export type BudgetTrace = {
contextWindow: number
responseReserve: number
promptBudget: number
estimatedPromptTokens: number
systemTokens: number
queryTokens: number
ragTokens: number
historyTokens: number
chunksKept: number
chunksDropped: number
turnsKept: number
turnsDropped: number
historyElided: boolean
queryTruncated: boolean
ragPlacement: RagPlacement
numPredict: number
}
export type BudgetResult = {
messages: BudgetMessage[]
trace: BudgetTrace
/**
* What to send as num_predict: the space the window actually has left after
* the prompt, less a margin for estimator error. Never below the reserve.
*/
numPredict: number
}
/**
* Ceiling on tokens reserved for the answer.
*
* This only bounds how much prompt space is held back; it is not the
* generation cap. It used to double as one, which pinned every answer at 1024
* tokens regardless of window size and cut long answers off mid-sentence
* (#1342).
*/
export const MAX_RESPONSE_RESERVE = 8192
/** Share of the window reserved for the answer when that is smaller. */
export const RESPONSE_RESERVE_FRACTION = 0.25
/**
* Headroom left between the estimated prompt and the end of the window when
* sizing num_predict. The estimator is calibrated per model but still only an
* estimate; undershooting here means llama.cpp context-shifts mid-answer.
*/
export const NUM_PREDICT_SAFETY_FRACTION = 0.05
export const NUM_PREDICT_SAFETY_MIN = 64
/** Share of the remaining budget the retrieved context may claim. */
export const DEFAULT_RAG_SHARE = 0.35
/**
* Marker inserted where turns were dropped.
*
* Worth the handful of tokens: without it the model sees a conversation that
* appears to begin mid-thought and has no way to tell that it is missing
* context, which is exactly when small models start confidently inventing what
* "we discussed earlier".
*/
export const ELISION_MARKER =
'[Earlier messages in this conversation have been omitted to fit the context window.]'
/**
* Turns are dropped in blocks rather than one at a time.
*
* Dropping a single turn per request would shift the prompt prefix on *every*
* subsequent turn, invalidating the backend's KV cache each time — trading a
* small saving in tokens for a full re-prefill. Dropping several at once means
* the prefix then stays stable for several turns.
*/
export const HISTORY_EVICTION_BLOCK = 4
/**
* Group a flat message list into whole turns.
*
* A turn is a user message plus everything that answers it. Truncating on a
* message boundary instead can leave the transcript starting with an assistant
* reply to a question the model can no longer see — which reads, to the model,
* as though it said something unprompted.
*/
export function groupIntoTurns(history: BudgetMessage[]): BudgetMessage[][] {
const turns: BudgetMessage[][] = []
for (const message of history) {
if (message.role === 'user' || turns.length === 0) {
turns.push([message])
} else {
turns[turns.length - 1].push(message)
}
}
return turns
}
/**
* Plan the prompt.
*
* Never returns a message array estimated to exceed `contextWindow -
* responseReserve`, except in the degenerate case where the fixed blocks alone
* are too large — and in that case the query is truncated visibly rather than
* being handed to the backend to cut silently.
*/
export function planPrompt(inputs: BudgetInputs): BudgetResult {
const ratio = inputs.ratio ?? 1
const placement = inputs.ragPlacement ?? 'tail'
const contextWindow = inputs.contextWindow
const responseReserve =
inputs.responseReserve ??
Math.max(256, Math.min(MAX_RESPONSE_RESERVE, Math.floor(contextWindow * RESPONSE_RESERVE_FRACTION)))
const promptBudget = Math.max(0, contextWindow - responseReserve)
const cost = (messages: BudgetMessage[]) => estimateMessagesTokens(messages, ratio)
// --- Fixed blocks: system prompts and the question --------------------
const systemTokens = cost(inputs.systemBlocks)
let query = inputs.query
let queryTokens = cost([query])
let queryTruncated = false
// Degenerate case: the system prompts plus the question alone overflow the
// window. Truncating here is a bad outcome, but it is a *visible* one — the
// alternative is the backend silently cutting the question in half.
if (systemTokens + queryTokens > promptBudget) {
const available = Math.max(0, promptBudget - systemTokens)
if (available > 0) {
const perToken = query.content.length / Math.max(1, estimateTokens(query.content, ratio))
const keepChars = Math.max(0, Math.floor(available * perToken) - ELISION_MARKER.length)
query = {
...query,
content:
query.content.slice(0, keepChars) +
'\n\n[This message was truncated because it exceeds the context window.]',
}
queryTokens = cost([query])
queryTruncated = true
}
}
let remaining = promptBudget - systemTokens - queryTokens
// --- Retrieved context: whole chunks, best first ----------------------
//
// Whole chunks, deliberately. The previous behaviour capped on a running
// character count, which could hand the model a chunk cut off mid-sentence —
// the worst of both worlds, since it costs tokens without carrying a complete
// fact. A chunk that doesn't fit is dropped, not shortened.
const ragBudget = Math.max(0, Math.floor(Math.max(0, remaining) * (inputs.ragShare ?? DEFAULT_RAG_SHARE)))
const keptChunks: BudgetChunk[] = []
let ragTokens = 0
if (inputs.ragChunks.length > 0 && ragBudget > 0) {
for (const chunk of inputs.ragChunks) {
const candidate = [...keptChunks, chunk]
const rendered = inputs.renderRagBlock(candidate)
const candidateTokens = cost([{ role: 'system', content: rendered }])
if (candidateTokens <= ragBudget) {
keptChunks.push(chunk)
ragTokens = candidateTokens
}
// Keep scanning: a later, smaller chunk may still fit where this one didn't.
}
}
const ragMessage: BudgetMessage | null =
keptChunks.length > 0
? { role: 'system', content: inputs.renderRagBlock(keptChunks) }
: null
remaining -= ragTokens
// --- History: whole turns, newest first -------------------------------
const turns = groupIntoTurns(inputs.history)
const keptTurns: BudgetMessage[][] = []
let historyTokens = 0
for (let i = turns.length - 1; i >= 0; i--) {
const turnTokens = cost(turns[i])
if (historyTokens + turnTokens > remaining) break
keptTurns.unshift(turns[i])
historyTokens += turnTokens
}
const turnsDropped = turns.length - keptTurns.length
// Chunky eviction: once we're dropping anything, drop a whole block, so the
// surviving prefix stays put for the next several turns instead of shifting
// on every request and forcing a re-prefill each time.
if (turnsDropped > 0 && keptTurns.length > 0) {
const overshoot = turnsDropped % HISTORY_EVICTION_BLOCK
const extra = overshoot === 0 ? 0 : HISTORY_EVICTION_BLOCK - overshoot
for (let i = 0; i < extra && keptTurns.length > 1; i++) {
const removed = keptTurns.shift()!
historyTokens -= cost(removed)
}
}
const historyElided = keptTurns.length < turns.length
const historyMessages = keptTurns.flat()
// --- Assemble, stability-descending -----------------------------------
const messages: BudgetMessage[] = []
messages.push(...inputs.systemBlocks)
if (historyElided) {
messages.push({ role: 'system', content: ELISION_MARKER })
}
if (placement === 'front' && ragMessage) {
// Historical ordering, kept only so the two can be compared under eval.
messages.splice(inputs.systemBlocks.length, 0, ragMessage)
messages.push(...historyMessages)
} else {
messages.push(...historyMessages)
if (ragMessage) messages.push(ragMessage)
}
messages.push(query)
const estimatedPromptTokens = cost(messages)
// The planner already kept the prompt within `contextWindow - responseReserve`,
// so the reserve floor can never push generation past the window.
const safetyMargin = Math.max(
NUM_PREDICT_SAFETY_MIN,
Math.ceil(estimatedPromptTokens * NUM_PREDICT_SAFETY_FRACTION)
)
const numPredict = Math.max(responseReserve, contextWindow - estimatedPromptTokens - safetyMargin)
return {
messages,
numPredict,
trace: {
contextWindow,
responseReserve,
promptBudget,
estimatedPromptTokens,
systemTokens,
queryTokens,
ragTokens,
historyTokens,
chunksKept: keptChunks.length,
chunksDropped: inputs.ragChunks.length - keptChunks.length,
turnsKept: keptTurns.length,
turnsDropped: turns.length - keptTurns.length,
historyElided,
queryTruncated,
ragPlacement: placement,
numPredict,
},
}
}