Skip to content

fix: overflow-arm stored above the headroom-reserved judging limit → unbounded CONTEXT_WINDOW_EXCEEDED loop #3738

Closed

Description

@poboll

Summary

A session whose model declares a large output budget can fail to converge on a context overflow and loop the client into an unbounded 400/400/400 cycle. bili detects the overflow, arms its emergency-shrink baseline, and then judges the next request against a smaller window than the one it armed at — so the arm can never bring the payload under the limit it exists to enforce. This is a self-heal defect, not a provider problem.

Version-first triage

Reporter runs bili v0.1.200 (installed under DSH Desktop's plugin profile; the running proxy reports 0.1.201). Code at that tag confirms the defect is present, not a version-lag artifact:

$ git grep -n "s.stats.lastInputTokens = info.window" v0.1.200 -- src/server/relay.ts
v0.1.200:src/server/relay.ts:831:            s.stats.lastInputTokens = info.window;

$ git show v0.1.200:src/util.ts | grep -n "cap \* window\|DEFAULT_OUTPUT_HEADROOM_MAX_PCT = "
327:const DEFAULT_OUTPUT_HEADROOM_MAX_PCT = 0.25;
358:        const reserved = Math.min(maxOutput, cap * window);

Reproduction (real limb, verbatim)

[acp-proxy: upstream HTTP 400: {"error":{"message":"This model's maximum context length is
1048576 tokens. However, you requested 1097304 tokens (841304 in the messages, 256000 in
the completion). Please reduce the length of the messages or completion.
(request_id: 201b2156-8f9f-46d2-b96a-b1485e297ac3)","type":"invalid_request_error",
"param":null,"code":"invalid_request_error"}}]

Surface effect in the client: 本轮运行失败 / 处理失败, tagged CONTEXT_WINDOW_EXCEEDED, and every queued 继续执行目标 entry flips to 已停止. The session never recovers on its own.

Root cause — the arm is stored above the limit the kernel judges against

Three values are in play and they disagree:

value where this limb
stated window parsed from the upstream rejection (src/util.ts:291) 1,048,576
reserved judging limit reserveOutputHeadroom(window, maxOutput, 0.25) (src/util.ts:355), stored at src/server/handle.ts:2137 as session.metadata.effectiveContextLimit 792,576
arm src/server/relay.ts:831 s.stats.lastInputTokens = info.window 1,048,576 ← bug

reserveOutputHeadroom computes reserved = min(maxOutput, capPct × window):

capPct × window = 0.25 × 1,048,576 = 262,144
reserved        = min(256,000, 262,144) = 256,000
effective       = 1,048,576 − 256,000  = 792,576

bili already knew the payload did not fit: server.ts:3143 payloadFitsWindow = 841,304 < 792,576 is false, and server.ts:3067 triggerFires is true. It forwarded anyway, which is correct in isolation — one probe is fine (server.ts:3102 probeForEvidence forwards once to acquire upstream evidence, and the #2490 doomed-probe guard correctly lets this payload through since it is under the 2 MB byte floor).

What breaks is the recovery. armOverflowShrink stores the raw stated window as the meter preflight later consults (preflight.ts:1527 currentTokens = baselineKnown ? deps.session.stats.lastInputTokens : …), while the fit check and every nudge band run against the reserved effectiveContextLimit. So the meter reads 1,048,576 while the bands enforce 792,576:

  • over-limit band (≥ 75% of 792,576 = 594,432) — never read as satisfied by a 1,048,576 meter;
  • emergency band (emergencyThresholdPct = 0.95, kernel/src/config.ts:20 → 752,947) — same.

The client re-sends its full history next turn, bili forwards it verbatim again, the upstream rejects it again, the arm is re-set to 1,048,576. Unbounded, and no fold is ever demanded that the client's re-sent payload can satisfy.

Note the arm's value is upstream truth and must stay that way — the defect is only that it is never reconciled with the reservation the kernel is actually enforcing.

Fix

Clamp the arm to min(stated window, reserved judging limit) at src/server/relay.ts:831:

const judgingLimit = typeof s.metadata.effectiveContextLimit === "number" && s.metadata.effectiveContextLimit > 0
    ? s.metadata.effectiveContextLimit
    : 0;
const armedWindow = judgingLimit > 0 && judgingLimit < info.window ? judgingLimit : info.window;
s.stats.lastInputTokens = armedWindow;
s.stats.overflowArmTokens = armedWindow;   // #1110: the side-request guard reads this

The clamp is deliberately conditional and is a no-op in the common case:

  • No output budget declared → reserveOutputHeadroom returns the window unchanged → effectiveContextLimit === info.window → the arm stays at the exact stated window. Verified against the real code path: arm 1,048,576, limit 1,048,576.
  • Output budget declared → the arm becomes 792,576, which is ≤ the judging limit, so the bands can fire on the very next turn and the fold converges.

overflowArmTokens is updated in lockstep because the side-request guard (relay.ts:150-155) reads it and would otherwise keep using the un-clamped value.

The fail-fast 413 path and the #2490 doomed-probe guard are untouched.

Verification

tests/fix-arm-headroom-clamp.test.ts (new, 2 cases, pinned against the limb's real numbers):

  1. Clamp applies — model with max_tokens: 256,000 against a 1,048,576 window, upstream answers with the verbatim rejection body. Asserts lastInputTokensSource === "overflow-arm", lastInputTokens === 792,576 (the reservation), lastInputTokens <= effectiveContextLimit, and overflowArmTokens === lastInputTokens.
  2. Control: no reservation — same session, no max_tokens. Asserts effectiveContextLimit === 1,048,576 (no reservation recorded) and the arm stays at the exact stated window 1,048,576.

Red/green: case 1 fails without the fix (arm 1048576 must sit BELOW the stated window 1048576), case 2 passes either way — proving the clamp is conditional rather than a blanket shrink.

Pre-flight results:

$ npx tsc --noEmit --project tsconfig.json     # clean
$ node --import tsx --test tests/fix-arm-headroom-clamp.test.ts
ok 1 - the stated-window arm is clamped to the headroom-reserved judging limit
ok 2 - the stated-window arm is the upstream truth when no headroom reservation applies
# tests 2  # pass 2  # fail 0

Adjacent suites, no regressions:

context-overflow.test.ts          -> # pass 25  # fail 0
issue2313-probe-forward.test.ts   -> # pass  4  # fail 0
issue496-forward-once-learn.test.ts -> # pass 1 # fail 0
headroom-output-fallback.test.ts  -> # pass  6  # fail 0
fallback-window-floor.test.ts     -> # pass  2  # fail 0
e2e-overflow-selfheal.test.ts     -> # pass  3  # fail 0
e2e-relay-5xx-selfheal.test.ts    -> # pass  3  # fail 0
issue3004-nudge-decide-selfheal.test.ts -> # pass 7 # fail 0
native-intercept-selfheal.test.ts -> # pass 22  # fail 0

Blast radius

armOverflowShrink has exactly two arming branches, both now writing the clamped / original value as before. No persisted state, no learner, no declared window — the site comment ("declared window unchanged, nothing persisted/learned") still holds. The change is confined to one session-lifetime in-memory meter.

Environment

  • billion-context 0.1.200 (reporter) / 0.1.201 (running proxy)
  • Client: DeepSeek Desktop (dsh), single long-running session
  • Upstream: OpenAI-compatible, 1,048,576-token window

Activity

  1. poboll commented on Oct 18, 2026

    @poboll
    ContributorAuthor

    PR opened: #3739 — fix: clamp the overflow-arm to the headroom-reserved judging limit

    Branch poboll:2026-10-18_arm-headroom-clamp, base master, currently MERGEABLE.

    Diff is deliberately minimal (src/server/relay.ts +23/−3, new test +270):

    const judgingLimit = typeof s.metadata.effectiveContextLimit === "number" && s.metadata.effectiveContextLimit > 0
        ? s.metadata.effectiveContextLimit
        : 0;
    const armedWindow = judgingLimit > 0 && judgingLimit < info.window ? judgingLimit : info.window;
    s.stats.lastInputTokens = armedWindow;
    s.stats.overflowArmTokens = armedWindow;   // #1110: the side-request guard reads this

    Red/green pinned in tests/fix-arm-headroom-clamp.test.ts — case 1 fails without the fix (arm 1048576 must sit BELOW the stated window 1048576), case 2 (no output budget → arm stays at the exact stated window) passes either way, which is what proves the clamp is conditional rather than a blanket shrink of the arm.

    Pre-flight: tsc --noEmit clean; adjacent suites green (context-overflow 25, issue2313-probe-forward 4, issue496-forward-once-learn 1, headroom-output-fallback 6, fallback-window-floor 2, e2e-overflow-selfheal 3, e2e-relay-5xx-selfheal 3, issue3004-nudge-decide-selfheal 7, native-intercept-selfheal 22 — all 0 failures).

    Merging stays yours per AGENTS.md §4.

  2. ranxianglei commented on Oct 18, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    查重: 不重复 —— 但同作者的 PR #3739 已在 issue 后 4 分钟提交,实现的就是本 issue 提出的 clamp 修复(Closes #3738)。因此不再另开并行 PR;我的工作改为:①独立复核 bug 在 v0.1.200/master 的代码证据,②按 PR Review 规范独立审 #3739(不复读 issue 结论),③定向跑测试后给出八问结论。

    标签: bug dsh scope/kernel p1(缺陷;dsh Desktop 客户端;上下文管理核心自恢复逻辑;p1——overflow 后的会话永不收敛、无限 400 循环、队列全部停止,用户无法继续该会话)

    开始验证,稍后在本线程与 #3739 各给结论。

  3. added a commit that references this issue on Oct 18, 2026
  4. ranxianglei commented on Oct 18, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    修复 PR #3739 的独立审核已完成,结论:可合(八问全文见该 PR 线程)。要点:bug 真实性已在 master 代码上红绿复现钉死;arm 写入点 clamp 到预留判定线是根因层修复(恢复 #1839 的 arm ≤ 判定窗口契约),消费者回归清点无破坏;远程 CI 全绿(含我推的一个 docs-only 小修 728b15f69:注释里未填的 #N+1: 占位符改为 #3738)。合并留给人工:#3739

  5. ranxianglei commented on Oct 18, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷
    (ai: vllm-qwen/qwen3.8-27b)

    独立验证完成,PR #3739 审核进行中(本 issue 与 #3739 是同一件事:你报的问题 + 你的修复 PR,按「一个 issue 一个 PR」由 PR 承载收工)。

    1. 缺陷在 v0.1.200 tag 上确认存在(不是版本滞后):armOverflowShrink 把上游声明的原始窗口(1,048,576)原样存进 lastInputTokens/overflowArmTokens,而 preflight 的判定目标是 headroom 预留后的 792,576——arm 高于它要服务的判定上限。

    2. 机制我独立走了一遍代码(不复述你的分析,结论一致且补了一层):meter 在每次 fold 提交后会递减(stream/absorb 路径),所以 pre-fix 的 fold walk 要收敛必须攒够「真实缺口(≈49K) + 整个预留额(256K)」的节省量——foldable 质量不够时每一轮都 exhausted → fail-fast → 死循环;够时也会多折叠最多 256K 的可恢复上下文。另外查到一个相关事实:#453 的 max_tokens clamp 在 v0.1.200/v0.1.201 就已存在,事故那次 400 能带着完整 256K 预算发出去,正是因为 planner 侧对输入的计量低于预留上限(估算偏差类),clamp 判定「放得下」而放行——arm 是这种场景下唯一的上游实证锚点,它存高了后续每轮判定都要为幻影缺口买单。

    3. PR #3739 审核进展(已直接在你的分支上补了东西,均为独立标注 commit):

    • ✅ 注释里的 #N+1: 占位符 → 改为引用 #3738;
    • ✅ 测试文件头部的机制叙述改精确(原叙述「每个 band 都读成 fits」与代码实际路径不符,已改为 floor/target 算术表述);
    • 🔧 正在补第 3 个测试用例:turn-2 收敛的行为级回归(arm 钳制后下一轮 preflight 真正折到预留线以下并成功转发;红绿对照:无 clamp 时 arm=100,000≠40,000 断言失败)。首跑挂起,正在排查用例自身的 mock/时序问题(不影响前两个用例,它们已通过红绿验证)。

    完整的结构化八问审核结论会贴到 #3739(含回归清点、行为变更披露、可合判定)。合并仍归你(AGENTS.md §4)。

  6. ranxianglei commented on Oct 18, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷
    (ai: vllm-qwen/qwen3.8-27b)

    收工判定:缺陷确认、修复经独立审核通过,由 PR #3739 承载,待 CI 绿后人工合并。

    结构化八问(triage 结论)

    1. 是什么问题:上游溢出 400 后武装的紧急收缩基线(overflow-arm)被存成声明的原始窗口(1,048,576),高于内核实际判定的 headroom 预留线(792,576),恢复路径每轮要多付一份预留额的节省量 → 大窗口+大输出预算会话陷入无界 400/fail-fast 循环。
    2. 什么原因导致的:arming 处把「上游实证的窗口上限」与「预留后的判定尺度」混用,未与 effectiveContextLimit 对齐。独立代码走读进一步钉死两点:meter 随 fold 提交递减,所以 pre-fix 收敛条件是 s > 真实缺口+整个预留额;且 使用pi 压缩后就提示prompt is too long #1195 请求内 refold 的门也会被虚高 arm 顶开(实测 deficit=0 时仍强制折叠销毁上下文)。另查实:plugin mode: no hard backstop when the agent ignores the compress nudge — context grows past the window and upstream 400s #453 max_tokens clamp 在 v0.1.200/201 已存在,那次 400 能带着完整预算发出是因为 planner 侧计量低于真实 payload(估算偏差类)而放行——arm 是唯一的上游实证锚点,存高即全线失真。
    3. 影响范围与严重程度:需同时满足「模型声明输出预算(预留额>0)」+「发生上游溢出 400」;命中后会话无法自愈、只能重启。p1。
    4. 怎么修复的:arm 钳制到 min(stated, reserved)——条件式钳制,无预留时逐字节同旧行为;overflowArmTokens 锁步更新(「BUG」sideRequestGuard 会永久拦截进程内第三方插件的 LLM 调用,导致与MemOS冲突 #1110 guard)。
    5. 是否有回归问题:逐项审过——无预留路径字节级不变(对照用例钉住)、两个 arming 调用点一致、fail-fast 413 / dsh压缩与bili压缩冲突 #2490 doomed-probe / PR #489 后续: 默认图片计费 b64/4 在像素 tile 计费上游(官方 Anthropic/OpenAI)会硬 502 误杀多模态会话 #496 gate 未触碰、有预留路径变化全部朝「按真实尺度收敛」方向。未发现回归。
    6. 是否破坏缓存:否(不改正常轮次线上形状,折叠产物本就是 summary 块)。
    7. 是否改了设计方向:否,恢复既有不变量。
    8. 是否引入新配置:否。

    标签:bug / dsh(事故客户端 DeepSeek Desktop)/ scope/kernel(上下文管理核心逻辑;kernel 源码未动)/ p1(触发后会话不可用直至重启)。

    交付:PR #3739(Closes 本 issue)。审核中已在该分支追加一个测试 commit:turn-2 收敛的行为级回归(mock 强制声明窗口,红绿对照本地实测:pre-fix case1/case3 FAIL、case2 PASS;post-fix 3/3 PASS),并修正了测试头部的机制叙述;#N+1: 占位符你已自行修复(728b15f69)。相邻定向套件全绿、typecheck 干净,全量以 CI 为准。合并归你(AGENTS.md §4)。

  7. Sieuno commented on Oct 19, 2026

    @Sieuno

    补充一个 0.1.201 / DSH / DeepSeek Anthropic Messages 兼容端点的真实案例,供确认 #3739 是否覆盖。已看到 #3738 关闭、修复进入 0.1.202;本案例发生在 0.1.201,尚未在 0.1.202 复测,不是在声称新版本回归。本评论由用户授权的 OpenClaw Agent 根据脱敏日志整理发布。

    与原报告的差异

    • Windows,DSH desktop profile;事发时检查的 DSH runtime 为 0.2.1-rc.2,桌面壳构建号未核实。
    • billion-context@0.1.201,面板构建 ff42dcc1,shadow 存储。
    • 模型 deepseek-flash,端点 https://api.deepseek.com/anthropic/v1/messages;最终成功走 deepseek-official API Key 路由。
    • 最初输出预留 256000;随后已降为 32768,但输入本身仍超过上游窗口,因此并非仅高输出预留造成。
    • 按上述 URL 前缀/模型设置 compress.modelContextLimit=700000 并应用后,日志确认普通预检 target 为 700000;但后续同会话的 overflow 请求内重试 target 变成 1048576。

    上游错误数字如下,均为上游返回值,不是本地估算:

    messages completion requested total 上游上限
    1216613 256000 1472613 1048576
    1153358 32768 1186126 1048576
    1117786 32768 1150554 1048576

    同一会话的关键序列

    以下为 2026-10-18 的 UTC 日志时间,对应北京时间 10 月 19 日凌晨;SESSION_C 是稳定匿名别名。

    1. 17:33:44Z:约 1557491 本地估算,预检 target 700000。
    2. 17:46:44Z:32 段压缩后汇总 1557491 → 904983,仍有 130 个可压范围;预算耗尽,fail-fast 502。此次超窗主请求没有转发。
    3. 17:46:48Z:后续同会话请求命中 estimate-only evidence probe,转发一次。该请求由客户端自动恢复还是用户动作触发,未严格确认。
    4. 上游 overflow 后,17:46:52Z 请求内重试日志 target 为 1048576,不是 operator 配置的 700000;仅再折叠 1 段,仍收到 400:1117786 + 32768 = 1150554。
    5. 17:49:25Z:在同一会话底部正常追加继续消息,不分支/不编辑历史,target 又为 700000;沿用已有压缩成果继续折叠。
    6. 18:01:12Z:成功取得上游 usage input=773909 cached=5888,后续思考/工具调用继续,用户确认本轮跑通。

    最终同会话已恢复,压缩确实执行且成果可续用。不是“不会压缩”“全部状态丢失”或“永久无法恢复”。 700K 是本地保守预算,不是上游硬上限;成功输入 773909 不代表违反真实模型上限。各日志计量口径不同,不将 baseline、local-est、循环 payload 与真实 usage 混算。

    希望维护者确认的两个边界

    1. 显式 operator limit 与请求内 retry 的优先级:[PR] fix: clamp the overflow-arm to the headroom-reserved judging limit #3739 修的是 overflow-arm 与 effective judging limit 对齐,是否也覆盖请求内 refold 的 target?本例普通预检 700000 → probe → retry 1048576 → 下一次普通请求又 700000,是否为预期行为?
    2. DeepSeek Messages 的 input+output 限制:文档/实现按 Anthropic wire 排除 output-headroom reservation,而此兼容端点明确对 messages + completion 求和校验。是否需要按 upstream/model 能力区分,而不是仅按 wire protocol?这只是兼容性线索,不认为它解释了全部输入估算偏差。

    只读核对时,当前 master 的 src/util.ts 中 shouldReserveOutputHeadroom 仍为 protocol !== "anthropic";src/server/handle.ts 请求内 retry 将 realWindow 传入 pipeline,预检调用处仍有 overflowWindow !== undefined ? { ...reqConfig, modelContextLimit: overflowWindow } : reqConfig。这是静态线索,未做新的端到端复现,不能据此宣称 0.1.202 仍必现。

    恢复 workaround 为:当前实际 provider 的输出预算 32768、上述模型的 operator limit 700000、应用路由配置,并在同一会话、不编辑历史或新建分支的前提下继续。32 轮耗尽后已有成果保留,后续继续压缩成功。

    另有 fork adoption 404 / snapshot 409 的伴随记录,但 #2469 / #2821 已有相关讨论,且 404 后还出现备用 resume-inheritance;本评论不将其定性为全部状态丢失,也不将 shadow DIGEST DRIFT 定性为根因。

    目前没有可公开的确定性最小转录;完整项目历史、请求 body、凭据均未附。若上述 Messages / operator-limit 差异应独立跟踪,请告知,我们再拆分,先在此补充以避免重复立项。

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions