Skip to content

(fix) stop compressing injected AGENTS.md rows — break the compress→re-inject loop and protect the real last user turn - #93

Merged
Tyan66666 merged 7 commits into
mainfrom
fix/instruction-barrier
Sep 11, 2026
Merged

(fix) stop compressing injected AGENTS.md rows — break the compress→re-inject loop and protect the real last user turn#93
Tyan66666 merged 7 commits into
mainfrom
fix/instruction-barrier

Conversation

@Tyan66666

@Tyan66666 Tyan66666 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Refs #71 — 本 PR 是 issue #71 修复方案的第 1 步(PR2 随后单独开,故不关闭该 issue)。

问题

长会话里,宿主会把 AGENTS.md 等策略文件的当前副本注入会话表面("指令行")。实测发现两个互相纠缠的 bug:

  1. 压缩→重注死循环:指令行按普通文本参与压缩。模型一旦把某文件的当前副本压进摘要块,它就从表面消失;宿主发现"这份文件不在了",下一步立刻重贴一份。token 原样回来,模型继续压缩,无限循环。实测:一个长会话 43 次压缩里 20 次在 7 个事件内触发了重注入;本会话也实时复现了一次(seq 8 被压缩 → 宿主在 seq 53191 重贴)。
  2. "保护最后一条用户消息"保护错了对象:范围表的尾扫逻辑是"保护最后一条非 checkpoint 的 user/message"——而表面上最后一条 user/message 经常就是宿主刚注入的 AGENTS.md 副本(它和真实用户输入在同一个 enter 批次里追加)。结果:真实用户消息 unprotected、可被压缩,合成注入行反而稳坐保护席。

原因

buildCompressibleSeqRanges(src/region.ts)完全没有"这是宿主注入的策略行"的概念;尾扫用 user/message && !isCheckpointNode 匹配,无法区分真实用户轮次和注入行。

修复

  • src/messages.ts:新增 classifySurfaceEvent(real / metadata / checkpoint / instruction 四类;未知 plugin 名保守归 instruction——未来宿主新增注入绝不能悄悄变成可压缩内容)、isAgentInstructionsRow(两种实测宿主形态:kind:'agent-instructions' 和 legacy kind:'plugin'+plugin:'agent-instructions')、isRealUserTurnisCheckpointNode 从 region.ts 移来统一为一份实现。
    • 宿主的三种"内容型 plugin 行"(@deepseek-ai/dsh-system-prompt 动态上下文快照、user-approval 审批提示、tools-ptc 延迟工具上下文)归为 real:它们不是用户说话,但可以像 main 一样被折叠。早前的形态把每个 plugin 行都当策略屏障,等于悄悄关掉了那里的折叠。
  • src/region.ts buildCompressibleSeqRanges
    • 指令行是屏障:清空当前段、整行跳过——范围表永不提供任何指令行(含过期副本);
    • newestInstructionSeqsOf:按 source.changes[].scope(= 一个文件;每个 worktree 是独立 scope)分组,每组最新副本额外 pin 进保护集(双保险)。没有 changes[] 的 legacy 行被跳过:没有 scope,宿主的"存在性门"就认不出是哪个文件,这种行不可能被重注,守卫它只会让覆盖它的每一段都变成永久硬拒绝(范围表仍然把它当屏障);
    • 尾扫改用 isRealUserTurn:只有真实用户轮次能赢得"最后一条用户消息"保护。
  • src/tools.ts:手动压缩范围若覆盖某文件的当前副本,直接拒绝guardedRowsInSpan + protectedRowRejectionNote,由 guardedSurfaceSeqsOf 供集)——报错点名行号、说明宿主必重贴(压了等于白压),并提示「过期的旧副本可以压」。拒绝发生在 kernel 应用之前,不产生幽灵块。(初稿是警告,评审中推翻:压当前副本没有合法收益,硬拒绝与 PR2 的铁律「组内最新永不清」保持一致。)
    • 拒绝文案还会列出这段里仍可压缩的切片(例如 seq 10..12 and 30..31),让模型直接改切或拆成两次调用,而不是重试同一个调用;
    • 探测的是位置跨段shadowedSeqsOf,即事务真正会遮蔽的那串 seq),不是 start <= seq <= end 的数值区间——表面在多次替换后是局部非单调的(checkpoint 会被插到更老的残留节点前面),所以「两个 checkpoint 的 T2 蒸馏」的数值边缘内可能正好夹着一条当前指令行,而实际跨段并不含它;数值区间版会拒掉 nudge 直接递给模型的那个调用。用同一个 shadowedSeqsOf 也让"守卫探测的范围"和"事务定价/校验的范围"永远一致。
  • src/nudge.ts:删掉 region.ts 为它导出的那份 checkpoint 判定副本,改用 src/messages.ts 的 isCheckpointNode——同一语义不再有两处实现。
  • src/commands.ts:人用的 /acp compress 命令复用同两个函数施加同样的拒绝——压缩当前副本对谁都省不下 token(宿主无条件重贴),显式意图不能推翻这个算术;过期副本照常可压。

