Status. The design is settled and split into a series; this body is the design. It replaces the original proposal, which stays available in this issue's edit history — what changed and why is tabulated at the end. #4574 was the first implementation attempt and is closed in favour of the series below, with its review findings mapped in its closing comment.
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 whether context fits. Every "does it fit" question is answered by a provider; every trigger is a real number.
The design: three modules, recursion bounded at one
One module judges a provider response; the other two use it. The send module may call the compaction module. The compaction module never calls the send module and never calls itself. That, plus one compaction per send, is why nothing here can loop.
Judgement module
Input: one provider response or error. Output: one of five. Shared, so "was this cut" and "was this a size rejection" have exactly one implementation.
├ completed normally no error, an ordinary finish reason
├ cut off finishReason = length: the model stopped for lack of room
├ rejected, size the error classifies as a context-length rejection
├ rejected, unrecognised any other error; nothing is inferred from it
└ usage suspicious completed, but usage is missing, did not grow, or exceeds
the window the model itself reports
Send module
Input: the baseline plus the new message. baseline is the last accepted request's real input + output — an upper bound on what carries forward, never an underestimate, since a wire that does not resend reasoning sends less next time. The new message is deliberately not measured; the provider judges it.
① Threshold
no declared window, or no baseline -> skip this step entirely
baseline + reply reserve >= window -> call the compaction module
(this spends the send's only call)
② Send
③ Judge
completed -> record the new baseline, persist the anchor
cut off -> no action. The reply may have been cut because the
provider ran out of window room, or because the
provider's own output cap is lower than the one Maka
sends. Those are indistinguishable from outside, and
an indistinguishable signal must not drive an action.
rejected, size -> compaction not yet called this send: call it, resend
already called: report that this message is too large
on its own
rejected, unknown -> report it as it came
usage suspicious -> record the baseline and write the matching note
Reply reserve is min(2 × last reply, 8000): measured from the reply the model actually wrote, not the largest it could write.
Compaction module
Input: the event ledger, the boundary the last accepted input covered, the last reply's token count. Output: a smaller baseline. Entered at most once per send.
① Choose the fold range
default: everything except the live head
live head = the user message this turn is answering, plus any in-flight
tool call and its result. Neither may become a summary.
② Send it to the summarizer (same model, same connection, no threshold step)
③ Judge
completed -> is the summary usable? required sections present, no
unterminated fence. Malformed gets one stricter repair.
cut off -> one retry with a shorter prompt, then fail open
rejected, size
-> retreat to the span the LAST ACCEPTED INPUT covered. That
span was accepted by this model on this connection, so it is
provably within capacity, where halving the range is a guess.
The last reply's own span folds separately above 24,000
tokens. One retry, then fail open.
rejected, unknown -> fail open
Failing open always means: send the request unfolded and let the provider decide. Compaction never terminates a turn.
Invariants
- The local side never decides that a request does not fit. Only the provider terminates.
- Everything Maka does on its own is reversible: one fold, and notes.
- Inputs are provider-reported numbers and the user's own declaration, nothing else. A signal that cannot be told apart from a benign cause drives nothing.
- The declared window is a target, not a limit. Exceeding it is reported, not prevented.
The series
| PR |
Module |
What it carries |
Depends on |
| #4653 |
Send |
Threshold and reply reserve, persisted anchor, one compaction per send, "this message is too large", stream-usage collection with the strict-relay retreat, the summarizer request shape, and all five system notes. One epoch move. |
— |
| PR 2 |
Compaction |
Retreat to the last accepted input boundary instead of halving; the last reply's span folded separately above 24,000 tokens. |
#4653 |
| #4575 |
UI |
Settings: the model's reported window shown beside the declaration, with one-click apply. |
#4653 |
| #4576 |
UI |
Composer: read-only context-usage indicator fed by the persisted anchor. |
#4653 |
| PR 5 |
Send |
"Compact and retry" for a rejection the classifier does not recognise. Tracked on #4623. |
#4653 |
| optional |
— |
Extract the judgement module so both callers share one implementation. Pure refactor. |
PR 2 |
What discussion changed, against the original proposal
| Original |
Final |
Why |
| Metadata window seeds a default declaration |
Declaration only; metadata is a hint with one-click apply |
Maka decides for nobody, including the user |
| Reserve is the model's declared output limit |
min(2 × last reply, 8000) |
On k3-256k the limit is half the window, so reserving it folds at 50% utilisation |
| Proactive and reactive folds are independent |
One compaction per send |
The fold already covers everything but the live head; a second one costs the most recent context and buys a small tail |
finishReason: length folds once |
Drives nothing |
Window exhaustion and the provider's own lower output cap are indistinguishable |
| A fold rejected as too large halves its range |
Retreats to the last accepted input boundary |
That boundary is provably within capacity; halving is a guess |
| Two notes |
Five |
The silent cases each need their own sentence |
The original proposal left four points open; all are resolved: reasoning tokens are counted whole in the baseline (high and safe); with no declaration the runtime is purely reactive and says so through the notes; manual /compact uses the same module and clears the baseline; and the signed delta is dropped, which @Astro-Han accepted on #4486.
Refs #4458, #4486, #4283, #4623, #4634.
简体中文
状态:设计已定稿并拆成系列,本正文即设计本身。它取代了原提案,原文仍可在本 issue 的编辑历史中查看,改动对照见文末表格。#4574 是第一次实现尝试,已关闭,改为下方系列,其评审发现的去向写在那条关闭评论里。
#4458 与 #4486 的后续。#4486 删掉了编造的容量和两道终止闸门;本 issue 走完剩下的一步:runtime 不再估算上下文放不放得下。所有「放不放得下」由供应商回答,所有触发由真实数字驱动。
设计:三个模块,递归深度为 1
一个模块负责判定一次供应商响应,另外两个都用它。发送模块可以调压缩模块;压缩模块不调发送模块,也不调自己。加上「每次发送只压一次」,整条路径不可能成环。
判定模块
输入:一次供应商响应或错误。输出五选一。两个模块共用,所以「有没有被截断」「是不是尺寸问题」只有一处实现。
├ 正常完成 没有错误,正常的结束原因
├ 被截断 finishReason = length,模型是没地方写了才停的
├ 拒绝,尺寸 错误能被分类为上下文超长
├ 拒绝,认不出 其他错误,不做任何推测
└ usage 可疑 完成了,但用量缺失、没增长,或超过模型自己上报的窗口
发送模块
输入:baseline 加新消息。baseline 是上次被接受请求的真实 input + output,它是「带进下一次请求的量」的上界,不会低估——不回传推理内容的线路上,下一次 input 更小。新消息故意不量,由供应商裁决。
① 阈值
没声明窗口,或没有 baseline → 整步跳过
baseline + 回复预留 ≥ 窗口 → 调压缩模块(用掉本次发送唯一的一次机会)
② 发送
③ 判定
正常完成 → 记新 baseline,落盘锚点
被截断 → 不做动作。回复被截可能是供应商窗口没空间了,也可能是它
自己的输出上限比 Maka 发过去的更低。这两者从外部无法区分,
无法区分的信号不该驱动动作。
拒绝,尺寸 → 本次发送还没调过压缩 → 调一次,重发
已经调过 → 报「这条消息本身太大」
拒绝,认不出 → 原样报错
usage 可疑 → 记 baseline,并写对应提示
回复预留是 min(上次回复 × 2, 8000):量的是模型实际写出的回复,不是它可能写出的最大值。
压缩模块
输入:事件账本、上次被接受的 input 所覆盖的边界、上次回复的 token 数。输出:更小的 baseline。每次发送最多进来一次。
① 定折叠范围
默认:除活动头部之外的全部
活动头部 = 当前轮正在回答的用户消息,以及正在飞的工具调用与其结果。
这两样不能变成摘要,否则请求本身非法。
② 发给摘要模型(同模型同连接,没有阈值步骤)
③ 判定
正常完成 → 摘要能用吗?必需小节齐全、没有未闭合的代码围栏。
格式错给一次更严格的修复。
被截断 → 换更短的提示词重试一次,再失败放行
拒绝,尺寸 → 退到「上次被接受的 input 所覆盖的那一段」。这一段被这个模型、
这条连接处理过,是可证明装得下的边界,而砍半是猜。
上次回复那一段超过 24,000 token 时单独折叠。重试一次后放行。
拒绝,认不出 → 放行
放行永远是同一个含义:不折叠,原样发出,让供应商裁决。压缩从不终止一轮。
不变量
- 本地永远不判定「装不下」,只有供应商能终止。
- Maka 主动做的一切都是可逆的:一次折叠,以及写提示。
- 输入只有供应商给的数字和用户自己的声明。与良性原因无法区分的信号,什么都不驱动。
- 用户声明的窗口是目标不是限制,超了要报告,不要阻止。
系列拆分
| PR |
模块 |
内容 |
依赖 |
| #4653 |
发送 |
阈值与回复预留、锚点落盘、每次发送只压一次、「这条消息本身太大」、用量采集与严格中转回退、摘要请求形状、五种提示。一次 epoch 变更 |
— |
| PR 2 |
压缩 |
折叠被拒时退到上次被接受的 input 边界(取代砍半);上次回复那段超过 24,000 token 时单独折叠 |
#4653 |
| #4575 |
界面 |
设置页显示模型上报窗口,一键填入 |
#4653 |
| #4576 |
界面 |
输入框只读的上下文使用率指示器 |
#4653 |
| PR 5 |
发送 |
分类不出的拒绝加「压缩后重试」,记在 #4623 |
#4653 |
| 可选 |
— |
抽出判定模块,两个调用方共用一份实现。纯重构 |
PR 2 |
讨论改变了什么(相对原提案)
| 原提案 |
定稿 |
原因 |
| 元数据窗口作为默认声明 |
只认用户声明,元数据仅作提示加一键填入 |
Maka 不替任何人做决定,包括用户 |
| 预留取模型声明的输出上限 |
min(上次回复 × 2, 8000) |
k3-256k 的上限是窗口的一半,照它预留会在 50% 利用率就折叠 |
| 主动与被动折叠各自独立 |
每次发送只压一次 |
折叠已覆盖除活动头部外的全部,第二次代价是最近的原文,收益只有一小段尾巴 |
finishReason: length 折一次 |
什么都不驱动 |
窗口不够与供应商自身更低的输出上限无法区分 |
| 折叠被拒就把范围砍半 |
退到上次被接受的 input 边界 |
那个边界可证明装得下,砍半是猜 |
| 两种提示 |
五种 |
每一种静默情况都需要自己的一句话 |
原提案的四个待定问题均已解决:推理 token 在 baseline 里整体计入(偏高、安全);未声明窗口时 runtime 纯被动,并通过提示告知;手动 /compact 走同一个模块并清空 baseline;带符号的 delta 去掉,@Astro-Han 已在 #4486 接受。
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 whether context fits. Every "does it fit" question is answered by a provider; every trigger is a real number.
The design: three modules, recursion bounded at one
One module judges a provider response; the other two use it. The send module may call the compaction module. The compaction module never calls the send module and never calls itself. That, plus one compaction per send, is why nothing here can loop.
Judgement module
Input: one provider response or error. Output: one of five. Shared, so "was this cut" and "was this a size rejection" have exactly one implementation.
Send module
Input: the baseline plus the new message.
baselineis the last accepted request's realinput + output— an upper bound on what carries forward, never an underestimate, since a wire that does not resend reasoning sends less next time. The new message is deliberately not measured; the provider judges it.Reply reserve is
min(2 × last reply, 8000): measured from the reply the model actually wrote, not the largest it could write.Compaction module
Input: the event ledger, the boundary the last accepted input covered, the last reply's token count. Output: a smaller baseline. Entered at most once per send.
Failing open always means: send the request unfolded and let the provider decide. Compaction never terminates a turn.
Invariants
The series
What discussion changed, against the original proposal
min(2 × last reply, 8000)finishReason: lengthfolds onceThe original proposal left four points open; all are resolved: reasoning tokens are counted whole in the baseline (high and safe); with no declaration the runtime is purely reactive and says so through the notes; manual
/compactuses the same module and clears the baseline; and the signed delta is dropped, which @Astro-Han accepted on #4486.Refs #4458, #4486, #4283, #4623, #4634.
简体中文
#4458 与 #4486 的后续。#4486 删掉了编造的容量和两道终止闸门;本 issue 走完剩下的一步:runtime 不再估算上下文放不放得下。所有「放不放得下」由供应商回答,所有触发由真实数字驱动。
设计:三个模块,递归深度为 1
一个模块负责判定一次供应商响应,另外两个都用它。发送模块可以调压缩模块;压缩模块不调发送模块,也不调自己。加上「每次发送只压一次」,整条路径不可能成环。
判定模块
输入:一次供应商响应或错误。输出五选一。两个模块共用,所以「有没有被截断」「是不是尺寸问题」只有一处实现。
发送模块
输入:baseline 加新消息。
baseline是上次被接受请求的真实input + output,它是「带进下一次请求的量」的上界,不会低估——不回传推理内容的线路上,下一次 input 更小。新消息故意不量,由供应商裁决。回复预留是
min(上次回复 × 2, 8000):量的是模型实际写出的回复,不是它可能写出的最大值。压缩模块
输入:事件账本、上次被接受的 input 所覆盖的边界、上次回复的 token 数。输出:更小的 baseline。每次发送最多进来一次。
放行永远是同一个含义:不折叠,原样发出,让供应商裁决。压缩从不终止一轮。
不变量
系列拆分
讨论改变了什么(相对原提案)
min(上次回复 × 2, 8000)finishReason: length折一次原提案的四个待定问题均已解决:推理 token 在 baseline 里整体计入(偏高、安全);未声明窗口时 runtime 纯被动,并通过提示告知;手动
/compact走同一个模块并清空 baseline;带符号的 delta 去掉,@Astro-Han 已在 #4486 接受。