Skip to content

Long-conversation scoped compaction produces "gap-residue" orphaned messages; recommend an orphan reclamation / defragmentation mechanism. | 长会话范围压缩产生'缝隙残留'孤儿消息,建议孤儿回收/碎片整理机制 #355

Open

Description

@cycle2zhou

问题背景

在一个超长 agent 会话(120K+ context,多次范围压缩)中观察到:compressible ranges 累积了 55 条"缝隙残留"消息,每条 146B~6K,合计约 60K context,且无法被自动清理。

成因分析

范围压缩(startId~endId)在多次执行后,范围边界之间的消息成为"孤儿":

  1. 单条 < minCompressRange(3000 字符)的消息,compress 工具拒绝单独压缩;
  2. 孤儿消息没有 block 覆盖,不会进入 tier 蒸馏(T1→T2→T3)的折叠范围;
  3. nudge 会持续把它们列为 compressible,但 agent 压掉 146B 的消息要花 ~100B 摘要,边际收益为负——于是永远留在缝隙里。

这是结构性必然:任何长会话只要用范围压缩,就会产生此类缝隙。

建议(按优先级)

1. 孤儿消息自动回收(Orphan sweep)— 根治缝隙残留

将 compressible 但无 block 覆盖且 < minCompressRange 的孤立消息自动并入最近邻 block 的摘要(作为补充行),而不是永远留在缝隙。或提供一次性的"碎片整理":允许把 N 条不连续的微残留归并成 1 个 block。

2. nudge/提示消息自身成为残留

每次压缩 nudge 注入的提示文本(约 150B,如"⚠️ Compressible ranges available...")会留在缝隙里,成为上述孤儿的主要来源之一。建议:nudge 消息标记为"可回收微块"(不参与保留窗口、可静默丢弃),或纳入已有的 messageFilters 体系。

3. minCompressRange 单位与文档

schema 定义其为 characters(3000 chars ≈ 750 tokens),但相邻配置(preserveRecentTokens、nudgeGrowthTokens)是 token,agent 极易误判阈值量级。建议统一为 token 或至少在配置注释/schema 描述中显著标注单位。

4. (低优先级)compress 支持"不连续片段一次成块"

允许一次调用把多个不相邻的 [start,end] 片段合并为单个 block,降低碎片整理的操作成本(当前每个 entry 必须连续)。

环境

  • opencode-acp 版本:@latest(2026-09 使用)
  • 复现场景:Sisyphus 风格 agent(大量 rmux/bash 工具输出 + 手动 compress + 长会话)

