Skip to main content
P

dsh-early-stop-guard

pf-sjtu/dsh-early-stop-guard

Keeps a DSH agent turn open when the model ends it by narrating work it has not done: on agent/turn-stopping the guard replays the turn log and classifies the trailing message with regexes — in the default mode wait / ask / done / gray end the turn with no model call, and only a promise of pending work reaches the judge — then has a judge chain of four dialect kinds (System One three-question noul, OpenAI-compatible, OpenAI Decisions, or the session LLM) rule on whether the turn is really over, and steers the same turn with agent.steer when it is not.

Install

dsh plugin --profile web add github:pf-sjtu/dsh-early-stop-guard

README

dsh-early-stop-guard

DSH(DeepSeek Harness)插件:当模型只叙述「接下来要做什么」而没有真的发出工具调用时,把同一个回合继续推进一步,避免任务做到一半就被判定完成。

问题

DSH 的 agent loop 用一条极简规则判定回合结束:

const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }

也就是说,只要某一步产出的是纯文本、没有工具调用,整个回合就以 completed 收尾。模型写一句「平台层 17 项测试全绿。接下来改 environment.ts……先读全文取准确行文。」然后停下,在这条规则下与「任务真的做完了」完全等价——回合关闭,剩下的 11 条待办不会再被推进。

这个插件只针对这一种失败模式:末步纯文本 + 本回合更早有过工具调用 + 这段文本在承诺尚未执行的工作。

怎么工作

agent/turn-stopping(回合仍开着)
  ├─ 读本回合自己的 session 日志 → 逐步事实(哪一步调了哪些工具、末步正文)
  ├─ 上一次 steer 是否换来了真工具调用?没有 → 本回合不再续跑
  ├─ 闸门:末步无工具调用 + 更早有过工具调用 + 正文非空
  ├─ 每回合续跑预算
  ├─ 启发式分类(wait / ask / forward / done / gray)
  ├─ 必要时问裁判链 → 三题概率
  └─ 判定 continue 时 agent.steer(提示消息) → 同一回合再跑一步

agent.steer() 把消息塞进 agent 的收件箱,回合机重读收件箱:有 steer 就再跑一步,没有才关闭回合。因此这里不新开回合、不伪造用户消息、不给模型换上下文,只是不让「叙述了下一步」被当作完成。

判定顺序(承重设计)

裁判回答三个概率:narrates(在承诺未完成的工作)、asks_user(在等人确认)、waiting(在等外部结果)。判定严格按 waiting → asks_user → narrates:

用例期望narratesasks_userwaiting
「接下来改 environment.ts……先读全文取准确行文」继续0.9460.0220.024
「全部完成,已提交 3a4f2b1」结束0.0180.0230.018
「是直接改,还是先克隆一份?」结束0.5360.9610.047
「后台 job-7f2a 跑起来了,等它出结果」结束0.6450.0460.971
「现在改 deployment.ts 的路径校验。」继续0.8740.0370.019

后两行的 narrates 也在阈值之上(0.536 / 0.645)。如果先看 narrates,「在等你确认」「在等 CI」都会被判成继续——这正是必须把 waiting / asks_user 排在前面、且数值比较留在代码里的原因。

为什么不直接用普通 LLM 裁判

决策模型一次请求同时回答三个校准概率,单次约 1.2–1.4 秒、约 700 input token,比让会话模型读一遍摘要再输出 JSON 更便宜、更稳定,也更容易解释(日志里存的是概率,不是「true」)。裁判链仍然支持普通 LLM 托底。

安装

# 从 npm(发布后)
dsh plugin --profile web add dsh-early-stop-guard

# 本地目录开发调试
dsh plugin --profile <profile> add link:C:/Code/JS/dsh-early-stop-guard

安装后设置页出现 设置 → 插件 → 早停守卫。

设置页

三个面板,都在同一个标签页里:

  • 策略:总开关、每回合最多续跑次数、判定模式、三个概率阈值、注入给模型的话。改动即时保存。
  • 裁判供应商链:按顺序尝试,先成功者生效。每行可改 kind / 协议 / Base URL / 模型 / 环境变量名 / 超时,行内直接填密钥(见下),可上下移动、可删除、可禁用;底部有「新增供应商」「新增 OpenAI 兼容决策模型」「新增 OpenAI Decisions(gpt-6-luna)」「新增 LLM 托底」四个按钮。
  • 试判 + 判定记录:粘贴一段正文立刻看判定结果与三题概率(新增供应商后用它验证,不用等真回合翻车);下方是每条判定记录,一行看时间、动作、原因、回合与耗时,第二行是 n/a/w 三题概率加尾部正文摘要(超长自动省略号),点一行展开该条的供应商尝试与完整正文。可刷新与清空。

