Skip to content

fix: side-request guard must not clamp on the host usage baseline (#1110) - #1111

Merged
ranxianglei merged 1 commit into
masterfrom
2026-09-22_side-guard-overflow-arm
Sep 22, 2026
Merged

ranxianglei merged 1 commit into
masterfrom
2026-09-22_side-guard-overflow-arm

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What & why

Fixes #1110.

The side-request guard (sideRequestGuard, #554) decides whether a verbatim
passthrough request would be a guaranteed upstream 400 by comparing its raw
body against an "effective context window". That effective window was being
clamped by session.stats.lastInputTokens whenever lastInputTokensSource was
"usage":

const armedForGuard = session.stats.lastInputTokensSource === "usage"
    ? session.stats.lastInputTokens : 0;   // src/server.ts

But lastInputTokens is also the nudge baseline that every healthy host turn
writes from its real usage report. A compressed host conversation keeps that
baseline low (reporter observed 2.6k–3.4k). So an unrelated in-process caller's
fixed-size request (MemOS retrieval filter: no tools, max_tokens <= 200,
~4.3k tokens) was clamped to that low baseline and rejected with HTTP 413
side_request_payload_too_large permanently — it fit the model's real window
(256k) yet could never pass, with no config/env escape hatch. Reporter saw 26 of
37 filter calls fail over ~15h.

Root cause: the caller conflated two different quantities that share
source === "usage":

  • a normal successful turn's input size (the nudge baseline), and
  • the one-shot emergency arm left by an upstream context-overflow 400.

The guard's own contract (#554/#987) only ever wanted the second.

Fix

Decouple them with a dedicated field, session.stats.overflowArmTokens:

  • set only by the overflow handler (both the stated-window arm and the
    non-parseable/payload-size arm),
  • cleared by the next real usage report (src/loop/core.ts streaming,
    src/server.ts non-streaming, src/plugin.ts plugin-mode) and on compression
    reset (src/session.ts) — matching the documented one-shot semantics,
  • read by the guard caller instead of lastInputTokens.

A healthy compressed host baseline can no longer clamp any unrelated request,
while a genuine overflow arm still tightens the guard exactly as before.

Files: src/session.ts (field + reset), src/server.ts (guard caller + both
overflow writes + success clear), src/loop/core.ts, src/plugin.ts (success
clears), src/persist.ts (optional persisted field — backward-compatible, legacy
files lack it → treated as no arm).

Tests

Pre-flight on the rebased head (current master d3bb77d):

  • npm run typecheck ✅
  • npm test ✅ (2190 pass / 0 fail / 2 skipped — default-skipped E2E)
  • npm run build ✅

Note: the real-client e2e-codex suite needs a reachable upstream
(E2E_UPSTREAM_URL); none is reachable in this environment, so it was not run
locally. The change is confined to the side-request guard's input value plus one
new optional stats field (cleared in normal flow) and does not alter the main
compression pipeline; the in-process side-request e2e tests, the overflow-selfheal
suite, and the full 2190-test run cover the touched paths.

Scope note (not in this PR)

#1110 also surfaces a broader question — bili's process-level globalThis.fetch
intercept proxies unrelated in-process callers at all. This PR fixes the acute,
reproducible 413 (the guard). Whether/how to scope the fetch intercept away from
unrelated callers is a larger design decision and is left as a follow-up for owner
discussion rather than bundled here.

)

The guard's effective window was fed session.stats.lastInputTokens whenever lastInputTokensSource was 'usage' — but that value is ALSO what a healthy compressed host turn writes as its nudge baseline. A low baseline (e.g. 3k after compression) permanently 413'd an unrelated in-process caller's fixed-size request (MemOS retrieval filter) even though it fit the model's real window, with no config/env escape hatch.

Decouple the two: add stats.overflowArmTokens, set ONLY by an upstream context-overflow 400 (the one-shot emergency arm per #554/#987) and cleared by the next real usage report and on compression reset. The guard reads overflowArmTokens, never the baseline. Existing overflow-arm e2e still pins the arm path; new e2e regression reproduces the 413 pre-fix and passes post-fix.
@github-actions

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-22_side-guard-overflow-arm (6b7c165)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-1111

Each push to this PR publishes a new version under the pr-1111 npm tag.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr1111.tgz
npm install -g package

Install instructions are refreshed on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 收到。这是已完成的 PR(#1111 → 分支 2026-09-22_side-guard-overflow-arm),我不重做,现在做核对:拉取分支审 diff、本地跑 typecheck/test/build、查 CI 状态,随后在此汇报结论。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 PR #1111 核对完成 — 全部通过,可以合并(合并由人工操作)。

分支状态:单 commit 6b7c165,已 rebase 到当前 master d3bb77d,GitHub mergeable_state: clean。diff 干净:+73/−1,6 个文件,每一行都与本 PR 目的相关(无跑题改动、无空白重排、无锁文件波动)。

代码审查(overflowArmTokens 全量写点审计):

  • 全仓共 5 处写 lastInputTokensSource = "usage":src/server.ts:4089(overflow 显式窗口 arm)、src/server.ts:4113(overflow 不可解析 arm)— 这两处同时写 overflowArmTokens;src/server.ts:4588(非流式成功)、src/plugin.ts:915(plugin 模式)、src/loop/core.ts:208(流式 recordUsage)— 这三处同时 delete overflowArmTokens。即:任何真实 usage 报告都会退役 arm,任何 arm 都只来自 overflow 400,与 side request(max_tokens<=200)绕过 preflight 且不检查载荷体积,携带全量历史直转上游导致 400 #554/context window problem continues #987 的一次性语义完全一致。
  • guard 调用点(src/server.ts:1567)现在只读 overflowArmTokens;真 overflow arm 时取值与旧行为相同(两处 handler 里 lastInputTokens 和 overflowArmTokens 写同一值),健康 host 基线不再参与 clamp。
  • resetSessionCompression(src/session.ts:413)压缩重建时清 arm;persist 为可选字段,legacy 文件缺省 = 无 arm,向后兼容。
  • 两种压缩模式(plugin/proxy)均只影响 side-request guard 的输入值,不触碰主压缩管线、wire shape、config schema、persistence version —— 符合 §6 双模式要求。

本地 pre-flight(rebased head):

  • npm run typecheck ✅
  • npm test ✅ 2190 pass / 0 fail / 2 skipped(默认跳过的 E2E)
  • npm run build ✅

A/B 回归证据(我实际跑了):

CI:head 6b7c165 上 8/8 checks 全绿(test ubuntu/windows × node22/24、build-artifact、version-guard、codex 两项)。

一点提示:PR 的 Scope note 里留了 bili 进程级 globalThis.fetch 拦截会波及无关 in-process caller 这个更大的设计问题(#1110 的另一面)。这是方向性决策(要不要做、怎么圈定范围),按规范应由你拍板——需要的话我可以另立带来源标记的 issue 跟踪,等你一句话。

中文摘要:修复了 side-request guard 误用健康 host 的 nudge 基线(lastInputTokens)当上限、导致无关 in-process 请求被永久 413 的问题(根因是混用了"正常回合用量"与"overflow 一次性 arm"两个共享 source=usage 的量,现拆出独立字段 overflowArmTokens);已验证未修复时该 e2e 报 413、修复后全绿且 CI 8/8 通过,可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Real-world validation from the reporter (@Pasitearko, in #1110): built 0.1.135-pr.1111.997 (dist-tag pr-1111) and tested on Windows 11 / node v24.20.0 / DSH 0.1.5-rc.2 (standalone web profile) / memOS 2.0.20 in-process plugin / model deepseek-flash (max_tokens 256000).

The triggering calls are memOS background retrieval-filter requests: no tools, max_tokens = Math.min(2048, Math.max(160, candidates*8+80)) → always 160–200 → always isSideRequest, so always subject to the guard.

  • Pre-fix: same operation, 46 calls → 26 fail / 20 pass. Every failure was HTTP 413 side_request_payload_too_large, estimated payload 3553–5678 tokens vs. a reported effective window of only 2641–3390, 12–41ms each (local reject, never forwarded), persisting 09-21 19:12 → 09-22 10:57 with no self-recovery — the self-reinforcing deadlock from 「BUG」sideRequestGuard 会永久拦截进程内第三方插件的 LLM 调用,导致与MemOS冲突 #1110, caused by the guard clamping on the compressed host baseline (~2.6–3.4k).
  • Post-fix: 20/20 pass, zero failures; 538–1200ms (real upstream round-trip); passes on the first try after restart; overflowArmTokens present in the artifact and read only when actually armed.

Confirms the healthy-baseline / one-shot-overflow-arm decoupling holds end-to-end against a real client. The residual process-level fetch-scope concern (unrelated in-process callers being proxied) is tracked separately in #1117 and is out of scope for this PR.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 收到真实客户端验证数据——证据链闭环了,我补了两项独立确认:

验证结果与我此前的本地结论完全互证:

健康基线 / one-shot overflow arm 的解耦在真实客户端(Windows 11 / node v24.20.0 / DSH 0.1.5-rc.2 / memOS 2.0.20 in-process plugin)下端到端成立。本 PR 侧无需任何进一步动作,合并等你人工操作:#1111

中文摘要:真机验证(修复前 26/46 全部 413 死循环 → 修复后 20/20 通过)与此前本地 A/B 复现、CI 8/8 完全吻合,修复充分验证、无遗留动作,等待人工合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Independent Review: MERGE ✅ (human merge required — per AGENTS.md §7.4 this adds a persisted stats field + changes guard semantics, so it's "must stay human" territory; this review is that human gate's input.)

Verified independently, not from the self-review claims:

Check Result
New regression test on origin/master (pre-fix) Reproduces #1110 — fails with AssertionError: #1110: unrelated side request that fits the real window must forward, not be 413'd by the host baseline (11/12)
Test on PR head 12/12 pass
Typecheck / full suite (head) clean / 2192 tests, 0 fail, 2 skipped (same 2 pre-existing E2E gates as master)
Rebase onto origin/master clean; rebased head full suite 2197/2195/0 fail

Why the fix is root-cause level

  • Guard has exactly one call site (src/server.ts:1568) and now reads only overflowArmTokens (src/server.ts:1567).
  • Enumerated all five writers of lastInputTokensSource = "usage": the two arm paths (server.ts:4089→4092, 4113→4114 — both also write the arm) and three usage-report paths, each paired with delete …overflowArmTokens (src/loop/core.ts:208→210, src/server.ts:4588→4590, src/plugin.ts:915→917). No remaining path feeds a healthy baseline into the guard.
  • armFailureShrink (estimate grade, server.ts:3709) correctly never fed the guard either pre- or post-fix — side request(max_tokens<=200)绕过 preflight 且不检查载荷体积,携带全量历史直转上游导致 400 #554's "usage-grade evidence only" contract preserved.
  • side request(max_tokens<=200)绕过 preflight 且不检查载荷体积,携带全量历史直转上游导致 400 #554 tightening is NOT lost: existing unit + e2e arm→clamp tests still pass.
  • Migration is safe-direction: legacy files map to undefined arm (persist.ts:608); arm persists across restart via the stats spread (persist.ts:551) — one-shot-across-restart semantics intact.
  • Bonus correctness: pre-fix, post-arm compression shrank the guard's effective window below the stated one; post-fix the arm stays at window evidence grade. Correct.

Minor (non-blocking) — suggest a follow-up issue

One-shot release is untested. The e2e pins arm→block (deadlock repro) and #554 pins arm→clamp, but nothing asserts arm → real usage report → arm cleared → side request forwards again, nor the restart round-trip. The "one-shot" claim rests on three bare delete statements (all verified paired, risk low). A follow-up test would pin the contract — not worth a re-roll.

Informational

A session armed on the old code (arm persisted only inside lastInputTokens) loses the arm on upgrade: side requests forward instead of local-413 until a real overflow re-arms. Safe direction (worst case one upstream 400 that re-arms). No action needed.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 独立 review 收到——MERGE ✅ 作为人工合并门的输入已记录。你列的覆盖缺口我逐条核过了测试清单,完全一致:

已钉住(PR 分支上实际存在的测试):

未钉住:arm → 真实 usage report → delete overflowArmTokens(三处:src/loop/core.ts:208 / src/server.ts:4588 / src/plugin.ts:915)→ side request 恢复转发;以及 arm 的 persist→reload round-trip(src/persist.ts:608 / :551)。

按你的建议已立后续 issue:#1129 — https://github.com/ranxianglei/billion-context/issues/1129(带来源标记,含完整测试设计:三阶段 e2e + restart round-trip 变体,并标注非阻塞、不 gating 本 PR)。查重过 open+closed,无重复项。

另注:master 已前进到 aeeed25,PR head 仍为 6b7c165(base d3bb77d);你本地已验证对最新 master rebase 干净且全绿(2197/2195/0 fail),无需我再动分支。升级丢失 arm 的 informational 项也认同安全方向(最坏一次上游 400 重新 arm),不采取行动。

中文摘要:独立复核确认 one-shot 释放方向与 restart round-trip 无回归测试(pin 仅靠三处裸 delete),已按建议立项 #1129 跟踪补测(非阻塞);本 PR 证据链完整、rebase 已验证,等待人工合并。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

「BUG」sideRequestGuard 会永久拦截进程内第三方插件的 LLM 调用,导致与MemOS冲突

1 participant