Skip to content

fix(#2886): make recent-zone protection pair-aware (prevent orphaned function_call) - #2892

Merged
ranxianglei merged 3 commits into
masterfrom
2026-10-12_fix-2886-recent-pair-integrity
Oct 14, 2026
Merged

ranxianglei merged 3 commits into
masterfrom
2026-10-12_fix-2886-recent-pair-integrity

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

kernel-change: #2886 — computeProtectedRefs builds an asymmetric soft-protected zone: it can protect a recent tool-call while excluding its paired tool-result from the zone, because neverPreserveRecentTools drops big-result tools (read/bash/decompress/search_context) out of the visible set that the recent-N/token window scans. The advisory carve then keeps the call visible and folds the result, stranding an orphan function_call that strict OpenAI Responses validators reject with HTTP 400 invalid_request_error. The fix must live in the kernel because the recent-zone computation exists only in computeProtectedRefs; the host hook config.isMessageProtected(msg) receives only the message (no zone membership), so it cannot express pair-awareness.

What

Make the recent-protected zone symmetric per (tool-call, tool-result) pair: if either half (matched by toolCallId) lands in the protected set, protect both. The pass is monotonic — it only adds refs, never removes any — so no previously-protected set can shrink. It binds by toolCallId, not by tool name, so it holds even when the result lost its backfilled toolName.

Consumers fixed at once (single producer):

  • the recommendation node (compress.ts:1059 → buildCompressibleRanges) — what the plugin-mode agent is told is compressible vs protected;
  • the apply-time soft carve (compress.ts:1388).

Why the host side cannot solve it

The asymmetry is produced inside computeProtectedRefs (kernel/src/recommend.ts). A host-side remedy would need to know which messages are in the recent zone in order to co-protect a counterpart — but isMessageProtected(msg) has no such context, and isMessageProtectedWithPairing covers hard protectedTools pairing only, not the soft recent zone. Fixing the single source corrects both consumer paths simultaneously.

Behavior change (flagged per owner policy)

  • Old: a very-recent read/bash/decompress/search_context result whose call sits inside the recent zone could be folded immediately — the intended reclaimability that neverPreserveRecentTools provides.
  • New: that result is now co-protected until its paired call ages out of the zone, deferring compression of the most-recent big-tool-result by up to one zone window (defaults preserveRecentMessages=3 / preserveRecentTokens=5000). Pairs that have aged out of the zone are unchanged (still fully compressible).
  • Deliberate tradeoff: a bounded, safe reduction in aggressiveness for the freshest big results, in exchange for eliminating a hard upstream 400.

Deliberately NOT done

No wire sanitizer that drops orphan function_calls in coreToResponses. Silently dropping a tool call would lose user-intent data (a wire-fidelity violation — the #1039 anti-pattern). The pair-aware zone plus the existing turn-integrity gate already prevent the orphan.

Why the existing integrity gate does not suffice

The turn-integrity gate (kernel/src/turn-integrity.ts:138-212) already auto-withdraws to keep a linked (call, result) pair intact when a fold would split it — it is a genuine backstop on the proxy-side apply path (verified: ~4000-trial fuzz through the gated path yields zero surviving orphans). It does not suffice for this bug because it is a proxy-side, apply-time cleanup, not the component that decides what the agent folds:

  • In native plugin mode the agent acts on bili's recommendation (buildCompressibleRanges ← computeProtectedRefs), which was internally inconsistent: result = compressible while its paired call = recent-protected (the reporter's log line Excluded 1 protected message(s) m00051 …(recent/last-user zone) is bili computing exactly that asymmetric set). An agent told "result compressible, call protected" will attempt a fold that strands the pair.
  • Whether that fold then passes through a gated applyCompression (and gets cleaned up) or lands in the host's own context (src/stream.ts:697: "plugin-mode agents own their folds, so bili never sees those originals") decides whether the backstop ever sees it. In this report it did not.

Fixing the single producer makes the primary signal consistent and is the only lever bili has over an agent-owned fold; the gate stays as defense-in-depth for the proxy path.

Tests

  • New kernel/tests/issue2886-recent-pair-integrity.test.ts (5 tests). Tests 1–3 fail without the fix (verified by stashing recommend.ts and re-running); all pass with it. Test 4 guards against over-protection; test 5 pins the end-to-end "no stranded orphan at the boundary" invariant.
  • Kernel layer: 1283/1283 pass, typecheck clean, build clean.
  • Bili targeted (never-preserve-recent-tools, loop-responses-orphan, tool-pair-order, wire-contract, preflight-compress-responses, protected-tools): 53/53 pass; bili typecheck + build clean. Full global bili suite left to CI.

kernel/package.json bumped 0.0.119 → 0.0.120 (Kernel Boundary, CI-enforced). No change to the billion-context package version.

Closes #2886

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-10-12_fix-2886-recent-pair-integrity (393b6c9)

Option A — Install this exact build (recommended)

npm install -g billion-context@0.0.3608-pr.2892.42113dd

This exact version is immutable and reproducible. The rolling pr tag tracks the newest test build across all PRs.

Option B — Download artifact

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

Install instructions are refreshed on each push.

ranxianglei added a commit that referenced this pull request Oct 12, 2026
…2886)

Root package-lock.json still recorded acp-kernel 0.0.119 after the #2886 bump to 0.0.120 — the lock-sync gate failed on that one-line drift (#2892 review). Also syncs the two version fields in kernel/package-lock.json, which had been stale at 0.0.114 since before this PR (pre-existing); no other lockfile content touched.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Review 结论:方向、实现、测试均正确;发现并直接修复一处 CI 机械问题(lockfile 漂移),等重跑全绿即可合。

做了什么:拉取分支通读全 diff(kernel/src/recommend.ts +47、kernel/package.json 0.0.119→0.0.120、新测试 202 行),本地定向验证,核对 CI。未重做工作(识别为已完成的 agent PR)。

本地验证证据(worktree @ 66b4d3a00,后修复 @ b5cf9d7ee):

  • 新测试 5/5 过;把 recommend.ts 还原为 master 版重跑 → 测试 1–3 失败、4/5 通过,与 PR 描述一致(1–3 复现缺陷,4 防过度保护,5 是端到端不变量钉——它在无修复时也通过,因为该形状下 pair-boundary adjustment 已兜住,它钉的是「边界处不留孤儿」的总不变量而非复现路径);
  • 相邻 kernel 定向套件(protect-recent / message-protection / recommend / tool-pairs / ranges-consistency / recommend-range-integrity / orphan-fixes / regression-bugfixes)94/94 过;kernel typecheck clean;
  • PR 触碰文件与 master 无漂移,mergeable=true;BEHIND 不构成 blocker。

代码审查要点:

  • 修在单一 producer computeProtectedRefs(疏,不是堵):pair-closure 后置 pass 单调(只加 ref 不删)、按 toolCallId 绑定(不依赖 toolName,result 丢失回填名也成立)。单次遍历即完备——配对是双端点边(call↔result),无传递链。
  • 三个消费点一次修好:recommendNode→buildCompressibleRanges(plugin-mode agent 看到的可压区)、applyCompression 默认 protectedMessageIds(compress.ts:380)、applySingleRange soft carve(compress.ts:1379+)。两种压缩模式共用同一 producer,行为一致。
  • Golden pin 不受影响(defaultConfig/prompts/tool 表/wire markers 均未动);Kernel Boundary 合规(kernel-change 声明 ✓、版本 bump ✓、kernel: 前缀 ✓)。
  • 「不做 wire sanitizer 丢孤儿 call」的判断正确——那是 [bug] 插件透传会剥掉工具调用参数里的 render tag(arguments 流挂了 tag-echo 过滤器) #1039 反模式(wire-fidelity 违规)。

发现并直接修复的小问题(小问题直接修):CI lock-sync gate 红——root package-lock.json 仍记录 acp-kernel 0.0.119,与本 PR 的 0.0.120 bump 差一行,导致 test/static-gates 被跳过。已在 PR 分支补 additive commit b5cf9d7ee:root lockfile 一行 + kernel/package-lock.json 两个 version 字段(后者自 0.0.114 起就陈旧,属 pre-existing 的潜在 npm ci 陷阱,顺手同步;无其他内容变更)。CI 重跑中。


结构化八问:

  1. 是什么问题:recent 保护区不对称——zone 可能保护一个 tool-call 却不保护其配对 result,advisory carve 保留 call、折叠 result,留下孤儿 function_call,严格 OpenAI Responses validator 报 HTTP 400 invalid_request_error。
  2. 什么原因导致的:neverPreserveRecentTools 把大结果工具(read/bash/decompress/search_context)从 recent-N/token 窗口扫描的 visible 集里剔除,而配对的 call(名字不在名单内)仍可入区——名字级剔除造成 pair 两半不对称;host hook isMessageProtected(msg) 拿不到 zone 成员信息,无法表达 pair-awareness,所以必须修在 kernel 的唯一计算点。
  3. 影响范围与严重程度:触发条件 = very-recent 的大工具 result 落在 recent 窗口内且其 call 可被保护;后果 = 上游硬 400,该请求失败(非数据丢失);特定条件下出问题,频率取决于流量形态。
  4. 怎么修复的:在 computeProtectedRefs 末尾做 toolCallId pair-closure:任一半在保护集则补上另一半;单调只增不减,单 producer 同时修正推荐节点与 apply-time soft carve 两条消费路径。
  5. 是否有回归问题:审过的既有行为——Rule 1/2/3 区逻辑不变;aged-out pair 仍完全可压(test 4 钉住);单调性保证任何原保护集不会缩小;golden pin 不动;相邻 94 测试+新 5 测试本地全绿;全局 bili/kernel 套件以 CI 重跑为准。
  6. 是否破坏缓存:不涉及。缓存/重试/增量游标/去重键均未触碰;co-protection 只是把最新大结果的多折叠推迟至多一个 zone 窗口(默认 preserveRecentMessages=5 / preserveRecentTokens=5000),尾部更稳,对前缀缓存中性偏正。
  7. 是否改了设计方向:不涉及。同架构、在决策点修根因;明确拒绝 wire sanitizer 方案并给出理由([bug] 插件透传会剥掉工具调用参数里的 render tag(arguments 流挂了 tag-echo 过滤器) #1039 反模式)。
  8. 是否引入新配置:不涉及。无新 env/config/默认值;仅 kernel 版本 0.0.119→0.0.120(Kernel Boundary CI 强制)。

行为变更披露(owner policy):旧 = very-recent 大工具 result 可在 call 尚在区内时被立即折叠(neverPreserveRecentTools 的预期可回收性);新 = 该 result 与其 call 共同受保护直到 call 老化出区,最新大结果的压缩最多推迟一个 zone 窗口;已出区 pair 完全不变。有界且安全的激进度下降,换取消除硬 400。

判定:可合——待 b5cf9d7ee 触发的 CI 重跑全绿。kernel 变更属 human-only 合并档,请人工合并:#2892

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 CI 更新:head b5cf9d7ee 现已全绿,PR 可合并。

  • 主 CI:success——lock-sync(上一轮的红点,已由 b5cf9d7ee 修复)、全量 test 矩阵(ubuntu/windows × node 22/24)、static-gates、kernel-guard、version-guard 等全部通过。
  • 全部 e2e 工作流:success(E2E real codex、ci-image、e2e-native-dsh/pi/opencode、e2e-fake-codex、e2e-pi-codex-ws、e2e-local-registry、Windows codex overlay 等)。
  • 唯一插曲:e2e-native-opencode 的 "real opencode native lane" job 首轮撞上 15 分钟 job 超时被取消(annotation: "exceeded the maximum execution time of 15m0s")。判定为瞬时 runner/网络卡顿而非代码问题,依据:① 同一份代码在 12 分钟前(66b4d3a00,run 38221578814)该 job 59 秒跑完;② b5cf9d7ee 相对它只改了两个 lockfile 版本字符串,零运行时影响;③ 同 run 内兄弟 job 1 分钟正常完成。已 rerun-failed-jobs,重跑通过。

最终判定:代码审查结论不变(方向/实现/测试正确,详见上一条回复的结构化八问),CI 全绿,mergeable=true。kernel 变更属 human-only 合并档,请人工合并:#2892

computeProtectedRefs can protect a recent tool-call while excluding its paired tool-result (whose name matches neverPreserveRecentTools) from the zone, since that list drops big-result tools from the visible set. The advisory carve then keeps the call visible and folds the result, stranding an orphan function_call that strict Responses validators reject with HTTP 400 invalid_request_error. Make the recent zone symmetric per pair: if either half of a (tool-call, tool-result) matched by toolCallId is protected, protect both. Monotonic - only adds refs.
@ranxianglei
ranxianglei force-pushed the 2026-10-12_fix-2886-recent-pair-integrity branch from f0ad689 to 5eb1adb Compare October 13, 2026 16:35
@ranxianglei
ranxianglei merged commit 42e5b53 into master Oct 14, 2026
25 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

📦 Merged Build Published

This merge to master was published as 0.0.409-master.42e5b53 (rolling master tag).

npm install -g billion-context@0.0.409-master.42e5b53  # this exact build
npm install -g billion-context@master  # always the newest merged build

npm view billion-context@master prints the current version at any time.

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] neverPreserveRecentTools splits tool-call/result pairs at protected boundary, causing upstream 400 invalid_request_error

1 participant