密钥怎么填

决策模型供应商需要密钥。密钥不走设置 wire(DSH 会把 role: secret 字段抹掉,整体覆盖数组会顺手删掉密钥),而是存进本机的一个独立文件:

~/.dsh/early-stop-guard/secrets.json      # 0600,按供应商 id 存明文

密钥按供应商 id 索引,所以:在设置页里给某一行存好密钥之后,再改那一行的 id,密钥就找不到主人了(判定时会报 missing key for <新 id>)。设置页检测到这种孤儿密钥会在裁判链下方提示 已保存的密钥找不到对应供应商(改过 id?):<旧 id>,重新在该行保存一次密钥即可。

三种给密钥的方式,按优先级:

  1. 在设置页直接填:供应商卡片底部有 API Key 输入框(type=password),粘贴后点「保存密钥」,走 POST /api/early-stop-guard/secret 写入上面的文件。保存后徽标显示「已保存(本地文件)」,密钥不回显。点「清除密钥」删掉它。
  2. 环境变量:供应商行里的 apiKeyEnv 写变量名(如 BAMBOONODE_API_KEY),密钥放进程环境或 .env。GUI 进程常常没有这个变量,所以设置页里直接填更可靠。
  3. 两者都有时:设置页保存的密钥优先,环境变量兜底。

POST /test 的响应里只会出现「哪个供应商失败、失败原因」,不会出现密钥值。

深浅色

设置页跟随宿主主题:所有颜色取自宿主的 --dsw-alias-* 令牌(文字、边框、填充、状态色、代码块底色、圆角、等宽字体),深色由 body[data-ds-dark-theme] 切换,创建色用 color-mix(in srgb, var(--dsw-alias-label-primary) N%, transparent) 从前景色派生——不要用 bg-layer-* 当灰色块(浅色下它们是白色)。.esg-root 还会在深色下声明 color-scheme: dark,否则原生 <select> 弹层会按系统方案开成白底。宿主皮肤(如 claude 风格)改品牌色时按钮会跟着变,因为品牌色也是令牌。

配置

Config 由 schemastery 定义,设置页从 schema 自动生成;手改 cordis.patch.yml 也会被 resolveConfig 收敛回合法区间。

字段默认说明
enabledtrue总开关;关掉不判定、不注入、不写日志
maxContinuations3每回合最多续跑次数
maxSteps8描述给裁判的尾部步数
maxTailChars1200描述给裁判的尾部正文字符数
steerText见 lib/config.js注入给模型的话,包含三条合法收尾条件
dm.modeautoauto 只在叙述型/灰区问裁判;always 灰区也问;off 只用启发式
dm.threshold0.5narrates 达到则继续
dm.askThreshold0.4asks_user 达到则结束
dm.waitThreshold0.5waiting 达到则结束
dm.timeoutMs12000单次裁判请求上限(决策模型冷启动会慢,实测 0.45–3.7s)
dm.stateChars4000发给裁判的 state 字符串上限
providersBambooNode + 当前会话 LLM裁判链,数组顺序即优先级
breaker.failures3连续失败几次后熔断
breaker.cooldownMs600000熔断多久
logs.enabledtrue是否落盘
logs.maxRecords300内存中保留的记录数
logs.path空空表示 ~/.dsh/early-stop-guard/events.jsonl
debugfalse每个 hook 评估都打日志(含 skip)

供应商

{
  "id": "bamboonode",
  "label": "BambooNode System One",
  "kind": "decision",          // decision = HTTP 决策模型;llm = 复用聊天模型
  "protocol": "systemone",     // systemone | decisions | openai
  "baseUrl": "https://api.bamboonode.dev/v1",
  "model": "startlux-decision-27b-autothink",
  "apiKeyEnv": "BAMBOONODE_API_KEY"
}

kind: "llm" 的供应商走 DSH 自己的 ctx.llm.stream:llmProvider / llmModel 留空表示用当前会话正在跑的模型,填了就固定到那个路由。这就是链尾的托底。

protocol: "openai" 的供应商默认按新一代 OpenAI 请求体发送(max_completion_tokens、带 temperature: 0)。不同网关的方言并不通用——GPT-5 系拒绝 max_tokens、也拒绝非默认 temperature,而 vLLM / Ollama / 部分代理只认 max_tokens——所以插件会按「max_completion_tokens + temperature → max_tokens + temperature → 只发 max_completion_tokens → 只发 max_tokens → 只发 messages」的顺序降级重试,仅在 HTTP 400/422 且错误文本点名这几个参数时降级(鉴权、额度、模型名错误立即失败,不重试),并把成功的写法按 baseUrl|model 记住,后续回合不再多打一次 400。model 留空会直接报 provider <id> has no model configured: fill in Model in the Settings tab,不会白跑一次请求。