验证

  • npm run typecheck 干净;npm test 256/256 通过。
  • tests/instruction-barrier.test.ts(14 个用例)覆盖:分类器分桶(含未知 plugin 保守归类、三种内容型 plugin 行归 real);屏障切段;端到端 compress 硬拒绝(含「只含过期副本照常压缩」「无指令行不拒绝」两个反例);尾扫回归(真实用户消息受保护、注入行不进任何范围);按 scope 分组的最新副本判定(主目录与 worktree 各自独立);guarded 集收窄;拒绝文案格式与切片提示;/acp compress 命令同款拒绝。fixture 用实测宿主真实形态(kind/form/baseline/baselineIdentity/changes[{action,scope,path,digest}])。
  • 三条针对性用例(都是评审发现的测试盲区):
    • 屏障自己的变异杀手:一条被更新取代(非最新)的指令行——仅靠"pin 最新副本"切不开表面,只有屏障能挡住它(早前用例被 pin 顺带覆盖,删掉屏障照样绿);
    • 位置跨段 vs 数值区间:构造局部非单调表面(cp1 < 当前指令行 < cp2),断言位置跨段不含该行、而数值区间会误报;
    • changes[] 的 legacy 行不被守卫、不被 pin,但仍然是屏障。
  • 变异验证:把屏障改回"照常提供"→ 只有屏障那条用例红;把内容型 plugin 行改回 instruction → 对应用例红。

合并 main

main 在此期间前进了 27 个提交,本分支已合并 main 并解决冲突:AGENTS.md / README.md / README.en.md 取 main 版本再补回本分支新增;src/region.ts 保留 main 的 0.1.5 seam 写法(eventAtOf 取代已移除的 session.events),尾扫在 main 的取事件写法上改用 isRealUserTurn。合并同时删掉了 main 的局部 isPruneTombstoneisRealUserTurn 是它的严格超集(checkpoint 自成一类,引擎自写的墓碑归 metadata),留着就是两份分类逻辑。本分支不携带 dist/(按仓库现行政策由 dist-bot 在合并后重建)。

文档

README.md / README.en.md「工作原理」表新增"注入指令行卫生"一行;AGENTS.md 模块图更新 messages.ts / region.ts 职责,新增铁律 16(合并时主线已占用 13/14/15,故规则号从 13 顺延为 16),含全部实测证据与行号锚点。

issue #71 方案的第 1 步(PR1=修死循环);第 2 步(PR2=系统侧自动清理过期副本,scope 分组 + 两条铁律)随后单独开 PR。

…rotect the real last user turn

Compressing the current copy of an injected AGENTS.md row makes the host
re-inject the same file on its next step, so a model that follows the
nudge loops compress -> re-inject -> compress forever (live: 20 of 43
compressions in one long session re-triggered an injection within 7
events). The range table's protected tail also had a swap bug: it
protected the injected row itself (frequently the last user/message on
the surface) and left the REAL last user message compressible.

Fix: classifySurfaceEvent buckets host policy rows (unknown plugins
conservatively), instruction rows are barriers in the range table, the
newest row per scope is pinned, the tail scan uses isRealUserTurn, and
a manual compress that swallows a current row gains one warning line.
…truction row

Supersedes the warn-only draft within this same PR (owner decision during
review): compressing a CURRENT instruction row has no legitimate outcome —
the host re-injects the newest AGENTS.md copy unconditionally the moment it
leaves the surface, so the tokens come straight back and the call is pure
waste. A hard reject before the kernel apply (no phantom blocks) keeps the
manual path consistent with the system-side GC's planned iron rule: never
clear a group's newest row. Stale copies stay compressible — removing them
while the newest stays visible triggers no re-injection, and the rejection
note points the model at exactly that escape.
…ruction-row reject

Compressing a CURRENT injected AGENTS.md row reclaims nothing for ANY caller
— the host re-injects the newest copy unconditionally. The model tool already
hard-rejects such ranges (d366ad1); this closes the /acp compress carve-out
with the same two helpers, so explicit human intent cannot bypass the
arithmetic either. Stale copies stay compressible.
dist/ is committed since issue #92 (git-source installs must ship runnable
artifacts), and CI fails when the committed artifacts drift from src/.
This commit rebuilds dist after merging release v0.2.17 into the branch so
the PR carries the artifacts matching its own source.
@ranxianglei

Copy link
Copy Markdown
Contributor

我来协助看一下这个问题,请分析并回复处理结果。

Resolve the conflict surface after main moved 27 commits ahead (#134, #135,
#137, #140, #142 + dist-bot refreshes):

- AGENTS.md: keep main's rules 13/14/15 and its block-ledger.ts module-map row;
  renumber this branch's rule 13 -> 16 and add a merge note.
- README.md / README.en.md: keep main's updated shadow-price rows, re-add the
  injected-instruction hygiene row.
- src/region.ts: keep main's isSystemNode; drop the local isCheckpointNode
  (it now lives in src/messages.ts as the shared classifier) and the local
  isPruneTombstone (isRealUserTurn already files engine-authored rows under
  `metadata`, so they cannot win tail protection); adopt main's eventAtOf()
  accessor in the tail scan.
- Migrate this branch's new code off `session.events` (removed by the 0.1.5
  seam, #137): newestInstructionSeqsOf reads sessionEventsOf(session),
  guardedSurfaceSeqsOf reads eventAtOf(session, seq), and the tests use
  sessionEventsOf() so they run on both host generations.
- dist/: dropped the branch's committed dist per the current dist-bot policy;
  dist is now byte-identical to main.

Gates: npm run typecheck clean, npm test 250/250.
…-content parity, leftover-helper cleanup

Two read-only reviews (logic + merge audit) and a source pass turned up five real
problems, all fixed here:

- The docblock above guardedSurfaceSeqsOf still described the abandoned
  "warn, don't reject" draft, so the code and its own comment disagreed about
  whether a covering range is refused. The comment now says what the code does.
- The compress guard probed a numeric `start <= seq <= end` interval. After
  earlier replacements the surface is locally non-monotonic (a checkpoint
  spliced ahead of older residual nodes), so a tier-2 distill of two checkpoints
  can carry a current instruction row numerically inside its edges while the
  span actually shadowed excludes it — the interval form rejected the exact call
  the nudge hands the model. The guard now probes the positional span
  (`shadowedSeqsOf`), the same list the transaction prices and verifies.
- A row without `changes[]` counted as "newest copy of its file", which made
  every span over one a permanent hard reject. With no scope the host cannot
  re-inject such a row, so guarding it was pure loss; the range table still
  barriers it.
- Every `plugin` row counted as a policy barrier, which silently stopped folding
  host content rows that main used to compress. Only agent instructions and
  policy kinds barrier now (`skill-catalog` included); unknown plugin names
  still fall to instruction, so a future injection stays protected by default.
- nudge.ts carried a second copy of the checkpoint-node predicate (region.ts
  exported one for it), so a checkpoint shape change had two places to miss.
  It now uses the shared `isCheckpointNode` from messages.ts.

Tests: the barrier's mutation killer only held because the newest-row pin alone
already split the surface. A non-newest row now pins the barrier itself. Four
tests added (positional guard, host-content parity, identity-less row,
slice-naming rejection) and two updated to the new contracts.

Verified: `tsc --noEmit` clean, `npm test` 256/256.
@Tyan66666
Tyan66666 merged commit d63317c into main Sep 11, 2026
4 checks passed
@Tyan66666
Tyan66666 deleted the fix/instruction-barrier branch September 11, 2026 10:16
ranxianglei pushed a commit to ranxianglei/billion-context-dsh that referenced this pull request Sep 11, 2026
Pulls in main past the branch base (215a54c): the Tyan66666#93 instruction-row-barrier fix
(d63317c — stop compressing injected AGENTS.md rows, break the compress→re-inject
loop, protect the real last user turn) plus its dist refresh.

Only AGENTS.md conflicted — both sides had extended the same region.ts module-map
line. Resolved by keeping BOTH notes (decompress paging Tyan66666#112 + instruction-row
barriers rule 16). Every code file auto-merged cleanly.

Verified on the merged tree: typecheck clean, 261/261 tests pass (the extra tests
are main's new instruction-barrier coverage).
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.

2 participants