QuanhuZeYu/dsh-idle-compactor

dsh-idle-compactor

Idle-triggered context compaction plugin for DeepSeek Harness: compact a session once it goes quiet past a configurable token floor.

待分类compactioncontext-managementcordis

dsh-idle-compactor(中文)

English | 简体中文

按「闲置时长」触发的上下文压缩插件:会话上下文涨过设定的体量下限、并且安静了设定这么久,就自动压一次。 下次回到这个会话,看到的是已收敛的历史,而不是每次请求都拖着一条没人再看的长尾巴。

DSH 内置的自动压缩只认两个触发条件:请求压力 pressure 与提供方确认的上下文溢出。它不看「这个会话 多久没人用了」。本插件补的就是这一维 —— 闲置时长和上下文体量,两个都得过线才动手。

环境要求

  • 带 compaction seam 与 tokenMeter 的 DeepSeek Harness(在 0.1.2-alpha.3 上验证)。
  • 零运行时依赖:源码里的 @deepseek-ai/* 全是 import type,编译后消失,lib/index.js 是一个自包含的 ESM 文件。

安装

git clone https://github.com/QuanhuZeYu/dsh-idle-compactor.git
dsh plugin --profile web add ./dsh-idle-compactor

# pnpm 9 往 workspace root 加依赖需要显式 -w:
dsh plugin --profile web add -w ./dsh-idle-compactor

装完重启 profile:patch 层是热加载的,bundle 挂载不是。

bundle 自带已提交的 lib/ 产物,所以无论 git 还是路径安装都不触发构建脚本,也就不需要 allowBuilds 授权。 dsh plugin add 做的事就是把 bundle 追加进 profile 的层栈,它的 cordis.patch.yml 才会生效。想手工配,就往 ~/.dsh/profiles/web/package.json 里写这两处:

{
  "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-idle-compactor"] } },
  "dependencies": { "dsh-idle-compactor": "link:../../dsh-idle-compactor" }
}

判定条件

每 scanMs(默认 30 秒)扫一遍当前进程里的活体 agent,全部满足才压:

条件依据
agent 处于 idle(没有回合、没有维护任务在跑)agent.status
会话最新一条日志事件距今超过 idleMssession.events.at(-1).time(durable,重启不会把老会话误判成新会话)
实测请求压力 ≥ thresholdTokensctx.tokenMeter.measure(session).totalTokens
模型可见表面也 ≥ thresholdTokens同一次 measure 的 surfaceTokens
上次压缩之后确实又有新内容每会话 compactedAt 水位
冷却 / 失败退避已到期cooldownMs、retryBackoffMs
该会话没有被归档ctx.workspaceRegistry.archivedSessionIds
该会话没有还在等的后台工作自己名下未结的 ctx.jobs、自己收件箱、以及它作为父会话的活体子会话

归档只是把会话从所有一级分组表面隐藏掉,并且故意留下活体 agent 继续跑。所以扫描必须显式点名归档 id: 隐藏一段对话不等于授权改写它给模型看的内容。归档集合还在加载中的会话同样跳过 —— 分类不明的会话一律不动。

压力和表面为什么要各查一遍

totalTokens 是请求压力:锚在上一次成功调用的 provider usage 上,只有下一次调用才会重新定价。 surfaceTokens 才是压缩能动的那部分 —— 模型可见表面的实际内容。刚压完的会话会呈现「压力仍高达 33 万、 表面只剩 7 千」的状态;此时再压只是把已经很短的上下文又摘要一遍,白丢细节。所以两个数都要过线才动手 (这种「压力过线、表面不够」的跳过会记在 debug 级日志里)。

这也是为什么界面上的上下文数字在压缩之后可能还是很高:它显示的是压力锚点,要等下一次请求才重新定价。

执行走标准 seam ctx.compaction.compactNow(agent, signal),也就是 /compact 用的同一个入口:轮次之间的 maintenance 事务,选区与保留策略仍由压缩后端决定。插件自己不选区、不写摘要。

安静不等于干完了

父代理派完子代理、自己结束回合之后,按 loop 的判定它就是 idle;而这段等待多久只取决于子代理,跟 idleMs 无关 —— 一次委托可能跑十分钟,也可能跑三小时。在等待期间压缩父会话,等于把它马上要回去接续 的那份上下文先改写掉。所以扫描还会查三本账:

  • 本会话名下的未结束 job(ctx.jobs.list(agent),只算 ownerSession 就是本会话的那些;因此一个跑很久 的后台 pwsh 构建同样会把住它);
  • 本会话的收件箱,那里可能已经落了通知但还没开回合;
  • 本会话直接或者间接当父会话的活体子会话。

第三本账不是重复的。continuable 子代理(以及被 send_message 唤醒的子代理)在 harness 里根本不登记 job,而子代理 driver 在「已接受 prompt」和「真的开回合」之间那一段还会报 idle;所以只要某个后代在 安静窗口之内产生过任何日志,就算作在飞的活 —— 包括仍在创建窗口里的。已经安静满 idleMs 的子会话不再 把住父会话;不在本进程里的冷子会话本来就管不住,因为没有东西会替它唤醒父会话。

waitWarnMs 只做通报不做放行:一段等待超过它,就在这一段里警告一次,然后继续等。

为什么 bundle 还要额外启用一个 host 平面的压缩后端

Agent preset 把 compaction-basic 挂在 isolate realm 里,而 dsh-web-app 关掉了 host 平面那一份,host 光纤 读不到 realm 实例。所以本 bundle 的 patch 层把 host 那一份重新启用并设 auto: false:它只应答闲置 compactNow(), 不注册自己的压力压缩,压力压缩仍完全归 preset 那份后端管。附带好处:minimal 模式的会话本来根本没有压缩 后端,现在也能被压。

配置

写在 profile 的 cordis.patch.yml 里(后层会覆盖整份 config,要保留的键请一并重写):

- id: idle-compactor
  config:
    thresholdTokens: 131072   # 128K
    idleMs: 600000            # 10 分钟
键默认含义
enabledtrue关掉就完全不注册扫描。
thresholdTokens131072上下文体量下限;要十进制 128K 就写 128000。
idleMs600000闲置窗口。
scanMs30000扫描间隔,决定闲置达标后最多再等多久动手。
includeSubagentsfalse是否也压 origin: subagent 的子会话。
excludeArchivedtrue跳过 workspace registry 标记为归档的会话。
skipAwaitingWorktrue有未结束后台工作(自己的或活体后代的)时一律不压。
waitWarnMs10800000一段等待把会话把住这么久就警告一次(每段只警告一次)。
cooldownMs60000同一会话两次压缩的最小间隔。
retryBackoffMs300000失败或「无可选区」后的退避。
maxPerScan1单轮最多压几个会话,限制并发摘要请求。
dryRunfalse只记日志不真压。

未知键名或越界值在装载时报错,不会静默兜底。

模型侧影响

  • 每次落地压缩多一次摘要请求,由压缩后端用该会话的路由模型发起;不往对话里插任何提示,也不加 prompt section。
  • 模型可见的唯一变化,就是替换掉被压区段的那一个 checkpoint 节点。
  • 低于 thresholdTokens 的会话测一次就转入下一轮,不会每 tick 重复测量。

压缩不会做什么

日志永远不被重写。一次压缩追加 compaction/start、compaction/summary、compaction/end,再用一条带 surfaceOp: replace 的 user/message 替换选中区段;shadowedSeqs 记录了被移出模型可见表面的每一条事件, 原文仍在会话日志里,消费方可以读回。存储索引和归档标记都不变。

已知限制与待办

  • 只管活体会话。 进程里没有 agent 的会话既没有可测的 surface,也没有承载 maintenance 的 agent,只有被 打开之后才会进入候选。要覆盖冷会话,就得为候选者临时 resume 一个 agent;includeSubagents 默认关掉也是 同一个考虑:后台子会话本来就归父会话的生命周期管。
  • 绝不打断正在跑的回合。 资格条件要求 agent.status === "idle",且 compactNow 是轮次之间的维护任务。 它的另一半 —— 回合之间但还在等子代理 —— 由 skipAwaitingWork 兜住:等待没有上限的父会话,只会在等待 结束之后才被压。
  • 没有设置面板。 目前只能改 patch 层;加一个带热重载的 settings.section 是下一步。

开发

npm install                                  # typescript + vitest 作为 devDependency
npm run link-dsh -- <dsh-checkout 路径>       # 从 checkout 解析 harness 包
npm run build                                # src/index.ts -> lib/index.js
npm test                                     # 12 个集成测试
npm run typecheck:tests                      # 连测试一起类型检查(tsconfig.check.json)

源码依赖的多数 @deepseek-ai/* 包并不在公共 registry 上,所以 scripts/link-checkout.mjs 是把一个本地 DeepSeek Harness checkout 链接进本项目自己的(已被 gitignore 的)node_modules。它不会往那个 checkout 里写 任何东西;同级存在多个 checkout 时它会停下来让你指定,不会替你猜版本。npm install 不是必需的:没有它 脚本会借用 checkout 自带的 typescript 和 vitest,而 npm scripts 是按文件路径而不是按 bin 垫片调用它们的, 所以借来的和装的一样能用。

tests/idle-compactor.spec.ts 跑在真实 agent loop + 真实会话日志 + 真实 tokenMeter + 真实 BasicCompactionEngine(只脚本化它的 summarize)之上,断言的是日志里出现的 compaction/summary 事件, 不是 mock 调用次数。默认测的就是已构建产物 —— 和 profile 加载的是同一份;设 DSH_IDLE_COMPACTOR_BUNDLE 可以指向别的构建。

覆盖:闲置超阈值恰好压一次且实测上下文下降;同一安静期不重复压;有新活动后重新武装;未达阈值不动; 压力过线但表面已低于阈值时不动;归档会话不动(归档集合还在加载时同样不动);dryRun 不写任何东西; 坏配置直接抛。等待这块正反都测:名下有未结 job 的、子会话正在跑回合的,都不压,等工作结束后才压; 而「无关会话在跑」和「job 属于别的会话」两种情况照点压 —— 说明起作用的是父子关联与 job 归属,不是 场上有没有别的动静。

目录结构

src/index.ts              插件本体(纯类型导入,零运行时依赖)
lib/                      已提交的构建产物 —— profile 实际加载的东西
cordis.patch.yml          bundle 层:插件行 + host 平面压缩后端行
tests/                    在真实组装的 harness 上跑的集成测试
scripts/link-checkout.mjs 仅开发用:把 checkout 的包只读链接进来
vitest.config.mjs         独立测试根,不依赖任何 checkout 配置
tsconfig.json             只输出可擦除语法的 ESM,声明文件进 lib/types
tsconfig.check.json       测试平面类型检查:src + specs,带 Node 环境类型

许可证

MIT,见 LICENSE。