Skip to content

Guard prompt tokens against context window overflow#113

Merged
FuJacob merged 5 commits into
mainfrom
fix/guard-prompt-context-overflow
May 21, 2026
Merged

Guard prompt tokens against context window overflow#113
FuJacob merged 5 commits into
mainfrom
fix/guard-prompt-context-overflow

Conversation

@FuJacob

@FuJacob FuJacob commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • Chunked prompt decoding: decodePrompt was feeding all prompt tokens to llama_decode in a single call. When prompts exceeded the 512-token batch limit (n_batch/n_ubatch), ggml hit an out-of-bounds tensor access and abort()ed. Now decodes in chunks of batchCapacity.
  • Context window guard: Caps prompt tokens to contextWindowTokens − maxPredictionTokens before decoding, trimming from the front to preserve the tail closest to the caret. Applies to both generate() and summarize().
  • Edge-case clamp: maxPromptTokens is clamped to at least 1 so a pathological maxPredictionTokens >= contextWindowTokens doesn't crash Array.suffix or leave the KV cache empty.

Context

When visual context (OCR up to 2000 chars), clipboard (1200 chars), and prefix text (1000 chars) combine into a large prompt, two things go wrong:

  1. Prompts over 512 tokens overflow the batch size → ggml_abort (the primary crash)
  2. Prompts over 2048 tokens overflow the context window → ggml_abort

Xcode windows with dense code are a reliable trigger due to high OCR token density.

Test plan

  • Build succeeds (xcodebuild -scheme tabby build)
  • Open Xcode with a large file, copy a big block to clipboard, trigger suggestion — no crash
  • Verify suggestions still work normally for short prompts
  • Greptile P1 feedback addressed (max(1, ...) clamp)

🤖 Generated with Claude Code

Greptile Summary

This PR addresses a hard crash caused by llama_decode() receiving more tokens than the context window allows when visual context, clipboard, and prefix text combine to overflow the 2048-token limit. It also introduces a shared PromptContextSanitizer to strip prompt-injection artifacts (ANSI escapes, separators, shell syntax) from clipboard and OCR inputs before they reach the prompt.

  • Adds a max(1, contextWindowTokens − maxPredictionTokens) front-trim guard in both generate() and summarize(), keeping the tail of the prompt closest to the caret.
  • Refactors decodePrompt to process tokens in batchCapacity-sized chunks, preventing llama_decode from exceeding n_batch/n_ubatch on a single call.
  • Introduces PromptContextSanitizer as a pure Support/ helper and wires it into ClipboardContextProvider, ScreenshotContextGenerator, and SuggestionRequestFactory; includes two new factory tests covering ANSI and punctuation stripping.

Confidence Score: 3/5

The crash fix and batch-chunking refactor are sound, but a cache-consistency gap in the trim path needs to be closed before merging.

When a front-trim fires, rebuildPromptContext stores the full original promptBytes alongside the trimmed promptTokens in promptCache. On any subsequent request that no longer overflows, preparePromptContext computes a large confirmedCommonBytes (full-bytes comparison succeeds), then falls through to a token-level commonPrefixCount between the stored trimmed-suffix tokens and the new full tokens. If even one token matches at position 0, reusableTokenCount > 0 and KV cache entries built from the trimmed-suffix are treated as valid prefix state for the new full prompt, producing incorrect completions.

tabby/Services/Runtime/LlamaRuntimeCore.swift — specifically the PromptContextRequest construction in the trim branch and how rebuildPromptContext stores that value in promptCache.

Important Files Changed

Filename Overview
tabby/Services/Runtime/LlamaRuntimeCore.swift Adds front-trim guard for both generate() and summarize() with max(1, …) clamping; refactors decodePrompt to chunk batches correctly — but stores full promptBytes alongside trimmed promptTokens in the cache after a trim, which can mislead subsequent KV cache comparisons.
tabby/Support/PromptContextSanitizer.swift New pure helper that strips ANSI escapes, non-alphanumeric scalars (except @ and .), collapses whitespace, and optionally caps length; idempotent and well-tested.
tabby/Support/SuggestionRequestFactory.swift Adds activeVisualContextSummary helper mirroring activeClipboardContext, both now routing through PromptContextSanitizer; visual context still lacks a character cap at this layer (relying on ScreenshotContextGenerator's boundedSummaryText upstream).
tabby/Services/Utilities/ClipboardContextProvider.swift Replaces simple whitespace trim with full PromptContextSanitizer.sanitize; functionally correct but creates redundant dual-sanitization since SuggestionRequestFactory also sanitizes the same string downstream.
tabby/Services/Visual/ScreenshotContextGenerator.swift Delegates normalizeRecognizedText and boundedSummaryText to PromptContextSanitizer; adds hasMeaningfulSignal guard after boundedSummaryText for defense against low-quality summarizer output.
tabbyTests/SuggestionRequestFactoryTests.swift Adds two new sanitization tests covering ANSI escape stripping for visual context and punctuation/special-char stripping for clipboard context; assertions are precise and correct.
tabby.xcodeproj/project.pbxproj Restores the Release build's Sparkle Ed25519 public key from the placeholder value.

Fix All in Codex Fix All in Claude Code

Reviews (2): Last reviewed commit: "Clamp maxPromptTokens to at least 1" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

FuJacob and others added 4 commits May 20, 2026 18:44
When visual context, clipboard, and prefix text combine to exceed the 2048-token
context window, llama_decode() hits an out-of-bounds tensor access and ggml_abort
crashes the app. This is easily triggered in Xcode where dense code fills the OCR
capture.

Both generate() and summarize() now cap prompt tokens to
contextWindowTokens − maxPredictionTokens, trimming from the front to preserve
the tail closest to the caret. Front-trimming also invalidates the KV prefix
cache since the byte-level overlap no longer holds.

Also fixes the Release build's Sparkle public key placeholder that prevented
update checks from working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
decodePrompt was feeding all prompt tokens to llama_decode in a single call.
When the prompt exceeded 512 tokens (batchCapacity / n_batch), ggml hit an
out-of-bounds tensor access and aborted. This is the actual crash trigger —
the context window guard alone wasn't sufficient since 512 < 2048.

Now decodes in chunks of batchCapacity, only requesting logits on the final
token of the last chunk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread tabby/Services/Runtime/LlamaRuntimeCore.swift Outdated
Comment thread tabby/Services/Runtime/LlamaRuntimeCore.swift
Comment on lines +139 to 152
private static func activeVisualContextSummary(rawSummary: String?) -> String? {
guard let rawSummary else {
return nil
}

let sanitizedSummary = PromptContextSanitizer.sanitize(rawSummary)
guard !sanitizedSummary.isEmpty,
PromptContextSanitizer.containsAlphanumericSignal(sanitizedSummary)
else {
return nil
}

return clippedText(trimmed, maxCharacters: maxClipboardContextCharacters)
return sanitizedSummary
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 activeClipboardContext caps the returned string via clippedText(sanitizedContext, maxCharacters: maxClipboardContextCharacters), but activeVisualContextSummary returns the sanitized summary without any character limit. If a caller ever passes a summary that bypasses ScreenshotContextGenerator's boundedSummaryText, the factory layer applies no cap, making the token guard in LlamaRuntimeCore the only safety net. Adding a character cap here would make the two context inputs symmetric.

Suggested change
private static func activeVisualContextSummary(rawSummary: String?) -> String? {
guard let rawSummary else {
return nil
}
let sanitizedSummary = PromptContextSanitizer.sanitize(rawSummary)
guard !sanitizedSummary.isEmpty,
PromptContextSanitizer.containsAlphanumericSignal(sanitizedSummary)
else {
return nil
}
return clippedText(trimmed, maxCharacters: maxClipboardContextCharacters)
return sanitizedSummary
}
private static let maxVisualContextSummaryCharacters = 2_000
private static func activeVisualContextSummary(rawSummary: String?) -> String? {
guard let rawSummary else {
return nil
}
let sanitizedSummary = PromptContextSanitizer.sanitize(rawSummary)
guard !sanitizedSummary.isEmpty,
PromptContextSanitizer.containsAlphanumericSignal(sanitizedSummary)
else {
return nil
}
return clippedText(sanitizedSummary, maxCharacters: maxVisualContextSummaryCharacters)
}

Fix in Codex Fix in Claude Code

If maxPredictionTokens ever equals or exceeds contextWindowTokens, the
subtraction goes zero or negative. Array.suffix with a negative value
crashes, and suffix(0) leaves the KV cache empty for sampling. Addresses
Greptile review feedback on both generate() and summarize().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FuJacob FuJacob merged commit 2afd13b into main May 21, 2026
3 checks passed
Comment on lines +163 to 169
let promptBytes = Array(prompt.utf8)
let contextRequest = PromptContextRequest(
promptBytes: Array(prompt.utf8),
promptBytes: promptBytes,
promptTokens: promptTokens,
samplingFingerprint: SamplingFingerprint(options: options),
cachedPrefixBytes: cachedPrefixBytes
cachedPrefixBytes: adjustedCachedPrefixBytes
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale promptBytes stored in cache after a front-trim

When front-trimming fires, promptTokens is the suffix of allPromptTokens, but promptBytes is still set to the full Array(prompt.utf8). Both values are stored together in promptCache via rebuildPromptContext. On a subsequent request that doesn't overflow (e.g., the user deletes text), preparePromptContext computes a large confirmedCommonBytes from the full-bytes comparison, then falls through to a token-level commonPrefixCount between the stored trimmed-suffix tokens and the new full tokens. If even one token matches at position 0 (possible with shared prompt-template prefixes), reusableTokenCount > 0 and KV cache entries built from the trimmed-suffix tokens are treated as valid prefix state for the new full prompt, producing incorrect completions. The fix is to store an empty (or sentinel) promptBytes in the trim branch so no byte-prefix match can grant cache access.

Fix in Codex Fix in Claude Code

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