Skip to content

Context budget: stop estimating fit; the provider decides, the Maka window is a user target #4558

Description

@Joob1n
English

Follow-up to #4458 and #4486. #4486 removed the fabricated capacity and the two terminal gates. This issue takes the remaining step: the runtime stops estimating context fit altogether. Every "does it fit" question is answered by a provider, every trigger by a real number.

I plan to implement this as a single PR. The design was discussed on #4458 with @Astro-Han and @likun666661; the one contested point is called out at the end.

Principles

  • Fit is the provider's answer. Maka neither estimates, nor judges, nor terminates. The only irreversible outcome is the provider's own rejection.
  • The Maka window is a target, not a limit. It is the compaction trigger the user configures. If the user has not set one, there is none.
  • Real numbers only. Maka uses provider-reported usage and user-declared settings. charsPerToken takes part in no decision.
  • Reversible first. Maka's only proactive actions are a fold (reversible) and a hint.
  • Maka does not decide for the user. It surfaces facts (the model's declared window, the last accepted usage); they take effect only when the user applies them.

Three windows

Source Role When unset / unknown
Maka window User setting, the only source Proactive fold trigger No threshold, no proactive fold
Metadata window Provider /models or models.dev A hint next to the setting, one click to apply No hint
Provider window Unknown to Maka Hard limit Acts through rejection / truncated reply / eviction

What "new content" is

Everything appended between two provider requests, not only the user's message:

produced by the model   previous step's text, tool_call arguments, reasoning (wire-dependent)
                        size known: previous usage.outputTokens
from outside the model  tool results, user message, images, steering, tool-schema changes
                        size unknown; text tool results are already capped at 8 KB each

baseline = input_prev + output_prev, both provider-counted. The only unknown is the outside part, and it does not need measuring: the provider will.

Main flow

appended content
   │
   ▼
[A] content policy (not a judgment)
    a text tool result over 8 KB → archived to an artifact, a placeholder goes on the wire
   │
   ▼
[B] proactive fold (the only proactive trigger)
    Maka window unset, or no valid baseline → skip
    baseline > Maka window → foldOnce(); send regardless of outcome
    previous step finished with `length` → foldOnce() once
   │
   ▼
[C] send ── the provider decides, six branches ────────────────────────────┐
   │                                                                       │
   ├─ accepted, usage present and grew   → record baseline, continue          │
   ├─ accepted, usage flat or lower      → hint: provider is dropping or rewriting context; set a Maka window
   ├─ accepted, usage missing            → hint: this provider reports no usage; only the rejection path remains
   ├─ accepted, finish = length          → reply was cut; mark, fold once before the next step
   ├─ rejected, classified ContextLength                                       │
   │     not yet folded this send → foldOnce() → resend once ─────────────────┘
   │     already folded          → surface the provider's error verbatim; the message stays editable
   │     and baseline < Maka window → hint: window is larger than the provider's; suggest input_n, one click
   │     and Maka window unset     → hint: set it to input_n?, one click
   └─ rejected, unclassified            → error + a "compact and retry" action; the user decides

At most two folds per send: one in [B], one on rejection. No loop.

foldOnce

range      structurally chosen largest safe prefix: no partial, never splits a tool pair, one pair kept as tail
           pre_turn (step 0): fold prior turns, the user message pinned into the tail
           mid_turn (step ≥ 1): fold prior turns plus this turn's completed steps;
                                tail = user message + the in-flight tool pair
summary    maxOutputTokens = 8k
           summarizer says input_too_large → halve the range → retry; down to the tail alone and still rejected → give up, send anyway
           summarizer finishes with `length` → one shorten pass through the existing repair path
quality    judged on the summarizer call's real in/out: a large fold that yields a few hundred tokens is rejected
           no word limit in the prompt (measured summaries are ~1K; the failure direction is too short)
write      checkpoint; replay = summary + everything after the boundary, no size-based selection
after      baseline cleared; the next accepted request is the first measurement

The tail needs no token bound: text results are capped by [A], images are counted by the provider, an oversized user message goes through the rejection path and is edited by the user.

Baseline validity, including resume

source    the session's newest persisted token_usage input/output (lastRequestAnchor from #4486)
valid     same model, same connection (run-header check)
invalid   model or connection changed → discard; after any fold → clear; usage not persisted before a crash → none
too high  user deleted messages / branched back → keep; at worst one unnecessary fold, reversible
shrunk    user lowered the Maka window → baseline > new window → fold before the next append; same path, no error

Rule: rather none than a guess. The cost of no baseline is one possible rejection on the first request.

Every number in the design

Number Origin A judgment?
input / output provider
Maka window user no, a target
8 KB content policy for one tool result no
8k summary output cap no; the summarizer's provider truncates, then a shorten pass

With no window set, the only Maka-owned numbers left are the two content-policy constants.

Against the current main

Removed

maxHistoryEstimatedTokens and its five consumers; the 32,000 fallback
the quarter-window / 16,384 reserve (retired outright)
estimateNextRequestTokens; midTurnRequestPayloadChars (a JSON.stringify of the whole request every step)
lastRequestPayloadChars, forcedTriggerEstimate, the capacity re-entry, lastShapeFailure
the payloadChars half of lastRequestAnchor
the estimated input fit in fitHistoryCompactMessages
chars/4 in summary-quality validation
size-based replay selection (ai-sdk-backend.ts ~4878)
the metadata window taking effect as capacity on its own

Changed

baseline gains output_prev (reasoning tokens counted per wire)
retreat halves the range instead of dropping one event, with a give-up exit
the Maka window reads the user setting only; the metadata window becomes a hint

Added

maxOutputTokens wired into the summarizer call (history-compact-summarizer.ts:44 is declared and never passed)
finish = length → fold once before the next step
silent-eviction detection (append-only step, usage did not grow)
missing-usage hint
window-too-large / window-unset hints with one-click apply of input_n
unclassified rejection → "compact and retry" action
metadata window shown next to the setting with one-click apply
composer usage indicator: percentage with a window, absolute tokens without

Open points

  1. Reasoning tokens in the baseline. Anthropic resends thinking inside a turn's tool loop; OpenAI does not. Per wire, or always counted (high, safe)?
  2. Out-of-the-box behaviour. With no window set the runtime is purely reactive: the first time a session fills up costs one round trip and possibly one cut reply. Acceptable, or is the one-click hint on first rejection enough?
  3. Manual /compact. Same foldOnce; after it clears the baseline, the indicator shows "unmeasured since compaction"?
  4. The delta, contested. fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict #4486 keeps a signed char/4 delta so step 0 can fold before a large new message or image goes out. My case for dropping it: the delta prices an image at a constant and under-reads CJK by about half, so the round trip it saves is the one it is most likely to misjudge; large text is already capped by [A]; and removing it takes the whole payload ruler and the per-step JSON.stringify with it. Happy to be argued back on the PR.

Refs #4458, #4486, #4283.

简体中文

#4458#4486 的后续。#4486 删掉了编造的容量和两道终止闸门;本 issue 走完剩下的一步:runtime 不再估算上下文放不放得下。所有「放不放得下」由某个供应商回答,所有触发由真实数字驱动。

我计划用一个 PR 实现。设计已在 #4458 上与 @Astro-Han@likun666661 讨论;唯一有争议的点放在最后。

原则

  • 放不放得下由供应商回答。 Maka 不估算、不判定、不终止。唯一不可逆的结果是供应商自身的拒绝。
  • Maka 窗口是目标不是限制。 它是用户设的压缩触发阈值。用户没设,就没有。
  • 只用真实数字。 供应商返回的 usage 与用户声明的设置。charsPerToken 不参与任何决策。
  • 可逆优先。 Maka 主动做的只有折叠(可逆)和提示。
  • 不替用户决定。 Maka 提供事实(模型声明的窗口、上次成功的用量),用户点了才生效。

三个窗口

来源 角色 未设 / 未知时
Maka 窗口 用户设置,唯一来源 主动折叠的触发阈值 无阈值,无主动折叠
元数据窗口 供应商 /models 或 models.dev 设置项旁的提示,一键填入 不显示
供应商窗口 Maka 不知道 硬限制 通过拒绝 / 掐断 / 淘汰起作用

「新内容」是什么

两次供应商请求之间追加的一切,不只是用户消息:

模型产出的   上一步的文字、tool_call 参数、推理 token(按 wire)
             尺寸已知:上一步 usage.outputTokens
模型之外的   工具结果、用户消息、图片、steering、工具 schema 变化
             尺寸未知;文本工具结果已被压到每条 ≤ 8 KB

baseline = input_prev + output_prev,两个都是供应商数的。唯一的未知是模型之外的部分,它不需要量,供应商会量。

主流程

追加内容进入
   │
   ▼
[A] 内容策略(不裁决)
    单条文本工具结果 > 8 KB → 存 artifact,请求里放占位符
   │
   ▼
[B] 主动折叠(唯一的主动触发器)
    Maka 窗口未设 或 baseline 无效 → 跳过
    baseline > Maka 窗口 → foldOnce(),成败都照发
    上一步 finish = length → 也 foldOnce() 一次
   │
   ▼
[C] 发送 ── 供应商裁决,六个分支 ────────────────────────────────┐
   │                                                            │
   ├─ 接受,usage 正常且增长   → 记 baseline,继续                 │
   ├─ 接受,usage 没涨 / 下降  → 提示:供应商在丢或改上下文,请设 Maka 窗口
   ├─ 接受,usage 缺失         → 提示:此供应商不报用量,只剩拒绝路径
   ├─ 接受,finish = length    → 回复被掐断;标记,下一步前折一次
   ├─ 拒绝,识别为 ContextLength                                    │
   │     本次未折过 → foldOnce() → 重发一次 ───────────────────────┘
   │     已折过     → 原样报供应商错误,消息保留可编辑
   │     且 baseline < Maka 窗口 → 提示窗口设大了,建议 input_n,一键采纳
   │     且 Maka 窗口未设        → 提示「设成 input_n?」,一键采纳
   └─ 拒绝,识别不出           → 报错 + 「压缩后重试」,用户裁决

每次发送最多两次折叠:[B] 一次 + 拒绝路径一次,无循环。

foldOnce

范围      结构性选出最大安全前缀:不切 partial,不切开工具调用对,留 1 对尾巴
          pre_turn(step 0):折 prior turns,用户消息钉在尾巴
          mid_turn(step ≥ 1):折 prior turns + 本 turn 已完成步骤;尾巴 = 用户消息 + 正在飞的工具对
摘要调用  maxOutputTokens = 8k
          摘要供应商说 input_too_large → 范围对半缩 → 再调;缩到只剩尾巴仍拒 → 放弃,照发
          摘要供应商 finish = length → 复用 repair 路径压短一遍
质量      用摘要调用的真实 in/out:折了很多却只吐几百 token → 拒收
          提示词不加字数限制(实测约 1k,风险方向是太短)
写入      checkpoint;回放 = 摘要 + 边界之后的全部,不按大小挑选
折叠后    baseline 清空,下一次被接受的请求是新的第一个测量点

尾巴不需要 token 上界:文本被 [A] 压住,图片由供应商数,巨大用户消息走拒绝路径由用户编辑。

baseline 有效性(含恢复)

来源     本会话最新一条 token_usage 持久化的 input / output(#4486 的 lastRequestAnchor)
有效     同模型、同连接(run header 校验)
无效     切模型 / 换连接 → 丢弃;任何折叠后 → 清空;崩溃未落盘 → 无
偏大     删消息 / 回退分支 → 沿用,最多多折一次,可逆无害
窗口改小 沿用,baseline > 新窗口 → 下一次追加前折一次,同一路径,不报错

规则:宁可没有,不要猜。没有 baseline 的代价是第一次请求可能被拒一次。

每个数字的来历

数字 来历 是裁决吗
input / output 供应商
Maka 窗口 用户 否,是目标
8 KB 单条工具结果的内容策略
8k 摘要长度上限 否,超了由摘要供应商截断再压短

未设窗口时,Maka 自己的数只剩后两个内容策略常量。

相比当前 main

maxHistoryEstimatedTokens 及五个消费者;32,000 兜底
「四分之一 / 16,384」预留(整个退役)
estimateNextRequestTokens;midTurnRequestPayloadChars(每步对整个请求 JSON.stringify)
lastRequestPayloadChars、forcedTriggerEstimate、capacity 重入、lastShapeFailure
lastRequestAnchor 的 payloadChars 一半
fitHistoryCompactMessages 的预估输入裁剪
摘要质量校验里的 chars/4
按大小选回放范围(ai-sdk-backend.ts ~4878)
元数据窗口自动作为 capacity 生效

baseline 加 output_prev(推理 token 按 wire 计)
retreat 对半缩而非退一条,加放弃出口
Maka 窗口只读用户设置;元数据窗口降级为提示

maxOutputTokens 接到摘要调用(history-compact-summarizer.ts:44 声明了从未传)
finish = length → 下一步前折一次
静默淘汰检测(只追加却没涨)
usage 缺失提示
窗口设大 / 未设 → 提示 + 一键采纳 input_n
识别不出的拒绝 → 「压缩后重试」
设置项旁显示元数据窗口 + 一键填入
composer 指示器:有窗口显示百分比,无窗口显示绝对值

开放问题

  1. 推理 token 是否计入 baseline。 Anthropic 同 turn 工具循环回传 thinking,OpenAI 不回传。按 wire 分,还是一律计入(偏大、安全)?
  2. 开箱体验。 未设窗口时纯被动:首次撑满多一次往返、可能一次掐断。接受,还是首次拒绝时的一键提示够了?
  3. 手动 /compact 同一个 foldOnce;清空 baseline 后指示器显示「压缩后未测量」?
  4. 增量之争。 fix(runtime): let the provider decide fit, anchor estimates on real usage, retire the local verdict #4486 保留带符号 char/4 增量,让 step 0 能在大消息或图片发出前折。我主张删的理由:增量对图片是常量、对 CJK 低估约一半,它想省的那次往返恰恰是它最容易估错的;大文本已被 [A] 压住;删掉它连带带走整条 payload 尺子和每步一次的 JSON.stringify。欢迎在 PR 上说服我。

Refs #4458, #4486, #4283

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions