dsh-context-rescue
Recovers a session the host already refused for exceeding the context window: it re-checks the gateway message the built-in classifier misses, takes the real window out of that message, and folds the oldest span into one checkpoint without a model call (tool-result pruning, the host compaction engin
Install
dsh plugin --profile web add dsh-context-rescue
Sandbox test results
| Stage | Result |
|---|---|
| 1 install | ✓ |
| 2 list | ✓ |
| 3 mount | ✓ |
| 4 boot | — |
Four stages run in an isolated sandbox: install (network on) → list → mount → boot (last three with no network).
README
dsh-context-rescue
中文 · [English](README.en.md)
给"上下文爆窗"的 DeepSeek Harness 会话叫救护车。
会话里每条消息都回 400,因为某一次请求超过了模型真正能吃的窗口。/compact 也救不回来
—— 它沿着同一条超限的投影重发;如果压缩被代理层接管,还会被直接拒绝。这个插件是急救车:
识别爆窗、把最老的历史折叠成结构化摘要、把同一步重发一次,会话就活了。
400 the request exceeds the model's context window
└─ context-rescue: overflow detected (gw-prose, score 0.95)
├─ T1 裁剪工具输出 → 宿主未提供
├─ T2 宿主压缩引擎 → 代理拒绝,本进程内该车道永久关闭
├─ T3 机械折叠 → 956 节点 → 169 节点,991,512 → 298,595 tokens
└─ T4 超大单条消息 → 仅当历史只有一条巨消息时:保留它的开头与结尾
└─ 重试同一步 → 200 OK
为什么需要它
这条失效链在上游有完整记录(DSH 讨论 #10138),它是三段叠加,不是单点 bug:
1. 网关用自然语言拒绝(payload core text alone is ~N bytes … no upstream can accept it),
宿主的分类器认不出来,于是这次失败没有被打上 CONTEXT_WINDOW_EXCEEDED;
2. 错误码不对,宿主自带的上下文溢出分支就不会执行;
3. 没有任何东西缩小投影,重发出去的请求字节级相同,于是再失败一次 —— 无限循环。
现有插件都是事前预防(滚动换窗、裁剪工具输出、代理侧压缩)。没有一个在会话已经卡死之后
动手。本插件平时什么都不做,只在请求失败时介入,因此可以和上面任何一款共存。
安装
# 直接从本仓库装 dsh plugin --profile <profile> add git+https://github.com/Lequait/dsh-context-rescue.git#master # 本地目录 dsh plugin --profile <profile> add /path/to/dsh-context-rescue # 从 npm 安装(0.2.5 已发布) dsh plugin --profile <profile> add dsh-context-rescue
分支形式跟随 master;市场条目则是钉死提交(git+…#<commit>),列表指向的构建不会在脚下挪动。
包内声明了 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },以 context-rescue
为 id 插入。**运行时它不 import 任何 @deepseek-ai/* 包**:surfaceOp、compaction/* 事务、
配对规则、模型窗口解析全部按宿主的线上结构在本地构造。宿主升级不会打断它的 import,
它也不声明任何宿主 peer 依赖(也就没有"peer 缺失装不上"这回事)。装完重启一次(新 bundle 需要宿主重载)。
上架条目
market/ 放图标和生成脚本,产出的是格式 1 的插件市场索引条目。校验用的是宿主自己的
parseMarketIndex —— 也就是 Discover 页读索引时跑的那套代码:
# 校验一条以本仓库某个 commit 作为 Git 源的条目 node scripts/make-market-index.mjs \ --repo https://github.com/OWNER/dsh-context-rescue \ --commit "$(git rev-parse HEAD)" \ --out market/index.json # 或者一步发布:先推送,再把条目钉到真正推上去的那个提交 npm run publish:remote -- --repo OWNER/dsh-context-rescue --dry-run
它会打印解析出的安装 spec(git+https://…git#<commit>;加 --npm 则是 name@version),
任何一条规则不通过就带 JSON 路径非零退出。校验器位置可用 DSH_APP_ASAR 覆盖。
DSH 只读用户自己配置的索引:市场 bundle 是可选的、默认 indexes: [],所以"能被发现"只差一步。
本仓库已经发布,用户有两条路:
# 1. 直接从 git 装 —— 不需要任何市场,钉住你复核过的 commit dsh plugin --profile <profile> add git+https://github.com/Lequait/dsh-context-rescue.git#<commit>
# 2. 通过 Discover 页
添加索引源 https://github.com/Lequait/dsh-context-rescue/raw/master/market/index.json
(Discover → 索引源 / "Add index source";官方市场 bundle
@deepseek-ai/dsh-plugin-market 是可选的,不是每个 profile 都有)
选中条目 dsh-context-rescue —— 它装的是条目里钉住的 commit,不是分支
仓库同时带了 dsh-plugin topic,按 topic 扫描的列表能自动发现它;条目也已投给两个策展列表
(PR 记录在 docs/PUBLISH.md)。
两个不同的计数
measure() 数的是 surface 节点;真正要上游接受的却是请求,也就是
session.deriveMessages() 生成的那批消息。这两个数并不相同:按前者看还装得下的会话,
按后者看可能照样被拒。/rescue-status 两个都报,宿主闸门断言的也是后者——每次折叠之后,
派生出的请求必须比折叠前小,而且必须在窗口以内。
救不回来的时候
有些会话救不回来:光是系统提示词就已经超过窗口。插件会把每一档都试一次,然后把这条会话记为「已无解」,不再每个请求都重新测量一遍——否则那条会话的每个请求都要付一次完整测量、然后再以同样方式失败。这一步照常继续,请求照常发出,失败原样交回宿主。/rescue(或 context_rescue 的 action: "rescue")总会再试一次;任何一次真正生效的救援都会让正常路径重新打开。
这个「无解」结论属于当时那个窗口和当时那份历史形状,而不是永久属于这个会话:换到窗口更大的模型,或者投影在这之后又长大了,结论都会被丢弃,失败路径与每步预检都会重新测量一次。否则「换个模型试试」——卡住时最自然的动作——反而会让插件继续装看不见。
检查点里留下什么
摘要不调用模型,所以它留的是事实而不是文笔:错误与失败、任务清单、跑过的操作(一行一次,
重复的合并计数)、用户的请求、最近的结论,以及这次工作触及的文件与路径。路径无论是存放在
参数树里还是存放在宿主实际写入的那一条 JSON 文本里,都会被读出来——一次把路径全丢掉的折叠,
会让请求变小、让模型失去线索。
请求发出之前
等到 400 才救,代价是一次白走的重试;而有些会话一次都救不回来(issue #10138)。所以同一套急救
也挂在 agent/pre-step 上,早一步发生:
1. 先量循环即将发出的投影;
2. 如果它已经超过模型窗口,这个请求根本不可能被接受 —— 就地折叠:先 T1 裁剪,再走零模型检查点;
3. 如果只是落在压力带(fitRatio × 窗口 到窗口之间),一律不动:那是普通压缩策略,归宿主管。
窗口依次来自模型注册表(用会话头里的 provider)、会话自身、windowFallback,并按目标缓存,
一个 turn 不会每步都去解析一次。这里故意不走 T2:宿主引擎有自己的 pre-step 钩子,借它会为
同一次超窗压缩两遍。
量一份大投影才是急救里最贵的一步:本机实测整条阶梯中位数 1.1 秒,几乎都花在给节点计价上。pre-step
每一步都会调用,所以它一个 turn 只量一次,之后只数「这之后新追加了什么」——同一 turn 的第二次检查
耗时可忽略,但仍然会发现大段粘贴(任何新消息超过 4,000 字符就强制重新测量;投影节点数与缓存不符也
强制重测,那说明别人折叠过)。node scripts/dev/rescue-bench.mjs 会打出本机最大几份日志的耗时分布。
请求失败之后它做什么
挂在 agent/request-error 瀑布上 —— 这条钩子对任何失败都会触发,跟宿主给的是什么错误码
无关,因此正好补住分类器的漏。
1. 判定:强证据(网关原文、model's context window、no upstream can accept、
预算与窗口的算术关系)+ 结构化错误码;顺带抽出 requestTokens / windowTokens。
失败里观测到的窗口优先于适配器声明的窗口。
2. 计量:走宿主 token meter,没有就本地估算。
3. 规划:用宿主自己的配对规则算安全切点(工具调用是 assistant/message 里的
tool-call block,结果是 tool/result,镜像事件不动余额)。保留钉住的头部和最近尾部;
遇到未闭合的 compaction 事务直接拒绝出计划。
4. 四档阶梯:
- T1 用宿主暴露的 pruner 裁剪工具输出(最便宜,零模型);
- T2 请宿主的压缩引擎做它本该做的事:
compactIfNeeded(agent, 'context-overflow', signal);
借来的引擎只有把投影真的变小才算救活,什么都没做的 no-op 会继续落到 T3;
若被代理拒绝(dsh_compaction_refused),记下来,本进程内不再问第二次;
- T3 写检查点:
compaction/start→compaction/prune→user/message(surfaceOp: replace)
→ compaction/end,正文是折叠区间的结构化摘要。
- T4 当整段超窗的其实只有一条消息(比如刚贴进来的一整个文件)时,没有区间可折:
把那条消息本身换成「开头 + 结尾」的摘录,并在检查点里写明原文长度与被丢弃的中间部分。
只对删掉它仍能保持工具调用配对完整的文本消息生效;孤儿 tool/result 之类一律不碰。
当超窗的其实是一次工具输出时,那次调用和它的结果会一起被换成摘录——单独删掉任一半,
另一半就成了孤儿。
系统提示词永远不会被摘录:把指令换成首尾摘录,等于让请求合法地丢掉这一轮该做什么,所以当窗口里
超出的只剩它时,阶梯报告失败而不是假装成功。一次贴进多段内容也能一趟处理完——折叠先吃掉旧区间,
留在尾部的那条由 T4 截断。而在剩下的内容里,旧历史会先被摘录:这一轮刚打进去的那条消息就是这一轮
要做的事,只有当更早的地方没有足够大的东西能把请求压回窗口时,才会动它。
5. 只重试一次:返回 { kind: 'retry' },让循环用变小后的投影重发同一步。全档失败或
超出每轮预算就 next() 交出控制权,绝不让真实错误被吞掉。
摘要是结构化的,不是复述:错误与失败、涉及的文件与路径、任务清单、关键操作、用户意图、
最近结论。长会话会丢细节,但保留骨架和线索。
工具
工具 · 用途
context_rescue · action: "status" 报告占用、能力与上次急救;action: "rescue" 立刻走一遍阶梯、不等失败——即使投影目前还装得下也会折,因为这是一次显式请求
context_rescue_estimate · 分类一段贴进来的报错文本(可选参数 text),给出从中提取的尺寸,外加本会话的占用情况
context_rescue_fold · 立刻折叠最旧的一段历史(没有参数,与 /rescue 走同一个机械折叠),并报告 token 变化
三个工具的结果在返回前都过一遍 lossless 化:宿主要求工具输出是 lossless JSON,一个 undefined 字段就会让整条结果被丢弃(issue #2)。
工具不抛异常,统一返回带 ok、reason 和计数的报告对象。
斜杠命令
只要宿主提供了命令注册表(/compact 用的那个槽),插件就会注册两个命令。它们是可选的:没有该注册表的 p
…(truncated)
Rendered from the README published on npm. Formatted only, not edited.
Versions
Published on npm::8,最后更新 2026-11-05