protocol: "decisions" 走 OpenAI 的 Decisions API:POST <baseUrl>/decisions,请求体是 { model, input, questions },三条 predicate 题各回一个概率——与 System One 回的是同一组事实,所以两种方言的判定可以互换。gpt-6-luna 是目前唯一支持该端点的模型,它是 Decisions 专用模型:不会出现在 GET /v1/models 列表里(别用列表判断可用性),输出 token 不计费,未命中缓存的输入按 $0.10/1M tokens 计。设置页底部的「新增 OpenAI Decisions(gpt-6-luna)」按钮会一次填好这一行(baseUrl https://api.openai.com/v1、model gpt-6-luna、apiKeyEnv OPENAI_API_KEY)。

概率阈值在 dm.threshold(narrates,默认 0.5)/ dm.waitThreshold(waiting,0.5)/ dm.askThreshold(asks_user,0.4),是全局的:插件没有按供应商分别设阈值的字段。pi 侧的 gpt-6-luna 端点在 13 条标注语料上标到 0.65(7/7 召回、1/6 误报),但那份语料用的是 pi 的输入格式与端点级阈值,迁移过来需要重新标定——这里用全局 0.5,宁漏勿滥。

一个供应商连续失败 breaker.failures 次后进入冷却,冷却期内不再发请求——一个挂掉的端点只花一次超时,而不是每个回合各花一次。

日志

默认 ~/.dsh/early-stop-guard/events.jsonl,一行一条:

{"id":7,"ts":1760000000000,"sessionId":"session-…","turn":3,
 "act":"continue","reason":"narrated","kind":"forward","steps":4,
 "text":"接下来改 environment.ts…","userRequest":"把平台层跑通",
 "verdict":{"narrates":0.946,"asksUser":0.022,"waiting":0.024},
 "provider":"bamboonode","latencyMs":1240}

act 取值:continue(续跑了)、stop(放行,回合正常结束)、skip(形状不对,debug 时才记)。reason 取值:narrated、wait-external、needs-user、done、wait、ask、gray、not-narrated、forward-denied、forward(no-judge)、budget-exhausted、steer-produced-no-tool-call、empty-text、last-step-called-tool、no-tool-in-turn、no-steps。

HTTP 接口

设置页读的六个路由(ctx.connection.fetch.register,headless profile 无 Connection 服务时自动不注册):

方法路径作用
GET/api/early-stop-guard/status生效配置、供应商(含密钥是否就位)、熔断状态、日志位置
GET/api/early-stop-guard/logs?limit=100判定记录,最新在前
POST/api/early-stop-guard/logs/clear清空
POST/api/early-stop-guard/secret存一个供应商的密钥({id, apiKey}),写入 secrets.json
POST/api/early-stop-guard/secret/clear删除某个供应商的密钥({id})
POST/api/early-stop-guard/test用一条正文跑一次真实裁判链

所有路由都必须声明 requestBody: 'buffered':省略不会报错,但请求会落进共享 RPC 通道并返回 400/404。

边界

这个插件只处理「正常结束但半途」这一种情况,刻意不做下面这些:

  • 不处理 error / interrupted / max-tokens 结束的回合(那是重试/恢复类插件的地盘)。
  • 不跨回合续跑:steer 只延长当前回合。
  • 不判断「任务是否真的完成」:那是完成度门禁类插件(跑测试、查证据)的活。
  • 裁判不可达时不放行叙述:宁可结束也不在没证据的情况下续跑。
  • 一次 steer 若没换来任何工具调用,本回合立刻收手,不再烧预算。

链尾用当前会话 LLM 托底时,裁判调用与 agent 共用同一份额度,请按需开关。

开发

pnpm install
pnpm test        # node:test,100 个用例

目录:

lib/config.js     Config schema、默认供应商、resolveConfig 收敛
lib/events.js     从 session 事件流重建本回合逐步事实
lib/heuristics.js precheck / classifyText / heuristicAction / verdictAction
lib/summary.js    给裁判的摘要与 state,超长取两端
lib/providers.js  决策模型与 LLM 两种裁判、熔断、按序尝试
lib/decide.js     整条判定链(可单测,无宿主依赖)
lib/logs.js       内存环形 + JSONL 追加
lib/index.js      钩子与 HTTP 路由接线
lib/client.js     设置页(浏览器半)

许可

MIT

Related plugins