Activity

  1. ranxianglei commented on Sep 2, 2026

    @ranxianglei
    Owner

    ework接单 .
    这个billion-context 那边应该也需要 综合考虑

  2. ranxianglei commented on Sep 2, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 已接单。按流程先做审核(复现查证 + 方案评估),结论如下,待 owner 拍板后再进入实现。

    一、查证结果(对照 v1.14.26 代码)

    ✅ 成立的部分

    1. 缝隙孤儿是结构性事实:范围压缩后,边界之间的消息无 block 覆盖;T2/T3 蒸馏的候选只来自 active blocks(lib/messages/inject/inject.ts:444-447,candidates 取自 activeBlockIds → blocksById),raw 孤儿消息永远不会进入 tier 折叠。
    2. minCompressRange 是字符单位:lib/compress/range.ts:180-201 用 countMessageCharacters() 累计 totalChars 与阈值比较。但注意:默认值是 5000 chars(v1.12.4 从 2000 上调,lib/config.ts:249),不是 issue 里写的 3000 —— 请确认你的版本/配置。相邻配置(preserveRecentTokens、nudgeGrowthTokens)确实是 token 单位,代码内部已用 ÷4 启发式桥接(resolveEffectiveFloor,lib/messages/inject/utils.ts:947-952)。

    ⚠️ 需要修正的部分

    1. "nudge 持续把它们列为 compressible" 不准确:nudge 推荐列表会剔除 sub-floor 范围(filterRecommendedRanges,inject.ts:386 + utils.ts:986-989,effective < floor 直接 drop)。把所有缝隙都列出来的是 acp_status(lib/compress/status.ts 的 renderUncompressedRanges 不过 floor 过滤)——你看到的 55 条应该来自 acp_status 快照。
    2. "无法被自动清理 / 结构性必然" 被高估了:compress 工具的 min 检查是跨 entry 求和的(range.ts:182-193,按 messageId 去重后累加所有 entry 的字符数)——一次调用批量传 N 个 sub-floor 片段,总和 ≥5000 chars 即通过,每个 entry 各自成块。所以工具层面已经可以一次清多个缝隙。真正的问题是两个:
      • (a) nudge 不引导这种批量行为(sub-floor 范围被推荐列表剔除,模型不知道可以打包);
      • (b) 55 个微 entry → 55 个 block,每块都要模型写 summary + 上下文里留 placeholder,对 146B 的消息块开销 ≥ 原文,净收益 ≈ 0 或为负。所以"把每个孤儿单独成块"本身就不是解 —— 正确处置是并入相邻 block 或丢弃。
    3. 建议 2 的前提(nudge 文本留在缝隙)在代码里不成立:nudge 文本是 per-request 经 message transform 注入的(applyAnchoredNudges,inject.ts:417),不落 session 存储,不会跨轮累积。实际会持久化、可能成为孤儿的是:chat 模式压缩通知(pruneNotification ≠ "off",默认 off)、/acp 命令输出(host 行为)、以及小的真实消息(短 user 消息、system reminder 等)。另外 "⚠️ Compressible ranges available" 不是实际 nudge 文案(实际是 "⚠️ Context limit reached — time to compress...")。请提供你的 acp.jsonc(尤其 pruneNotification、minCompressRange)和一份 acp_status 快照,可以定位那 55 条的具体来源。
    4. 补充:55 条里 ≥5000 chars(≈1250 tokens)的条目单独就能压(工具不会拒绝),属于"模型没压"而非"压不了";真正卡死的只有 sub-floor 部分。

    二、方案评估与建议(明确推荐,非选择题)

    建议 评估 我的推荐
    1. 孤儿回收 方向正确,是根治 采纳,但改为"出生即吸收"设计(见下)
    2. nudge/合成消息可回收 前提部分不成立(见查证 5) 收窄为:ACP 生成的合成消息(chat 通知、命令输出)标记为可回收微块——不参与保留窗口、可静默丢弃。低风险,作为 1 的补充
    3. 单位文档 成立 不改单位(改了是 breaking change:现有 minCompressRange: 5000 的用户语义会从 5000 chars 变成 5000 tokens = 20000 chars)。在 schema description + CONFIGURATION.md(中英)显著标注 "chars, ≈ tokens×4";若要 token 字段走新增字段 + 弃用周期(有 minContextLimit 弃用先例)
    4. 不连续片段一次成块 同意低优先级,且紧迫性低于想象 多 entry 调用已存在且 min 检查跨 entry 求和(查证 4),批量整理操作成本已经不高;合并成单块涉及边界解析/placeholder/decompress 语义的架构改动。建议 1 落地后需求基本消失,搁置

    建议 1 的具体设计(推荐方案):

    • 吸收时机 = block 出生时:新 block 创建后,检查其边界紧邻的前/后消息(本次压缩新形成的缝隙)。孤儿满足以下条件即被吸收进新 block:低于 floor(< minCompressRange)、非 protected(BLOCKED ref 永不吸收)。
    • 吸收动作:把该消息的 messageId 并入 block 的 effectiveMessageIds(decompress 时可还原)+ 在 summary 尾部追加一行机械补充(如 residual: m00123 (bash, 146B)),不走模型摘要——sub-floor 内容不值得模型写 summary,这正是它卡死的原因。
    • 存量清理:提供 /acp defrag 一次性命令,对全部 block 跑同样的吸收逻辑(覆盖你现有的 55 条)。
    • 安全阀:加一个 config 开关,默认 off 先 burn-in 一版(同 qualityGate 的模式),稳定后转默认 on。
    • 缝隙在出生时就被消灭,长会话不再累积;protected 消息(Bug 39 硬排除产生的缝隙)除外,它们由建议 2 的可回收标记兜底。

    建议执行顺序:③ 单位文档(独立、可立即落)→ ① 吸收机制 + /acp defrag(核心)→ ② 合成消息可回收(补充)→ ④ 搁置。

    三、billion-context 协同(回应 @Dog)

    确认你的判断。现状:acp-kernel(v0.0.19)feed billion-context 和 billion-context-pi,opencode-acp 是平行 adapter(自带实现,不依赖 acp-kernel);既有模式就是三个 adapter 平行改(见 devlog 2026-08-12_release-v1.14.15、2026-08-10_hide-consumed-per-block 的 "same defect in acp-kernel — separate PR" 先例)。

    缝隙语义(范围压缩 + min floor + T2/T3 只折 block)在三个 adapter 里都存在,所以:

    1. 吸收语义只设计一次——spec 写在本 repo 的 devlog DESIGN.md 里(含状态变更、decompress 语义、protected 边界、config 开关);
    2. 实现走两条平行 PR:opencode-acp(本 repo)+ acp-kernel(feed billion-context / billion-context-pi);
    3. 进入实现阶段时我会在 ranxianglei/billion-context(及 acp-kernel)提 companion issue,按规范标注来源(来源: #355 分析缝隙孤儿时发现)。

    四、待办

    • 等 owner 拍板:是否按上述顺序 + "出生即吸收" 设计进入实现?
    • 等 issue 作者:acp.jsonc 配置 + 一份 acp_status 快照(验证 55 条的来源分布,尤其是 sub-floor 占比)。
  3. cycle2zhou commented on Sep 3, 2026

    @cycle2zhou
    Author

    补充:机制精确定位(opencode-acp v1.14.26 源码核实)

    用本地实际运行版本做了逐行取证(dist/index.js 打包产物),修正/补强了上面的若干判断。

    0. 环境

    • 包:opencode-acp@latest(v1.14.26,hardened fork of DCP,35 bug fixes)
    • 配置:~/.config/opencode/acp.jsonc

    1. 孤儿消息的形成机制(结构性根源,可精确定位)

    range 分组函数 buildCompressibleRanges(dist/index.js:6914-7048)里,断点只有两个条件:

    const hasGap = info.refNum > prevRefNum + 1;                        // :6988
    if (cur && (info.isUser && cur.count >= 3 || hasGap)) {            // :6989
      groups.push(cur); cur = null;                                     // 当前 group 封口成独立 range
    }
    • 条件一 hasGap:消息 ref 编号不连续(refNum > prevRefNum + 1);
    • 条件二:遇到用户消息且当前 group 已累积 ≥3 条。

    关键链路:消息一旦被 compress,就从原始消息列表移除、变成 block(msg_dcp_summary_ 前缀的 summary)。下一次 buildCompressibleRanges 扫描时,block 不再作为原始消息参与遍历,于是被压范围两侧的残留消息 refNum 之间必然出现 gap → 被强制切成独立 range。

    这就是"缝隙残留"的根因:每次压缩都在边界制造 gap,gap 两侧的 1-2 条残留小消息从此永久成为独立 range,且永远无法再与相邻内容合并(相邻的是 block,不是原始消息)。这不是偶发 bug,是当前分组算法的确定性结果。

    2. 孤儿消息为何压不掉:双阈值 + 单位不一致(建议 3 的实证)

    存在两套独立阈值,且单位不一致:

    (A)compress 执行侧 —— 字符单位(:7304-7323):

    const totalChars = countMessageCharacters(...);        // :2247 按 text.length + tool 内容 length 计字符
    if (totalChars < minCompressRange) {                   // minCompressRange = 3000(字符)
      throw new Error(`Range too small (${totalChars} chars, min ${minCompressRange})...`);
    }

    (B)nudge 推荐侧 —— token 单位(:7051-7081):

    var EFFECTIVE_MIN_COMPRESSIBLE_TOKENS = 1250;          // :7051 硬编码常量
    function resolveEffectiveFloor(config) {
      const minChars = config.compress?.minCompressRange ?? 5e3;
      return minChars > 0 ? Math.floor(minChars / 4) : 0;  // 3000/4 = 750 tokens
    }
    // filterRecommendedRanges :7056 用 effectiveTokens >= floor 过滤推荐范围

    同一配置项 minCompressRange 在 A 侧按"字符"解释、在 B 侧被 /4 换算成"token",另外还叠一个硬编码的 1250 token 常量。结果是 nudge 可能推荐一个"有效 token ≥750"的 range,agent 执行 compress 时却按"字符 <3000"拒绝 —— 边际收益为负,孤儿永远留在列表里反复被 nudge 点名。

    3. 孤儿消息不参与 tier 蒸馏(建议 1 的实证)

    tier 触发逻辑(:7957-8025)只遍历 block,原始孤儿消息根本不进 tier 计算:

    let candidates = [...state.prune.messages.activeBlockIds]   // :7967 只遍历 block id
      .map((id) => state.prune.messages.blocksById.get(id))
      .filter((b) => b !== void 0 && b.active && (b.tier ?? 1) === tc.targetTier)
    // ...
    if (candidates.length < 2) continue;                        // :8007 必须 >=2 才触发

    且 tc.tokens < nudgeGrowthTokens 直接跳过(:7964)。所以:孤儿消息既不能被单独 compress(字符 < 阈值),又不能进 tier 蒸馏折叠(非 block),也不能靠 gc 回收(见下) —— 三条出路全堵死。

    4. 复现步骤

    1. 配置 minCompressRange: 3000、nudgeGrowthTokens: 35000、protectedTools: ["compress"]。
    2. 跑超长 agent 会话,产生大量单条 <3000 字符的短消息(rmux/bash 短回显、nudge 提示等)。
    3. 手动对中间一大段执行 compress(如把 m00010–m00050 压成一个 block)。
    4. 观察下一次 nudge:被压范围两侧的残留短消息各自变成独立 compressible range,且无法再合并。
    5. 重复多次压缩后,这些 gap 残留累积成几十条孤儿 range。

    5. 实测日志证据(本会话直接复现)

    # ① 压缩 2760 字符范围被拒(直接印证字符阈值)
    Range too small (2760 chars, min 3000). Not worth compressing — overhead exceeds savings.
    
    # ② 压缩 m00001–m00008(8 条)实际只压了 7 条,m00001(首条用户消息)被保留
    Compressed 7 messages into [Compressed conversation section].
    
    # ③ 紧接着的 nudge 立即把 m00001 列为独立孤儿 range
    Compressible ranges (oldest first):
    - m00001–m00001  1 msgs  927 [tool 0% | text 100%]
    

    6. 额外发现(修正/补强原文)

    1. minCompressRange 默认值是 5000 字符,不是 3000(:1652 minCompressRange: 5e3)。原文里的"3000 字符"是用户自己 acp.jsonc:33 的覆盖值,不是默认值。
    2. gc.maxBlockAge 默认 Number.MAX_SAFE_INTEGER(永不停用)(:1667,注释 "age-based deactivation removed (memory-loss fix)")。这直接印证 bug chore: release v1.4.0 #20,且说明"旧块死重永久累积"是当前默认行为,不是配置失误。
    3. 默认 protectedTools = ["skill", "compress"](:1581)。Sisyphus 风格工作流每条消息都调 skill,会导致大量内容进保护区、nudge 被频繁抑制;用户已自行改成 ["compress"]。
    4. 对原文"nudge 提示文本(~150B)自身成为孤儿"的修正:ACP 的 nudge 实际是通过 createSyntheticMessage(:6289-6294)生成的 synthetic 消息(id 前缀 msg_dcp_summary_),而 isSyntheticMessage(:2056-2058)会在 buildCompressibleRanges(:6920)里把它跳过 —— ACP 自身的 nudge 不会成为孤儿。实际观察到的孤儿 nudge 文本,更可能来自 opencode host 层的 <dcp-system-reminder> 注入(真实消息,带 <dcp-message-id>,由 messageFilters 的 omo-system-reminder 处理)。所以"nudge 成为孤儿"的确切成因应进一步定位到 host 注入层,而非 ACP 的 synthetic 层。
  4. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 逐条对照 v1.14.26 源码核实完毕。你的取证质量很高,大部分结论确认,有一处诊断需要修正(但实证现象是真的)。

    一、逐条核实结果

    ✅ #1 形成机制 —— 完全确认,且把根因从"孤儿累积"升级为"每次压缩确定性产 gap"

    buildCompressibleRanges(lib/messages/inject/utils.ts:854-858)断点条件与你引的 bundle 行完全一致:hasGap = info.refNum > prevRefNum + 1 和 (info.isUser && cur.count >= 3) || hasGap。被压消息不进扫描(lib/compress/status.ts:308-312 按 activeBlockIds.length === 0 过滤后才传入),于是每个 block 两侧必然出现 refNum 空洞 → 强制切开。这是分组算法的确定性输出,不是偶发。 我上一轮"孤儿"的说法以你这个精确定位为准。

    ⚠️ #2 双阈值单位不一致 —— 诊断需修正,但实证现象真实存在

    两边其实都是 char/4,不存在"字符 vs token"的单位错位:推荐侧 effectiveTokens = Σ round(part.length/4)(utils.ts:813-825),floor = minCompressRange/4(resolveEffectiveFloor)。真正的缺陷是两边用了不同的计数函数:

    • 执行侧 countMessageCharacters(lib/token-utils.ts:224-237):text 取 part.text.length;tool 取 extractToolContent(token-utils.ts:163-182)= input + output 内容长度;
    • 推荐侧(utils.ts:821):tool part 取 JSON.stringify(整个 part).length/4 —— 包含 name、callID、state.status、metadata 等 extractToolContent 不计的字段,外加 JSON 包装开销。

    所以:纯文本消息两边精确一致;tool 消息推荐侧系统性高估(通常 10–40%)。后果正是你日志①的模式——tool 为主的 range 在推荐侧过 floor、在执行侧被拒(2760 chars < 3000)。修复很便宜:推荐侧改用 countMessageCharacters ÷4(纯字符串长度,零 tokenizer 成本),让推荐门槛 ≡ 执行侧接受谓词。

    ✅ #3 tier 蒸馏只遍历 block —— 确认(inject.ts:444-447 candidates 取自 activeBlockIds;:499 <2 跳过;:439 nudgeGrowthTokens 门)。三条出路全堵死的判断成立。

    ✅ #6.1 默认 5000 —— 你自己已修正,与源码一致(config.ts:249)。

    ✅ #6.2 gc.maxBlockAge = Number.MAX_SAFE_INTEGER —— 确认(config.ts:264,注释原文 "no-op: age-based deactivation removed (memory-loss fix)")。注意这是有意行为(memory-loss fix 移除了 age 停用),不是配置失误;AGENTS.md 里的 maxBlockAge: 15 是过期文档。"旧块死重是否值得单独跟踪"留给 owner 拍板——目前 T2/T3 折叠是唯一收敛路径。

    ✅ #6.4 synthetic 排除 —— 确认(isSyntheticMessage,lib/messages/query.ts:5-7,前缀 msg_dcp_summary_/msg_dcp_text_/msg_acp_recap_;buildCompressibleRanges 在 utils.ts:775 直接 skip)。ACP 自身 synthetic 消息结构性不可能成为孤儿;你判断实际来源是 host 层注入的真实消息(system reminder 类)成立。建议 2 的范围据此收窄:只有 ACP 自己拥有的持久合成消息(chat 通知,开启时)需要可回收标记;host reminder 交给通用吸收兜底。

    二、设计更新(吸收你的机制发现)

    核心方案维持 "出生即吸收",并修正我上一轮的一个错误推论:吸收不会产生跨 block 的级联合并(残留组两侧仍是 block,吸掉一组不会让相邻两组合并)——价值主张就是干净的:每个 sub-floor 残留组从可见上下文整体消失,换成相邻 block summary 里的一行机械补充(如 residual: m00120-m00121 (bash×2, 1.4K)),纯收益、无模型判断。

    • 吸收时机:新 block 创建时,吸收其边界紧邻的 sub-floor 残留组(非 protected 成员;组内含 BLOCKED 消息则整组跳过);
    • 存量清理:/acp defrag 对全部 sub-floor 残留组跑同样逻辑,吸向 ref 距离最近的 active block;
    • 新增(来自 Add CI workflow for testing and building #2):统一计数函数(推荐侧改用 countMessageCharacters)——纯正确性修复,并入核心 PR;
    • 新增(低风险展示项):acp_status 设计上列全部 range(可见性需要),但对 sub-floor range 标注 "(below min — batch with neighbors or /acp defrag)",阻止 agent 做注定失败的单独压缩尝试(你日志①的场景);
    • 顺序:① 吸收机制 + /acp defrag + 统一计数(核心 PR)→ ② acp_status 标注 + 单位文档(可拆出独立小 PR)→ ③ ACP 合成消息可回收(收窄后范围很小)→ ④ 不连续片段成块:搁置。
    • billion-context 协同不变:语义先在本 repo devlog DESIGN.md 定稿(状态变更、decompress 语义、protected 边界、config 开关默认 off burn-in),再走 opencode-acp + acp-kernel 平行 PR(feed billion-context/-pi)。

    三、一个待确认点

    日志①那个 2760-char range:它是出现在 nudge 推荐列表里,还是 agent 从 acp_status 里挑的?如果 nudge 真的推荐了它,请贴一下那段 nudge 原文——我的代码阅读指向 tool 消息 JSON 序列化高估是主因,但你的实测数据优先于我的阅读,我想确认高估幅度和触发条件。

    仍等 owner 对方案与顺序拍板后进入实现。

  5. cycle2zhou commented on Sep 3, 2026

    @cycle2zhou
    Author

    针对「待确认点」的回答:

    来源:nudge 推荐列表,不是 acp_status。 整个会话里我从未调用过 acp_status —— 压缩 m00001–m00004 完全是被 nudge 的 "Compressible ranges (oldest first)" 列表驱动的,我从列表里挑中了这个早期已消费的范围。

    关于「请贴 nudge 原文」—— 这里有个直接印证 #6.4 的技术障碍:那段 nudge 原文我无法事后精确恢复。原因是 nudge 本身是 synthetic 消息(msg_dcp_summary_ 前缀),host 层以 <dcp-system-reminder> 注入,没有独立可恢复的 dcp-message-id,因此不在 compress/decompress 系统的可追溯范围内。这恰好是"nudge 层需要可回收/可追溯机制"的又一条实证:nudge 文本一旦滚出上下文,连它自己都无法被检索或压缩。

    无需原文即可确定的定量证据:

    • 执行侧精确值 = 2760 chars(报错原文 "Range too small (2760 chars, min 3000)")。
    • 该 range 是 text + tool 混合:m00001(用户文本)+ m00002(webfetch tool 失败)+ m00003(gh issue view 的 tool JSON 输出)+ m00004(我的文本总结),tool part 占大头。
    • 它被 nudge 列出 = 推荐侧过了 750 tokens floor;而执行侧 2760 chars < 3000 被拒。这个「推荐侧过 floor、执行侧被拒」的落差,方向完全支持你 Add CI workflow for testing and building #2 的修正诊断:纯文本部分(m00001/m00004)两边计数一致,差异全部来自 m00002/m00003 两个 tool part 的 JSON.stringify(整个 part) vs extractToolContent 差额。

    一个补充观察:m00002(webfetch 失败)这个 tool part 尤其典型 —— 它的 state.status 是 error、含完整 error 对象/堆栈,JSON.stringify 会把这些全部计入推荐侧 size,而 extractToolContent 只取 input+output 的文本内容。失败状态的 tool 消息高估幅度可能远高于正常的 10–40%。这可能是"tool 密集 + 失败态"场景下孤儿消息尤其多的一个放大因素。

  6. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 待确认点闭环,三个点逐一回复:

    一、nudge 来源确认 → #2 修正诊断实证成立

    你从未调用 acp_status、完全由 nudge 列表驱动——这条信息很关键:nudge 推荐列表是这类 agent 的第一引导面,统一计数修复的优先级因此是最高项(不是"顺手修")。

    另外补一个代码库自己的历史佐证:filterRecommendedRanges 的文档注释(lib/messages/inject/utils.ts:933-935)明确记载过同一失败类——issue #37(session ses_7fb5cbc8:显示 "10.8K compressible" → 管线解析为 3066 chars → 被拒 → 模型重试 ×10)。当年加的 ÷4 floor 对齐假设两边计数一致;tool part 的计数漂移就是那个假设下幸存的子案例。你的日志①是这个已知失败类的现存实例,不是新物种。

    二、nudge 可追溯性 —— 代码核实了你的观察,但定性要修正

    代码路径确认:每轮动态内容(usage、可见 ID、三类 nudge)全部注入一条新建的 synthetic suffix 消息(createSuffixMessage,inject.ts:65-72;ID 形如 msg_dcp_summary_<hash>,lib/messages/utils.ts:14-17,28,seed 稳定故跨轮同 ID);applyAnchoredNudges 有 suffix 时全部走它(utils.ts:521-538),锚到历史消息只是无 suffix 时的回退路径。synthetic 消息被 buildCompressibleRanges 直接跳过(utils.ts:775)、边界解析不可见(Bug 6)——所以确实无法被 compress/decompress 寻址,与你的观察一致。(一个细微差别:它在请求内会拿到 mNNNN ref,只是边界解析排除它——对你而言等效于"没有可恢复 ID"。)

    但"需要可回收/可追溯机制"这个定性我建议拆开看:

    • 可回收:不需要。 suffix 每轮从 state 重新生成、从不落盘、存储成本为零。把它变成持久的可回收对象反而会破坏前缀缓存设计——suffix 机制存在的唯一理由就是不碰历史消息(inject.ts:60-64 注释;对照 Bug 38:原地改历史消息导致缓存失效、89% fresh input token 浪费)。
    • 可追溯:已有带外通道。 debug: true 时每轮请求快照(logs/acp/context/<session_id>/)包含完整请求和 suffix 原文;info 级 nudge 事件进 daily log。事后取证走这两个通道即可。若你觉得不够,正确的杠杆是加厚 daily log 里的 nudge 记录(记录推荐的 range 列表),而不是把 nudge 文本持久化进消息流。

    对计划的影响:原第 ③ 项(合成消息可回收标记)据此砍掉——ACP 自己拥有的持久合成消息只剩 chat 通知(开启时),而它们本来就是真实消息,吸收机制(①)已经覆盖。计划简化为两项核心 + 一项搁置。

    三、失败态高估 —— 接受,幅度归因细化一点

    extractToolContent 对 error 态 part 也计错误内容(token-utils.ts:177-178:无 output 时回退取 stringifyToolContent(state.error))——所以两边都数了错误正文,差额来自 JSON 包装开销:属性名随嵌套深度重复出现 + name/callID/status/metadata 等字段。单字符串载荷(堆栈)开销温和;深度嵌套 JSON 输出(gh issue view 这类)才是最坏情形。方向你判断对了,幅度取决于结构而非 status 本身。

    落到实现上:统一计数修复的 PR 会附带回归测试钉住两个计数器的差额——fixtures 覆盖纯文本 / 正常完成 tool / error 态含堆栈 / 深度嵌套 JSON 输出四种 part,确保这个高估类被测试锁死而不是只修常量。

    四、状态

    验证全部闭环,我这边没有未决问题了。最终计划:

    1. 核心 PR:出生即吸收 + /acp defrag + 推荐侧统一用 countMessageCharacters(+ 上述四类 fixture 回归测试);
    2. 小 PR:acp_status sub-floor 标注 + 单位文档(schema/CONFIGURATION.md 中英);
    3. 合成消息可回收(砍掉,①已覆盖);不连续片段成块:搁置。

    billion-context 协同不变(spec 先定稿于 devlog DESIGN.md,平行 PR opencode-acp + acp-kernel)。等 owner 拍板即进入实现。

  7. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    是否考虑在推荐列表和压缩的时候自动把孤儿带上而不是事后回收

  8. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 考虑过,而且你这个方向比我原来的"事后吸收"更优——同意把"压缩时自动带上"改为主机制,事后回收降级为兜底。修订后的设计:

    双层机制

    推荐层(引导):nudge / acp_status 展示"有效范围"——range 边界紧邻的 sub-floor 残留组直接标注在条目上(如 m00098–m00122 (incl. residual m00098–m00099))。模型看到的意图边界 = 实际生效边界,不产生"我压了 X 结果连上了 Y"的意外。

    执行层(保证):compress 管线在边界解析后、状态应用前自动带上——扫描边界紧邻前/后的连续非 protected 消息,残留组总长 < minCompressRange 即并入本块 selection。两个关键语义:

    • 带上的内容进入模型写的 summary(这些消息本来就在模型可见上下文里,推荐层标注后模型会主动覆盖);若模型没写到,管线在 summary 尾部机械追加一行记录被带入的消息(保底可追溯)——比纯机械一行保真度高,比纯靠模型自觉可靠;
    • 工具结果/通知里报告带入清单("included 3 residual msgs, 1.8K chars"),无静默魔法。

    为什么优于事后回收

    1. block 覆盖在创建时由模型发起的动作一次性确定,没有额外的 post-hoc 状态变更步骤(原方案要在 finalize 后再改一次 block);
    2. 残留内容走模型摘要而非机械行,保真度更高;
    3. 自然收敛:前沿推进时,老 block 之间的残留会被后续压缩顺带收掉,长会话自清理,defrag 触发频率趋近于零;
    4. min 检查不受影响(带上只增字符数,不会把合法压缩打成 sub-floor)。

    护栏

    • 只带 sub-floor 残留组;≥floor 的留给模型显式决策(它本来就会以独立 range 出现在列表里);
    • 遇 protected / BLOCKED 消息即停(Bug 39 硬排除语义不变,protected 消息留在可见上下文是既有取舍);
    • 每侧带上量封顶(消息数或字符数上限,防 floor 配置异常时 runaway);
    • last user message 不带(既有 soft filter 覆盖)。

    兜底与开关

    • /acp defrag 保留但角色缩小:只处理"深历史里两个老 block 之间、不会再被任何未来压缩触及"的残留组,一次性扫平;
    • 配置开关 compress.residualSweep(名字待定),首版默认 off burn-in 一版,下版翻默认 on——全程可 decompress 还原,风险低。

    billion-context 协同不变:spec 先定稿 devlog DESIGN.md,kernel 侧平行实现同一语义。

    这个修订版如果 OK,我就按此进入实现(核心 PR 范围:管线带入 + 推荐层标注 + 统一计数修复 + 四类 fixture 回归测试 + defrag 命令)。

  9. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    是否已经定位残留bug。是否修复bug后就不会有孤儿

  10. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 两个问题分开答,都给出精确结论:

    Q1:是否已定位?—— 已定位,但要拆成三层(一个固有属性 + 两个具体缺陷)

    固有属性(不是 bug,无法消除):选择性范围压缩必然在边界处产生残留。模型压 [A..B]、[D..E],C 区间的消息留在可见上下文——这是"按需选择 + 范围粒度"的数学后果,分组算法把它们报成独立 range 是正确行为。只要压缩还是选择性的,每次压缩都会新生产残留组,任何修复都消灭不了这个生产过程。

    缺陷 A(真 bug,已实证):推荐侧/执行侧双计数漂移。nudge 推荐列表对 tool part 用 JSON.stringify(整个part)/4(lib/messages/inject/utils.ts:821),执行侧用 countMessageCharacters = input+output 内容长度(lib/token-utils.ts:224-237)→ tool 为主的 range 推荐侧系统性高估 10–40%+ → nudge 推荐的 range 被执行拒绝(作者日志①:推荐过 750-token floor、执行报 "Range too small (2760 chars, min 3000)";同族历史见 issue #37 的 ×10 重试循环,代码注释里就有记载)。效果:浪费重试轮次、残留比必要地活得更久。修复 = 统一计数函数(一行级改动 + 回归测试)。

    缺陷 B(结构性死胡同,非单行 bug):sub-floor 残留组对所有现有机制不可达——单独压缩被 min 检查拒绝,T2/T3 蒸馏只遍历 block(inject.ts:444-447),nudge 推荐列表又把它剔除。三个各自正确的行为交互出一个不可达状态。这不是修某行代码能解决的,需要新机制(即上上轮定的"压缩时自动带上" + defrag 兜底)。

    (附带展示层小问题:acp_status 列 sub-floor range 无标注,诱导 agent 做注定失败的单独压缩。)

    Q2:修复 bug 后就不会有孤儿?—— 不会"没有",但会"不累积"

    分层看:

    只修什么 结果
    只修缺陷 A(统一计数) 不再有"推荐了却压不了"的失败循环;但残留照常在每个压缩边界生产,sub-floor 的照样卡死
    A + 新机制(压缩时自动带上 + defrag) 新生 sub-floor 残留组被下一次相邻压缩顺带收掉(自清理),straggler 由 defrag 扫平 → 稳态残留 ≈ 0

    准确的不变式是 "孤儿不累积",而不是"孤儿不存在"。两类 gap 会按设计永远存在,它们不是孤儿:

    1. protected 消息缝隙(Bug 39 硬排除)——skill/compress 等受保护输出必须留在可见上下文保语义权威,它们两侧形成的 gap 是设计取舍;
    2. 活跃前沿(preserveRecent 窗口)——最近消息本来就不该压。

    一句话总结

    残留的生产是范围压缩的固有属性(消不掉);让它卡死的是缺陷 B(结构性,需新机制);让它在卡死前还反复触发失败尝试的是缺陷 A(真 bug,可修)。A 是正确性修复,新机制是收敛性修复,两者都要,缺一不可。

    方案本身 @Dog 的方向我上轮已修订完毕(压缩时自动带为主、事后回收降兜底),等你拍板即开工。

  11. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    bug单独提交issue。包括billion-context billion-context-pi那边
    除了bug之外,我理解t2是不是可以直接把孤儿铲除

  12. ranxianglei commented on Sep 3, 2026

    @ranxianglei
    Owner

    🤖 ework agent · qwen3.8-27b

    [bot] 🏷 两件事都办完:bug 已分仓提交 issue;T2 问题查了源码,答案比预想更有意思——T2 今天就已经能铲除 span 内的孤儿。

    一、T2 能否直接铲除孤儿?——能,且大部分已在生效

    边界解析实现(v1.14.26):

    • block ref bN 解析为其 anchor(合成 summary 消息)在原始消息数组中的位置索引(lib/compress/search.ts:439-449 buildBoundaryLookup);
    • selection 是位置扫描 for index in [startRawIndex..endRawIndex](search.ts:312-342 resolveSelection)——b5→b20 之间夹着的所有原始消息(含孤儿)全部进入新 block 覆盖集,随后从可见上下文消失(下游由标准 applyCompressionState/byMessageId 流程接管)。

    所以作者 claim #3 修正一半:nudge 候选列表只列 block(inject.ts:444-447,他读对了),但消费语义是 span-based——候选 span 内部的孤儿今天就被 T2 折叠。他那 55 个持久孤儿的正确解释:位于候选 span 之外(最后一个 block → 活跃前沿的 gap 最常见,及 session 头部)+ 其会话可能从未触发 T2(T1 summaries 未累积到 nudgeGrowthTokens=50K)。

    T2 够不到的三类:① 首/末候选块外侧紧邻的 sub-floor 残留组;② 只与非目标 tier block 相邻的 gap(cross-tier narrowing 按 blockId 连续性选 run,inject.ts:450-496);③ 从未触发 T2 的会话。

    便宜补丁:T2/T3 nudge 在首/末候选块外侧紧邻 sub-floor 残留时,推荐用外侧孤儿的 m-ref 代替 block ref 作边界——现有 span 语义自动收编,纯 prompt/nudge 改动 + 测试,不动管线。

    二、bug 已分仓提交(含 billion-context/-pi 审计)

    审计方法:shallow clone master + grep 双计数器模式。结果:

    仓库 结论 issue
    opencode-acp bug 确认存在(utils.ts:821 JSON.stringify(整part)/4 vs token-utils.ts:224-237 内容长度) #359
    acp-kernel(billion-context/-pi 压缩核心) 两侧完全一致:推荐侧 chars=msg.text.length(recommend.ts:198)≡ 执行门 totalRangeChars+=msg.text.length(compress.ts)。且早已解决同族问题并留回归测试(types.ts:193-210、recommend.ts:314-339、tests/effective-gate-chars.test.ts) —
    billion-context adapter src 无独立计数器启发式(复用 kernel);单位文档问题已解决(minCompressRangeChars 规范名 + CONFIGURATION.md 中英说明) #492(审计留痕)
    billion-context-pi 同上 #277(审计留痕)

    净结论:只有 opencode-acp 需要修(移植 kernel 的"单一共享计数器"原则);另两仓审计干净,跟踪 issue 留痕即可关闭。

    三、更新后的最终方案

    1. Core PR(opencode-acp):T1 压缩时自动带上相邻 sub-floor 残留(@Dog 方向)+ 统一计数器修 nudge推荐侧与compress执行侧字符计数不一致,tool-heavy range被推荐后遭min检查拒绝(#355分析发现) #359 + 回归测试(4 fixtures)+ T2 nudge 外侧边界扩展(上面的便宜补丁)+ /acp defrag 兜底;
    2. 小 PR:acp_status sub-floor 标注 + 单位文档(schema/CONFIGURATION.md 中英,对齐 billion-context minCompressRangeChars 措辞)。

    分层收敛:T1 即时收(前沿侧,新机制)→ T2 span 天然收(内部,已在工作)→ T2 nudge 扩展收(外侧相邻,小改)→ defrag 手动扫尾。稳态孤儿 ≈ 0。

    等你 GO 即开工(core PR 先写 devlog REQ/DESIGN 再动码)。

  13. ranxianglei commented on Sep 10, 2026

    @ranxianglei
    Owner

    报告最新状态

  14. ranxianglei commented on Sep 10, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷 #355 最新状态报告:

    ① 分析阶段 —— ✅ 已完成(三轮验证,全部代码级证据)

    根因拆成三层,每层都定位到行号:

    层 定性 位置
    固有属性 范围压缩必然在边界生产残留(不可消除) buildCompressibleRanges 分组逻辑本身正确
    缺陷 A(真 bug) 推荐侧/执行侧双计数漂移 → nudge 推荐的 range 被 min 检查拒绝(作者日志实证 2760<3000;#37 同族) inject/utils.ts:821 vs token-utils.ts:224-237
    缺陷 B(结构死胡同) sub-floor 残留组对所有现有机制不可达(单独压被拒、T2/T3 只折 block、nudge 列表剔除它) 三机制交互结果,非单行 bug

    关键修正(作者 claim #3):T2 消费语义是 span-based——resolveSelection(search.ts:312-342)位置扫描会把候选块之间的孤儿一并收进新 block。所以 T2 今天就在铲除 span 内孤儿;那 55 个持久孤儿都在候选 span 之外(末块→活跃前沿 gap 为主)。

    ② bug 分仓提交 —— ✅ 已完成(你上轮指令)

    ③ 实现方案 —— 📋 已定稿,等你 GO

    1. Core PR:T1 压缩时自动带上相邻 sub-floor 残留(@Dog 方向)+ 统一计数器修 nudge推荐侧与compress执行侧字符计数不一致,tool-heavy range被推荐后遭min检查拒绝(#355分析发现) #359 + 4 fixtures 回归测试 + T2 nudge 外侧边界扩展(纯 prompt 改动)+ /acp defrag 兜底;config flag 默认关,burn-in 后默认开
    2. 小 PR:acp_status sub-floor 标注 + 单位文档(对齐 billion-context 的 minCompressRangeChars 措辞)

    分层收敛:T1 即时收(新)→ T2 span 天然收(已在工作)→ T2 nudge 扩展收(小改)→ defrag 扫尾。稳态孤儿 ≈ 0。

    ④ 当前阻塞点

    无任何代码改动、无分支。唯一阻塞 = 你的 GO。收到后按流程走:devlog REQ/DESIGN → 实现 → 双 agent review → PR。

  15. changed the title [-]长会话范围压缩产生'缝隙残留'孤儿消息,建议孤儿回收/碎片整理机制[/-] [+]Long-conversation scoped compaction produces "gap residue" orphaned messages; recommend an orphan reclamation / defragmentation mechanism. | 长会话范围压缩产生'缝隙残留'孤儿消息,建议孤儿回收/碎片整理机制[/+] on Oct 16, 2026
  16. changed the title [-]Long-conversation scoped compaction produces "gap residue" orphaned messages; recommend an orphan reclamation / defragmentation mechanism. | 长会话范围压缩产生'缝隙残留'孤儿消息,建议孤儿回收/碎片整理机制[/-] [+]Long-conversation scoped compaction produces "gap-residue" orphaned messages; recommend an orphan reclamation / defragmentation mechanism. | 长会话范围压缩产生'缝隙残留'孤儿消息,建议孤儿回收/碎片整理机制[/+] on Oct 16, 2026
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

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions