Summary
A conversation of roughly 8,700 estimated tokens was terminated with context_budget_exhausted against a model whose real window is 200,000 — because the model's window was not declared, and the runtime substituted a fabricated capacity for it. The turn was killed before any request went out, so the provider never got to say whether it actually fit.
Five independent defects line up to produce this. Each is survivable alone; a relay model plus an image read hits all of them at once.
I would like to align on the target design before writing code, and on one sequencing question: add the diagnostics first, because the single number the verdict turns on is currently never recorded.
Evidence
From the session that failed (glm-5.3-flash on an openai-compatible relay, one turn, 33 runtime events):
runtime_events 33 rows, 34,706 bytes ≈ 8,700 tokens
session_messages 22 rows, 16,062 bytes
usage_llm_calls 3 rows — all auxiliary
session_title inputTokens 69
memory_proposal inputTokens 756
memory_canonicalize inputTokens 417
0 rows for the conversation's own model calls
terminal event contextBudgetExhaustedDetail: head_anchor_exceeds_capacity
no estimate, no capacity, no capacity source recorded
declared window at the time none → capacity = 32k + 16k = 48k
The relay's /models returns { "id": "glm-5.3-flash" } — no context_length, no limits — and openai-compatible carries no bundled metadata, so resolveSelectedModelContextWindow returns undefined.
Current pipeline (one step inside a turn)
new content enters the context
│ function_response (tool result) | text (user message)
▼
capacity resolveContextBudgetCapacity()
│ window known → capacity = window source=selected_model
│ window unknown → capacity = 32k + 16k source=policy_fallback ← (1)
▼
estimate estimateNextRequestTokens()
│ with anchor → last provider inputTokens + appendedChars / 4
│ no anchor → whole payload / 4
│ ← (2)
│ · conversation usage is never persisted → a restart loses the anchor
│ · charsPerToken is fixed at 4; Chinese runs closer to 2
│ · an image is stored as a reference (~0 chars) and billed as
│ hundreds-to-thousands of vision tokens
▼
estimate > capacity ?
│ │
no│ │yes
│ ▼
│ compact
│ selectSafeCompactionPrefix
│ · may not end on a partial
│ · may not split a call/result pair
│ · mid_turn keeps ≥ 1 verbatim tail event ← (3)
│ summarize with a model call → validate → write checkpoint
│ │
│ ▼
│ re-estimate; still over?
│ │yes
│ ▼
│ head_anchor_exceeds_capacity
│ TERMINATE — the request is never sent ← core defect
│ the provider never gets a say
▼
send ──→ provider
├─ rejected → reactive recovery (already implemented)
└─ ok → usage.inputTokens (discarded) ← (2)
The escape hatch that should have saved this is activeToolResultPrune, which can swap an oversized result for a small projection while archiveToolResultAsTransition keeps the full body in an artifact. It measures serialized bytes against a 2,048-token threshold. The image reference is 1,104 bytes ≈ 276 tokens, so the prune sees nothing to do. ← (4)
The five defects
| # |
Defect |
Effect |
| 1 |
Capacity is fabricated when the window is unknown |
The threshold has nothing to do with the model |
| 2 |
The estimate is distorted in three common cases |
Lost anchor after restart / non-English text / images |
| 3 |
Compaction's floor is summary + 1 tail event |
The new message never folds; its size is a hard floor |
| 4 |
The only escape hatch is blind to images |
It judges by bytes, so it closes itself |
| 5 |
Nothing is visible to the user until the task dies |
The first mention of "context" is the failure banner |
Target design
new content enters the context
▼
capacity
│ window known → capacity = window
│ window unknown → capacity = undefined fixes (1)
│ no local threshold; the provider decides
│ the UI says the window is undeclared
▼
estimate (drives compaction only — never termination)
│ anchor = last real inputTokens, persisted, survives restart fixes (2)
│ charsPerToken derived from this session's own history
│ images: only the step they first enter is mis-estimated, and
│ they sit inside the anchor from the next step on
▼
estimate > capacity ? ──yes──→ compact once (mark "compacted this step")
▼
send ←── the request goes out regardless of what the estimate said
│
┌────┴─────┐
▼ ▼
provider ok provider rejects
│ (context_length_exceeded / model_context_window_exceeded /
│ prompt is too long / request_too_large / …)
├ persist usage │
├ → next step's anchor │
└ → usage indicator │ fixes (5)
┌────┴────────────────┐
▼ ▼
not compacted already compacted
this step this step
compact, retry once at the compaction floor
(bounded re-entry) │
▼
shrink the tail fixes (4)
identify an oversized artifact
archiveToolResultAsTransition
full body stays in the artifact
model sees a placeholder projection
retry once
│
▼
still does not fit
┌──────────────────────────────────┐
│ terminate, stating only what is │
│ known: │
│ · the provider's own error code │
│ · bytes/tokens actually sent │
│ · the window │
│ · capacity source │
│ (declared or fallback) │
│ · which item is largest │
│ no inferred cause │
└──────────────────────────────────┘
Context usage indicator (defect 5)
In the composer toolbar (packages/ui/src/composer.tsx, .maka-model-selection-controls), immediately right of the thinking-level control:
window known
[ + ] [ 🛡 ] [ ◎ glm-5.3-flash ] [ 最高 ] [ ▤ 42% ]
│
numerator: the last request's real usage.inputTokens
(includes images, priced by the provider)
denominator: the declared or discovered window
window unknown
[ + ] [ 🛡 ] [ ◎ glm-5.3-flash ] [ 最高 ] [ ⚠ ]
│
"Context window not declared — usage cannot be shown and
the runtime cannot compact ahead of time." → Settings
Being unable to compute the percentage is the evidence that the configuration is missing, so the indicator's slot is the natural home for the warning. The user learns this when they start working, not when a turn is killed.
Design principles to align on
- An estimate may drive a reversible action (compaction) and must never drive an irreversible one (termination).
- Termination belongs to the provider's real rejection. Only the provider counts accurately — especially images and non-English text.
- Reports state what is known and do not infer a cause. Today's "change models or start a new task" and a tempting "the new message is too large" are the same mistake: both name a cause nobody measured.
Sequencing — the part I would most like a second opinion on
The number the verdict turns on is never recorded. The terminal event carries head_anchor_exceeds_capacity and nothing else: no estimate, no capacity, no capacity source. Everything above about how the estimate went wrong is inference from surrounding data, not measurement.
So I would like to land diagnostics first:
- Persist conversation
usage alongside the auxiliary calls that already record it, and put estimate / capacity / capacity-source / largest-item on the terminal event.
- Then defect 1 (stop fabricating capacity) and defect 5 (the indicator), which share that data.
- Then defect 4 (make the prune see artifacts) — this is what turns "context is full" from a dead end into something recoverable.
- Then defect 2 (anchor persistence and a self-calibrating chars-per-token).
Happy to be argued out of this order — in particular, whether an unknown window should mean "no threshold" (my preference: the provider's answer is correct, ours is not) or a conservative default (my objection: any constant is wrong for some model, and being wrong low kills live work while being wrong high only costs one round trip).
中文
问题
一个估算约 8,700 token 的会话,被 context_budget_exhausted 终止,而该模型的真实窗口是 20 万 —— 原因是模型窗口未声明,运行时用一个编造的容量替代了它。终止发生在请求发出之前,供应商从未有机会判断它到底装不装得下。
有五个互相独立的缺陷叠在一起。任何一条单独存在都不致命;而「中转站模型 + 读一张图」会同时踩中全部。
我希望在动手写代码之前先对齐目标设计,以及一个顺序问题:先加诊断 —— 因为判定所依据的那个数字,目前从不被记录。
证据
来自失败的那个会话(glm-5.3-flash,openai-compatible 中转站,一个 turn,33 条运行时事件):
runtime_events 33 条,34,706 字节 ≈ 8,700 token
session_messages 22 条,16,062 字节
usage_llm_calls 3 条 —— 全是辅助调用
session_title inputTokens 69
memory_proposal inputTokens 756
memory_canonicalize inputTokens 417
对话本身的模型调用:0 条
终止事件 contextBudgetExhaustedDetail: head_anchor_exceeds_capacity
没有估算值、没有容量值、没有容量来源
当时声明的窗口 无 → 容量 = 32k + 16k = 48k
中转站的 /models 只返回 { "id": "glm-5.3-flash" } —— 没有 context_length、没有任何上限 —— 而 openai-compatible 又没有内置元数据,所以 resolveSelectedModelContextWindow 返回 undefined。
五个缺陷
| # |
缺陷 |
后果 |
| 1 |
窗口未知时容量是编造的 |
门槛与模型无关 |
| 2 |
估算在三种常见情况下失真 |
重启丢锚点 / 非英文文本 / 图片 |
| 3 |
压缩下限是「摘要 + 1 条尾部」 |
新消息永不折叠,它的体量就是硬下限 |
| 4 |
唯一的退路对图片视而不见 |
按字节判断,退路把自己关上 |
| 5 |
用户全程看不到任何指标 |
第一次听说「上下文」就是失败横幅 |
本该救场的是 activeToolResultPrune:它能把超大结果换成小投影,同时由 archiveToolResultAsTransition 把完整内容留在 artifact 里。但它按序列化字节对照 2,048 token 的阈值判断,而图片引用只有 1,104 字节 ≈ 276 token,于是它认为无事可做。← (4)
目标设计的三条原则
- 估算只能驱动可逆动作(压缩),永远不能驱动不可逆动作(终止)。
- **终止权属于供应商的真实拒绝。**只有供应商数得准 —— 尤其是图片和非英文文本。
- **报告只陈述已知,不臆断原因。**现行的「换模型或开启新任务」,和一个听起来很顺的「新消息过大」,犯的是同一个错:都在指认一个没人测量过的原因。
上下文使用率指示器(缺陷 5)
放在输入框工具条(packages/ui/src/composer.tsx 的 .maka-model-selection-controls)里,思考档位控件右侧:
窗口已知
[ + ] [ 🛡 ] [ ◎ glm-5.3-flash ] [ 最高 ] [ ▤ 42% ]
分子:最近一次请求的真实 usage.inputTokens(含图片,按供应商计价)
分母:声明或探测到的窗口
窗口未知
[ + ] [ 🛡 ] [ ◎ glm-5.3-flash ] [ 最高 ] [ ⚠ ]
「未声明上下文窗口 —— 无法显示用量,也无法提前压缩」→ 去设置
算不出百分比这件事本身,就是配置缺失的证据,所以指示器的位置天然就是提示该出现的地方。用户在开始工作时就看到,而不是在任务被杀时才第一次得知。
顺序 —— 这一点我最想听听别人的意见
判定所依据的那个数字从不被记录。终止事件只有 head_anchor_exceeds_capacity,没有估算值、没有容量值、没有容量来源。上面关于「估算是怎么错的」的所有分析,都是从周边数据做的推理,不是测量。
所以我想先落诊断:
- 把对话的
usage 落盘(辅助调用已经在这么做了),并在终止事件上带上 估算值 / 容量 / 容量来源 / 最大的那一条。
- 然后是缺陷 1(不再编造容量)和缺陷 5(指示器),二者共用同一份数据。
- 然后是缺陷 4(让裁剪器看得见 artifact)—— 这一条把「上下文满了」从死局变成可恢复。
- 最后是缺陷 2(锚点落盘 + chars-per-token 自校准)。
这个顺序欢迎反驳。特别是:窗口未知时到底应该「不设门槛」(我倾向这个:供应商的答案是对的,我们的不是),还是给一个保守默认值(我的反对理由:任何常数对某些模型都是错的,而猜小了会杀掉正在进行的工作,猜大了只是多一次往返)。
Summary
A conversation of roughly 8,700 estimated tokens was terminated with
context_budget_exhaustedagainst a model whose real window is 200,000 — because the model's window was not declared, and the runtime substituted a fabricated capacity for it. The turn was killed before any request went out, so the provider never got to say whether it actually fit.Five independent defects line up to produce this. Each is survivable alone; a relay model plus an image read hits all of them at once.
I would like to align on the target design before writing code, and on one sequencing question: add the diagnostics first, because the single number the verdict turns on is currently never recorded.
Evidence
From the session that failed (
glm-5.3-flashon anopenai-compatiblerelay, one turn, 33 runtime events):The relay's
/modelsreturns{ "id": "glm-5.3-flash" }— nocontext_length, no limits — andopenai-compatiblecarries no bundled metadata, soresolveSelectedModelContextWindowreturnsundefined.Current pipeline (one step inside a turn)
The escape hatch that should have saved this is
activeToolResultPrune, which can swap an oversized result for a small projection whilearchiveToolResultAsTransitionkeeps the full body in an artifact. It measures serialized bytes against a 2,048-token threshold. The image reference is 1,104 bytes ≈ 276 tokens, so the prune sees nothing to do. ← (4)The five defects
summary + 1 tail eventTarget design
Context usage indicator (defect 5)
In the composer toolbar (
packages/ui/src/composer.tsx,.maka-model-selection-controls), immediately right of the thinking-level control:Being unable to compute the percentage is the evidence that the configuration is missing, so the indicator's slot is the natural home for the warning. The user learns this when they start working, not when a turn is killed.
Design principles to align on
Sequencing — the part I would most like a second opinion on
The number the verdict turns on is never recorded. The terminal event carries
head_anchor_exceeds_capacityand nothing else: no estimate, no capacity, no capacity source. Everything above about how the estimate went wrong is inference from surrounding data, not measurement.So I would like to land diagnostics first:
usagealongside the auxiliary calls that already record it, and put estimate / capacity / capacity-source / largest-item on the terminal event.Happy to be argued out of this order — in particular, whether an unknown window should mean "no threshold" (my preference: the provider's answer is correct, ours is not) or a conservative default (my objection: any constant is wrong for some model, and being wrong low kills live work while being wrong high only costs one round trip).
中文
问题
一个估算约 8,700 token 的会话,被
context_budget_exhausted终止,而该模型的真实窗口是 20 万 —— 原因是模型窗口未声明,运行时用一个编造的容量替代了它。终止发生在请求发出之前,供应商从未有机会判断它到底装不装得下。有五个互相独立的缺陷叠在一起。任何一条单独存在都不致命;而「中转站模型 + 读一张图」会同时踩中全部。
我希望在动手写代码之前先对齐目标设计,以及一个顺序问题:先加诊断 —— 因为判定所依据的那个数字,目前从不被记录。
证据
来自失败的那个会话(
glm-5.3-flash,openai-compatible中转站,一个 turn,33 条运行时事件):中转站的
/models只返回{ "id": "glm-5.3-flash" }—— 没有context_length、没有任何上限 —— 而openai-compatible又没有内置元数据,所以resolveSelectedModelContextWindow返回undefined。五个缺陷
本该救场的是
activeToolResultPrune:它能把超大结果换成小投影,同时由archiveToolResultAsTransition把完整内容留在 artifact 里。但它按序列化字节对照 2,048 token 的阈值判断,而图片引用只有 1,104 字节 ≈ 276 token,于是它认为无事可做。← (4)目标设计的三条原则
上下文使用率指示器(缺陷 5)
放在输入框工具条(
packages/ui/src/composer.tsx的.maka-model-selection-controls)里,思考档位控件右侧:算不出百分比这件事本身,就是配置缺失的证据,所以指示器的位置天然就是提示该出现的地方。用户在开始工作时就看到,而不是在任务被杀时才第一次得知。
顺序 —— 这一点我最想听听别人的意见
判定所依据的那个数字从不被记录。终止事件只有
head_anchor_exceeds_capacity,没有估算值、没有容量值、没有容量来源。上面关于「估算是怎么错的」的所有分析,都是从周边数据做的推理,不是测量。所以我想先落诊断:
usage落盘(辅助调用已经在这么做了),并在终止事件上带上 估算值 / 容量 / 容量来源 / 最大的那一条。这个顺序欢迎反驳。特别是:窗口未知时到底应该「不设门槛」(我倾向这个:供应商的答案是对的,我们的不是),还是给一个保守默认值(我的反对理由:任何常数对某些模型都是错的,而猜小了会杀掉正在进行的工作,猜大了只是多一次往返)。