fix(context): cap summary output at model limit; safe token upper bound

- Clamp the summary request's max_tokens to the model's output limit so a
  large STRIX_CONTEXT_SUMMARY_TOKENS can't get the request rejected (which
  left the overflowing session uncompacted). Applied consistently to the
  input-budget reservation and the request itself.
- Replace the tokenizer-unavailable fallback with the UTF-8 byte length, a
  guaranteed upper bound on tokens for byte-level BPE, so budget checks can
  never under-count dense history.
This commit is contained in:
Ahmed Allam
2026-07-26 00:15:49 +00:00
committed by Devin AI
parent 2163a66b78
commit 4eedfc64b4
4 changed files with 35 additions and 11 deletions
+12 -3
View File
@@ -192,9 +192,18 @@ def _fit_to_tokens(model: str, text: str, max_tokens: int) -> str:
return candidate
def _summary_output_tokens(model: str) -> int:
"""Summary output allowance, capped at the model's own output limit.
A configured ``summary_max_tokens`` above the model's cap would make the
provider reject the summary request, so compaction would silently fail and
leave the overflowing session unchanged.
"""
return min(load_settings().context.summary_max_tokens, output_limit(model))
def _summary_input_budget(model: str, previous: str | None) -> int:
"""Token room left for the head after instructions and the summary output."""
context = load_settings().context
overhead = count_tokens(model, _SUMMARY_INSTRUCTIONS)
if previous:
overhead += count_tokens(model, previous)
@@ -202,7 +211,7 @@ def _summary_input_budget(model: str, previous: str | None) -> int:
# the update instructions, etc.) that is not part of ``overhead``. Never
# floor above the actual room: doing so would let the summary request
# itself overflow a small window (and then compaction silently fails).
room = context_window(model) - context.summary_max_tokens - overhead - 256
room = context_window(model) - _summary_output_tokens(model) - overhead - 256
return max(0, room)
@@ -300,7 +309,7 @@ async def maybe_compact(
summary = await _summarize(
model,
_build_summary_prompt(serialized_head, previous),
context.summary_max_tokens,
_summary_output_tokens(model),
)
if summary is None:
return False
+6 -5
View File
@@ -71,10 +71,11 @@ def count_tokens(model: str, text: str) -> int:
"""Token count for ``text`` under ``model``.
LiteLLM's counter handles known tokenizers (and defaults to a tiktoken
encoding otherwise). If it still can't count, fall back to a *conservative*
estimate: token density varies, and dense text (code, base64, CJK) can run
well under 4 chars/token, so we assume ~3 to over-estimate rather than
under-estimate — an under-estimate would let a summary request be packed
encoding otherwise). If it still can't count, fall back to the UTF-8 byte
length as a guaranteed upper bound: byte-level BPE tokenizers (used by every
major provider) emit at least one byte per token, so token count can never
exceed the byte count. Over-counting is safe here — it makes budget checks
conservative — whereas any under-count could let a summary request be packed
past the real context window and get rejected.
"""
if not text:
@@ -82,4 +83,4 @@ def count_tokens(model: str, text: str) -> int:
try:
return int(litellm.token_counter(model=_lookup_key(model), text=text))
except Exception: # noqa: BLE001 - tokenizer may be unavailable for some models.
return -(-len(text) // 3)
return len(text.encode("utf-8"))