dsh-repeat-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/cordis.patch.yml +7 -0
- package/lib/detect.d.ts +14 -0
- package/lib/detect.js +65 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +38 -0
- package/lib/resume.d.ts +21 -0
- package/lib/resume.js +64 -0
- package/lib/stream-guard.d.ts +14 -0
- package/lib/stream-guard.js +71 -0
- package/lib/turn-stopping-guard.d.ts +15 -0
- package/lib/turn-stopping-guard.js +18 -0
- package/lib/types.d.ts +51 -0
- package/lib/types.js +7 -0
- package/package.json +34 -0
- package/src/detect.ts +68 -0
- package/src/index.ts +44 -0
- package/src/resume.ts +76 -0
- package/src/stream-guard.ts +88 -0
- package/src/turn-stopping-guard.ts +24 -0
- package/src/types.ts +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yuqing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# dsh-repeat-guard
|
|
2
|
+
|
|
3
|
+
拦截思考段的"复读"退化:模型在思考里陷入反复输出"好。""执行。"这类碎片空话时,掐断本次生成,并让本轮继续往下跑,而不是停下来等用户输入。
|
|
4
|
+
|
|
5
|
+
| 项 | 说明 |
|
|
6
|
+
|---|---|
|
|
7
|
+
| 类型 | dsh host 侧插件(函数式) |
|
|
8
|
+
| 监听 | `llm/stream`、`agent/turn-stopping` |
|
|
9
|
+
| 运行时依赖 | **无**,不产生任何运行时 import |
|
|
10
|
+
| 构建期依赖 | 仅 TypeScript |
|
|
11
|
+
| 入口 | `lib/index.js`(由 `src/index.ts` 编译得到) |
|
|
12
|
+
| 分发 | npm 包 `dsh-repeat-guard` |
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 它解决什么问题
|
|
17
|
+
|
|
18
|
+
模型退化时,思考段里会出现整行都是空话的情况,如"好。""执行。""OK." "Let me go."。
|
|
19
|
+
|
|
20
|
+
这类思考本身不含信息,但会一直持续到 token 耗尽,把一次本可以正常完成的任务拖垮。
|
|
21
|
+
|
|
22
|
+
## 检测范围
|
|
23
|
+
|
|
24
|
+
**只看思考段(`reasoning-delta`),正文(`text-delta`)不参与判定,原样透传。**
|
|
25
|
+
|
|
26
|
+
复读退化先出现在思考段,那时正文往往还没开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
27
|
+
|
|
28
|
+
## 判据:查表
|
|
29
|
+
|
|
30
|
+
判定不做任何"形状归纳",只查一张表:**思考里有没有独占一行的表项**。
|
|
31
|
+
|
|
32
|
+
最初试过两条归纳式判据,都被证伪:
|
|
33
|
+
|
|
34
|
+
1. **数碎片重复了几次**("好,好,好,好")。它不看句子结构,只看重复次数,于是"然后,然后,然后,"(逗号被当作片段分隔符)这类正常句子会被判成复读。
|
|
35
|
+
2. **按形状判断空话行**(中文去标点后 ≤2 字、英文 ≤3 词)。形状规则必然外溢:"解。""豫。"这种被换行切开的半截词也满足形状,实测在大段正常思考里频繁误杀。
|
|
36
|
+
|
|
37
|
+
表项是具体的、可增删的、可审计的,不会外溢。规则只有一条:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
整行去掉首尾空白 → 转小写 → 查表
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| 输入 | 结果 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `好。` `好的。` `执行。` `写。` `明白。` | 拦(表内) |
|
|
46
|
+
| `OK.` `Sure.` `Let me go.` `Got it.` | 拦(表内) |
|
|
47
|
+
| `查一下。` `这需要我。` | 不拦(表外) |
|
|
48
|
+
| `A.` `1.` | 不拦(表外) |
|
|
49
|
+
| `然后,` `注意:` `好的,` | 不拦(表外) |
|
|
50
|
+
| `好?` `好!` `好` `OK` | 不拦(表外) |
|
|
51
|
+
| `我需要检查实现。\n然后重新编译。` | 不拦(各行都在表外) |
|
|
52
|
+
|
|
53
|
+
**表在 `src/detect.ts` 的 `FRAGMENTS` 数组里**,增删词条改它即可。表项**连标点一起写**(`'好的。'` 对应思考里的 `好的。`),英文条目一律写小写。
|
|
54
|
+
|
|
55
|
+
已知边界:判据只看"有没有这样一行",不看上下文。所以"用户想让我改代码。\n好的。"这种**前面有实质内容、结尾又跟一句空话**的思考也会被拦。这是"按行判定"的直接结果。
|
|
56
|
+
|
|
57
|
+
## 工作原理
|
|
58
|
+
|
|
59
|
+
1. 包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察 `reasoning-delta`,累积本次响应已产出的思考文本。
|
|
60
|
+
2. 命中退化时,把触发退化的那个 delta 照常放行,补一个 `block-end` 闭合思考块,再补一个 `finish(stop)`,然后结束流。agent-loop 的 `BlockAssembler` 会把这种"没有 finish 就结束"的流当作正常 `stop`,于是本次调用被当成正常完成:已生成的思考照常落盘。
|
|
61
|
+
3. 提前结束流时显式向下游传播 `iterator.return()`,否则上游 HTTP 流会继续跑到结束,供应商照常计满 token,且连接悬挂。
|
|
62
|
+
4. 光截断只会让本轮就此结束、停下来等用户输入。所以再挂一个 `agent/turn-stopping` 监听器,在本轮边界提交之前调 `agent.steer(...)` 推一条输入,本轮就会接着再跑一步。
|
|
63
|
+
|
|
64
|
+
辅助调用(上下文压缩、会话标题等带 `purpose` 的调用)不参与检测。
|
|
65
|
+
|
|
66
|
+
每次拦截都会把**命中的那一行原文**写进 journal,便于事后核对误杀:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
[repeat-guard] 检出思考段复读,已掐断本次生成 | 命中行="好的。"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 掐断之后怎么让本轮继续
|
|
73
|
+
|
|
74
|
+
dsh 的 turn 循环(`dsh-agent-loop/lib/index.js:966-973`):
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
if (turnEnds && this.inbox.nextStep.length === 0) {
|
|
78
|
+
await this.dispatch.serial("agent/turn-stopping", { turn, signal });
|
|
79
|
+
}
|
|
80
|
+
if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读收件箱
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`agent/turn-stopping` 在 `break` 之前被 `await`,之后收件箱会被**重新读一次**。所以只要监听器往 `inbox.nextStep` 里推入东西,本轮就不 break,而是再跑一步。官方对该事件的说明也写明了这个用法,先例见 `dsh-hooks-claude-code/lib/index.js:292`。
|
|
84
|
+
|
|
85
|
+
注意:
|
|
86
|
+
|
|
87
|
+
- **做不到"这一步当作没发生"。** 被截断的 assistant 消息在 `step()` 里已经 `session.append` 落盘,dsh 没有回滚一步的机制;`step()` 唯一返回 `null`(循环继续)的出口要求本步真的产生了 tool-call,插件够不着。所以这里的语义是"接着再跑一步",会话里会留下被截断的思考记录。
|
|
88
|
+
|
|
89
|
+
## 可调项
|
|
90
|
+
|
|
91
|
+
判定本身没有阈值,只有 `src/detect.ts` 里的 `FRAGMENTS` 表。改完需要重新构建。
|
|
92
|
+
|
|
93
|
+
| 项 | 位置 | 含义 |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `FRAGMENTS` | `src/detect.ts` | 会被判为复读的空话短句表,增删词条改它 |
|
|
96
|
+
|
|
97
|
+
## 续跑时推给模型的输入
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
[复读拦截]
|
|
101
|
+
你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。
|
|
102
|
+
请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
这条消息带 `source.kind = 'plugin'`,客户端会渲染成折叠的 context 行,不是用户气泡。
|
|
106
|
+
|
|
107
|
+
## 构建
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm install
|
|
111
|
+
npm run build # 等价于 tsc,产出 lib/
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`lib/` **不进版本库**:`npm publish` 前由 `prepack` 钩子现构建,随包发布(`files` 字段已含 `lib`)。这样"改了 `src/` 忘了 build"不会让旧产物静默跟着提交——代价是构建失败时发不出去,这是有意的。
|
|
115
|
+
|
|
116
|
+
## 发布
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm login --registry=https://registry.npmjs.org
|
|
120
|
+
npm publish --registry=https://registry.npmjs.org
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**两条命令都别省 `--registry`。** npm 的凭据是**按源绑定**的:`npm login` 登的是哪个源,`~/.npmrc` 里就只在那个源下记一条 `//registry.npmjs.org/:_authToken`。所以 `npm config get registry` 一旦被切到 npmmirror 这类只读镜像(它本身也发不上去),不带参数的 `npm publish` 会直接报 `need auth`。
|
|
124
|
+
|
|
125
|
+
发布前不必手动构建,`prepack` 会跑一次 `npm run build`;tsc 报错则发布中止。
|
|
126
|
+
|
|
127
|
+
## 安装到 dsh
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
dsh plugin --profile web add dsh-repeat-guard
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
本插件是一个 **dsh bundle**——包根带 `cordis.patch.yml`,由 `package.json` 的 `dsh.bundle.patch` 声明。装进 profile 时它会自动进入 `dsh.profile.bundles` 分层栈,**不需要改 profile 自己的 `cordis.patch.yml`**。
|
|
134
|
+
|
|
135
|
+
装完**必须重启 dsh 进程**。`patchReload: live` 只重载已有的层,不会加载新增的层——实测编辑后运行中的进程既不加载也不报错。
|
|
136
|
+
|
|
137
|
+
机制(dsh 源码):`dsh plugin` 是 pnpm 转发器(`dsh/lib/plugin-*.js`),在 profile 目录跑 `pnpm add` 后按**安装后的真实包名**调和 `dsh.profile.bundles`,判定条件就是 `dsh.bundle.patch` 是否为 undefined(没有就印一句 "declares no dsh.bundle — installed as a plain dependency, not a profile layer")。加载时 `dsh-app-boot` 的 `loadProfileDirectory` 对该字段做 `join(packageDir, declared)` 并当 overlay patch 读;声明缺失直接抛错。
|
|
138
|
+
|
|
139
|
+
**只走 npm 分发。** git 安装(`add github:...`)会让 pnpm 拦下依赖的构建脚本,装出来的包没有 `lib/index.js`,除非安装者手工去 profile 的 `pnpm-workspace.yaml` 加 `allowBuilds` 白名单——那等于把本机的构建配置摊派给每个使用者。本地目录安装同理,只适合开发调试,不作为分发方式。
|
|
140
|
+
|
|
141
|
+
验证是否加载:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
journalctl --user -u dsh-web --no-pager | grep -a repeat-guard
|
|
145
|
+
# [repeat-guard] 已加载,复读拦截生效
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
⚠️ 插件加载失败会让 **dsh 启动直接失败**,而 dsh-web 是本机 GUI 的唯一通道。改完先离线验证再重启:用假上下文 import 产物调一次入口,或喂一段真实 chunk 序列。
|
|
149
|
+
|
|
150
|
+
## 开发约束:不得 import 外部包
|
|
151
|
+
|
|
152
|
+
**不得 import 任何外部包,包括 `@deepseek-ai/cordis`;模块之间只用相对路径互相导入。**
|
|
153
|
+
|
|
154
|
+
`cordis-plugin-loader` 的 `import()` 对**裸 specifier**(包名)直接 `import(name)`,锚点是 loader 自身的文件位置(dsh 安装目录内部),从 profile 或全局顶层加载时都可能解析不到插件的依赖。而**相对 specifier** 走 `new URL(name, baseUrl)`,由 Node 原生 ESM 按当前文件位置解析,不受影响——所以内部拆模块是安全的,新增模块时只连相对路径即可,源码里要带 `.js` 后缀。
|
|
155
|
+
|
|
156
|
+
由这条约束派生的两点:
|
|
157
|
+
|
|
158
|
+
- 类型一律在 `src/types.ts` 里声明,不 import dsh 的类型(那还要为 `tsc` 配 `paths` 指向 dsh 安装目录,路径里带 node 版本号,dsh 一升级就失效),也不引 `@types/node`(各模块按需 `declare` 用到的全局);
|
|
159
|
+
- 只用 TypeScript 写源码,由 `tsc` 产出 `lib/` 供 dsh 加载。dsh 的 loader 是原生 ESM import,**没有转译层**,运行时读到的永远是编译产物——改完 `src/` 必须重新构建才能生效。`lib/` 只随 npm 包发布,不进版本库。
|
|
160
|
+
|
|
161
|
+
## 代码约定
|
|
162
|
+
|
|
163
|
+
| 项 | 约定 |
|
|
164
|
+
|---|---|
|
|
165
|
+
| 缩进 / 引号 / 分号 | 2 空格、单引号、语句末分号 |
|
|
166
|
+
| 行宽 | 目标 100 字符,上限 120(中英混排按字符数计) |
|
|
167
|
+
| 控制语句 | **一律带花括号**,单行 `if`、`continue`、`break` 也不例外,不写 `if (x) return;` |
|
|
168
|
+
| 文件 | 一个文件只干一件事:类型声明 / 判定 / 续跑 / 单个监听器 / 装配,各占一个文件 |
|
|
169
|
+
| 函数 | 不超过 50 行;超了就先看能不能按职责拆开 |
|
|
170
|
+
| 注释 | 一律中文;导出函数与判定函数配 `@param` / `@returns`,文件内小工具函数用单行注释即可 |
|
|
171
|
+
|
|
172
|
+
排版细节已固化在 `.editorconfig`。花括号、文件职责、函数行数这三条没有配置项可表达,只能靠人工遵守。
|
|
173
|
+
|
|
174
|
+
## 目录结构
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
.
|
|
178
|
+
├── src/
|
|
179
|
+
│ ├── index.ts 插件入口:装配状态,注册两个监听器(只做装配)
|
|
180
|
+
│ ├── types.ts 与 dsh 交互的接口声明(纯类型,编译后为空模块)
|
|
181
|
+
│ ├── detect.ts 复读判定:阈值常量 + 纯函数,不碰会话状态
|
|
182
|
+
│ ├── resume.ts 续跑:注入文案、消息构造、推送
|
|
183
|
+
│ ├── stream-guard.ts llm/stream 监听器:思考段检测与掐断
|
|
184
|
+
│ └── turn-stopping-guard.ts agent/turn-stopping 监听器:让本轮继续
|
|
185
|
+
├── lib/ tsc 产物(已 gitignore),dsh 实际加载 lib/index.js
|
|
186
|
+
├── package.json
|
|
187
|
+
└── tsconfig.json
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
## 许可
|
|
192
|
+
|
|
193
|
+
MIT,见 `LICENSE`。
|
package/cordis.patch.yml
ADDED
package/lib/detect.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 复读退化判定:查表。
|
|
3
|
+
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**。判定就是"思考里有没有
|
|
5
|
+
* 独占一行的表项",整行与表项全等,不拆解行的内部。
|
|
6
|
+
*
|
|
7
|
+
* 增删条目直接改下面的 FRAGMENTS 即可,改完重新构建。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 找出退化的碎片行。
|
|
11
|
+
* @param text - 已生成的思考文本。
|
|
12
|
+
* @returns 命中返回该行的原文,未命中返回 null。
|
|
13
|
+
*/
|
|
14
|
+
export declare function findDegenerateLine(text: string): string | null;
|
package/lib/detect.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 复读退化判定:查表。
|
|
3
|
+
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**。判定就是"思考里有没有
|
|
5
|
+
* 独占一行的表项",整行与表项全等,不拆解行的内部。
|
|
6
|
+
*
|
|
7
|
+
* 增删条目直接改下面的 FRAGMENTS 即可,改完重新构建。
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 会被复读的空话短句表,标点照原样写。
|
|
11
|
+
*
|
|
12
|
+
* 英文条目一律写小写,判定时会把整行转成小写再比。
|
|
13
|
+
*/
|
|
14
|
+
const FRAGMENTS = [
|
|
15
|
+
// 中文
|
|
16
|
+
'好。',
|
|
17
|
+
'好的。',
|
|
18
|
+
'好嘞。',
|
|
19
|
+
'对。',
|
|
20
|
+
'对的。',
|
|
21
|
+
'是。',
|
|
22
|
+
'是的。',
|
|
23
|
+
'行。',
|
|
24
|
+
'嗯。',
|
|
25
|
+
'可以。',
|
|
26
|
+
'明白。',
|
|
27
|
+
'收到。',
|
|
28
|
+
'了解。',
|
|
29
|
+
'执行。',
|
|
30
|
+
'继续。',
|
|
31
|
+
'确认。',
|
|
32
|
+
'完成。',
|
|
33
|
+
'搞定。',
|
|
34
|
+
'写。',
|
|
35
|
+
'查。',
|
|
36
|
+
'看。',
|
|
37
|
+
// 英文
|
|
38
|
+
'ok.',
|
|
39
|
+
'okay.',
|
|
40
|
+
'sure.',
|
|
41
|
+
'alright.',
|
|
42
|
+
'right.',
|
|
43
|
+
'yes.',
|
|
44
|
+
'done.',
|
|
45
|
+
'got it.',
|
|
46
|
+
'let me go.',
|
|
47
|
+
'let me do it.',
|
|
48
|
+
'check it.',
|
|
49
|
+
];
|
|
50
|
+
/** 查表用的集合。 */
|
|
51
|
+
const FRAGMENT_SET = new Set(FRAGMENTS);
|
|
52
|
+
/**
|
|
53
|
+
* 找出退化的碎片行。
|
|
54
|
+
* @param text - 已生成的思考文本。
|
|
55
|
+
* @returns 命中返回该行的原文,未命中返回 null。
|
|
56
|
+
*/
|
|
57
|
+
export function findDegenerateLine(text) {
|
|
58
|
+
for (const raw of text.split('\n')) {
|
|
59
|
+
const line = raw.trim();
|
|
60
|
+
if (line !== '' && FRAGMENT_SET.has(line.toLowerCase())) {
|
|
61
|
+
return line;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// dsh-repeat-guard — host 侧插件入口:拦截"复读"型退化输出。
|
|
2
|
+
//
|
|
3
|
+
// 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
|
|
4
|
+
// (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
|
|
5
|
+
// 闭合思考块,再补一个 finish(stop),然后直接结束流。agent-loop 的 BlockAssembler 会把
|
|
6
|
+
// 这种"没有 finish 就结束"的流当作正常 stop(assembler.js: `this._finish ?? {kind:'stop'}`),
|
|
7
|
+
// 于是本次调用被当成正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
8
|
+
//
|
|
9
|
+
// 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
|
|
10
|
+
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
11
|
+
//
|
|
12
|
+
// 判定做什么:思考段里出现单独成行的一句空话(中文一到两个汉字,如"好。""执行。";
|
|
13
|
+
// 英文不超过三个单词,如"OK." "Let me go.")就掐断。判据的取舍见 detect.ts 的注释——
|
|
14
|
+
// 关键是不能把"同样的碎片出现了几次"当作复读信号,那样会误伤正常的短句。
|
|
15
|
+
//
|
|
16
|
+
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
17
|
+
// 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
|
|
18
|
+
//
|
|
19
|
+
// 依赖约束(重要):不得 import 任何外部包,包括 @deepseek-ai/cordis。
|
|
20
|
+
// cordis-plugin-loader 解析裸 specifier 时直接 `import(name)`,锚点是 loader 自身的
|
|
21
|
+
// 文件位置(dsh 安装目录内部),从 profile 或全局顶层加载时都可能解析不到插件的依赖。
|
|
22
|
+
// **相对路径 import 不受此限**:它由 Node 原生 ESM 按当前文件位置解析,所以本插件内部
|
|
23
|
+
// 按职责拆出的这些模块可以正常互相导入。新增模块时照此办理:只连相对路径。
|
|
24
|
+
//
|
|
25
|
+
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
26
|
+
import { createStreamGuard } from './stream-guard.js';
|
|
27
|
+
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
28
|
+
/**
|
|
29
|
+
* 函数式插件入口。ctx 为 cordis 上下文,注册的监听随插件卸载自动释放。
|
|
30
|
+
* @param ctx - 宿主 cordis 上下文。
|
|
31
|
+
*/
|
|
32
|
+
export default function repeatGuard(ctx) {
|
|
33
|
+
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
34
|
+
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
35
|
+
const state = { pending: new Set() };
|
|
36
|
+
ctx.on('llm/stream', createStreamGuard(state), { global: true });
|
|
37
|
+
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
|
|
38
|
+
}
|
package/lib/resume.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 续跑:截断之后让本轮不要就此关闭,而是再跑一步。
|
|
3
|
+
*
|
|
4
|
+
* 机制在 dsh 侧:`agent/turn-stopping` 是 serial 事件,在本轮边界提交之前被 await,
|
|
5
|
+
* 之后收件箱会被重新读一次(dsh-agent-loop/lib/index.js:967-973):
|
|
6
|
+
*
|
|
7
|
+
* if (turnEnds && this.inbox.nextStep.length === 0) {
|
|
8
|
+
* await this.dispatch.serial("agent/turn-stopping", { turn, signal });
|
|
9
|
+
* }
|
|
10
|
+
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
|
+
*
|
|
12
|
+
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
+
* 用户输入。dsh 自己在 dsh-hooks-claude-code/lib/index.js:292 有同样的用法。
|
|
14
|
+
*/
|
|
15
|
+
import type { GuardState, SteerableAgent } from './types.js';
|
|
16
|
+
/**
|
|
17
|
+
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
18
|
+
* @param state - 跨监听保留的拦截状态。
|
|
19
|
+
* @param agent - 本轮所属的 agent 句柄。
|
|
20
|
+
*/
|
|
21
|
+
export declare function reviveTurn(state: GuardState, agent: SteerableAgent | undefined): void;
|
package/lib/resume.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 续跑:截断之后让本轮不要就此关闭,而是再跑一步。
|
|
3
|
+
*
|
|
4
|
+
* 机制在 dsh 侧:`agent/turn-stopping` 是 serial 事件,在本轮边界提交之前被 await,
|
|
5
|
+
* 之后收件箱会被重新读一次(dsh-agent-loop/lib/index.js:967-973):
|
|
6
|
+
*
|
|
7
|
+
* if (turnEnds && this.inbox.nextStep.length === 0) {
|
|
8
|
+
* await this.dispatch.serial("agent/turn-stopping", { turn, signal });
|
|
9
|
+
* }
|
|
10
|
+
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
|
+
*
|
|
12
|
+
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
+
* 用户输入。dsh 自己在 dsh-hooks-claude-code/lib/index.js:292 有同样的用法。
|
|
14
|
+
*/
|
|
15
|
+
/** 续跑时推给模型的指令正文。 */
|
|
16
|
+
const RESUME_TEXT = [
|
|
17
|
+
'[复读拦截]',
|
|
18
|
+
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
19
|
+
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
20
|
+
].join('\n');
|
|
21
|
+
/** 折叠行显示的一行摘要;空字符串会让客户端回退成 opaque 渲染,故不可为空。 */
|
|
22
|
+
const RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
23
|
+
/**
|
|
24
|
+
* 构造推给模型的一条输入,结构与 dsh 的 createUserMessage 对齐。
|
|
25
|
+
* @returns 一条插件来源的 user 消息,客户端会渲染成折叠的 context 行。
|
|
26
|
+
*/
|
|
27
|
+
function resumeMessage() {
|
|
28
|
+
return {
|
|
29
|
+
id: crypto.randomUUID(),
|
|
30
|
+
role: 'user',
|
|
31
|
+
content: [{ type: 'text', text: RESUME_TEXT }],
|
|
32
|
+
source: {
|
|
33
|
+
kind: 'plugin',
|
|
34
|
+
plugin: 'repeat-guard',
|
|
35
|
+
form: 'notice',
|
|
36
|
+
summary: RESUME_SUMMARY,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
42
|
+
* @param state - 跨监听保留的拦截状态。
|
|
43
|
+
* @param agent - 本轮所属的 agent 句柄。
|
|
44
|
+
*/
|
|
45
|
+
export function reviveTurn(state, agent) {
|
|
46
|
+
// 三个提前返回都要留痕:不打印就无法区分"没触发""id 对不上""没有标记"。
|
|
47
|
+
if (agent === undefined || typeof agent.steer !== 'function') {
|
|
48
|
+
console.log('[repeat-guard] turn-stopping:载荷里没有 agent 句柄,不续跑');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const sessionId = agent.session?.id;
|
|
52
|
+
if (sessionId === undefined) {
|
|
53
|
+
console.log('[repeat-guard] turn-stopping:agent 上没有会话 id,不续跑');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!state.pending.has(sessionId)) {
|
|
57
|
+
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 没有待续跑标记,不续跑` +
|
|
58
|
+
`(当前有标记的会话:${[...state.pending].join(',') || '无'})`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
state.pending.delete(sessionId);
|
|
62
|
+
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
|
|
63
|
+
agent.steer(resumeMessage());
|
|
64
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `llm/stream` 监听器:包一层上游流,思考段命中复读退化就补完流并提前结束。
|
|
3
|
+
*
|
|
4
|
+
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
|
+
*/
|
|
6
|
+
import type { GenerateOptions, GuardState, StreamChunk } from './types.js';
|
|
7
|
+
/** `llm/stream` 的监听器签名。 */
|
|
8
|
+
export type StreamListener = (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>;
|
|
9
|
+
/**
|
|
10
|
+
* 造一个 `llm/stream` 监听器。
|
|
11
|
+
* @param state - 跨监听保留的拦截状态。
|
|
12
|
+
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
13
|
+
*/
|
|
14
|
+
export declare function createStreamGuard(state: GuardState): StreamListener;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `llm/stream` 监听器:包一层上游流,思考段命中复读退化就补完流并提前结束。
|
|
3
|
+
*
|
|
4
|
+
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
|
+
*/
|
|
6
|
+
import { findDegenerateLine } from './detect.js';
|
|
7
|
+
/**
|
|
8
|
+
* 逐 chunk 检查上游流的思考段,命中复读退化时补完这段流。
|
|
9
|
+
*
|
|
10
|
+
* 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
|
|
11
|
+
* 所以累积文本用一个字符串即可,不需要按 index 分开存。
|
|
12
|
+
*
|
|
13
|
+
* @param downstream - 上游模型的流。
|
|
14
|
+
* @param sessionId - 当前会话 id;有值才登记待续跑标记。
|
|
15
|
+
* @param state - 跨监听保留的拦截状态。
|
|
16
|
+
* @returns 包好的流。
|
|
17
|
+
*/
|
|
18
|
+
async function* guardStream(downstream, sessionId, state) {
|
|
19
|
+
const iterator = downstream[Symbol.asyncIterator]();
|
|
20
|
+
let accumulated = '';
|
|
21
|
+
try {
|
|
22
|
+
for (;;) {
|
|
23
|
+
const step = await iterator.next();
|
|
24
|
+
if (step.done) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const chunk = step.value;
|
|
28
|
+
if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') {
|
|
29
|
+
accumulated += chunk.text;
|
|
30
|
+
const hit = findDegenerateLine(accumulated);
|
|
31
|
+
if (hit !== null) {
|
|
32
|
+
if (sessionId !== undefined) {
|
|
33
|
+
state.pending.add(sessionId);
|
|
34
|
+
}
|
|
35
|
+
console.log(`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
|
|
36
|
+
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
37
|
+
const index = chunk.index ?? 0;
|
|
38
|
+
yield chunk;
|
|
39
|
+
yield { type: 'block-end', index, block: { type: 'reasoning', text: accumulated } };
|
|
40
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
yield chunk;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
// 提前 return 时必须显式向下游传播:不补这一步上游 HTTP 流会继续跑到结束,
|
|
49
|
+
// 供应商照常计满 token,且连接悬挂。上游可能已经结束/关闭,其异常忽略。
|
|
50
|
+
try {
|
|
51
|
+
await iterator.return?.();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* 上游已经结束或关闭,无需处理 */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 造一个 `llm/stream` 监听器。
|
|
60
|
+
* @param state - 跨监听保留的拦截状态。
|
|
61
|
+
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
62
|
+
*/
|
|
63
|
+
export function createStreamGuard(state) {
|
|
64
|
+
return (options, next) => {
|
|
65
|
+
// 辅助调用(上下文压缩、会话标题)不参与检测。
|
|
66
|
+
if (options.purpose !== undefined) {
|
|
67
|
+
return next();
|
|
68
|
+
}
|
|
69
|
+
return guardStream(next(), options.sessionId, state);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent/turn-stopping` 监听器:把上一步掐断欠下的续跑补上。
|
|
3
|
+
*
|
|
4
|
+
* 监听器的返回值不被使用(该事件是 serial 的副作用钩子),实现方式是调
|
|
5
|
+
* `agent.steer(...)`,见 resume.ts 的说明。
|
|
6
|
+
*/
|
|
7
|
+
import type { GuardState } from './types.js';
|
|
8
|
+
/** `agent/turn-stopping` 的监听器签名。 */
|
|
9
|
+
export type TurnStoppingListener = (payload: unknown) => void;
|
|
10
|
+
/**
|
|
11
|
+
* 造一个 `agent/turn-stopping` 监听器。
|
|
12
|
+
* @param state - 跨监听保留的拦截状态。
|
|
13
|
+
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
14
|
+
*/
|
|
15
|
+
export declare function createTurnStoppingGuard(state: GuardState): TurnStoppingListener;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent/turn-stopping` 监听器:把上一步掐断欠下的续跑补上。
|
|
3
|
+
*
|
|
4
|
+
* 监听器的返回值不被使用(该事件是 serial 的副作用钩子),实现方式是调
|
|
5
|
+
* `agent.steer(...)`,见 resume.ts 的说明。
|
|
6
|
+
*/
|
|
7
|
+
import { reviveTurn } from './resume.js';
|
|
8
|
+
/**
|
|
9
|
+
* 造一个 `agent/turn-stopping` 监听器。
|
|
10
|
+
* @param state - 跨监听保留的拦截状态。
|
|
11
|
+
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
12
|
+
*/
|
|
13
|
+
export function createTurnStoppingGuard(state) {
|
|
14
|
+
return (rawPayload) => {
|
|
15
|
+
const payload = rawPayload;
|
|
16
|
+
reviveTurn(state, payload?.agent);
|
|
17
|
+
};
|
|
18
|
+
}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与本插件交互的 dsh 接口声明。
|
|
3
|
+
*
|
|
4
|
+
* 这些类型一律在本插件内声明,不 import dsh 的类型:那样还要为 tsc 配 paths 指向
|
|
5
|
+
* dsh 安装目录,路径里带 node 版本号,dsh 一升级就失效。
|
|
6
|
+
*/
|
|
7
|
+
/** 一个流式 chunk。本插件只读 reasoning-delta 的 index/text,其余字段原样透传。 */
|
|
8
|
+
export interface StreamChunk {
|
|
9
|
+
readonly type: string;
|
|
10
|
+
readonly index?: number;
|
|
11
|
+
readonly text?: string;
|
|
12
|
+
readonly [key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** 一次模型调用的参数;只声明本插件读到的两个字段。 */
|
|
15
|
+
export interface GenerateOptions {
|
|
16
|
+
readonly purpose?: string;
|
|
17
|
+
readonly sessionId?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `agent/turn-stopping` 的载荷。
|
|
21
|
+
*
|
|
22
|
+
* dsh 的注释写明:本轮即将关闭(模型不再欠响应)时、在边界提交之前 await 这个事件;
|
|
23
|
+
* 监听器若不同意关闭,就调 `agent.steer(...)` 推入新的输入,机器会重读收件箱——
|
|
24
|
+
* 有新的 steering 就再跑一步,没有才真正关闭本轮。
|
|
25
|
+
*/
|
|
26
|
+
export interface TurnStoppingPayload {
|
|
27
|
+
readonly agent?: SteerableAgent;
|
|
28
|
+
}
|
|
29
|
+
/** 能往本轮收件箱推输入、从而让本轮继续的 agent 句柄。 */
|
|
30
|
+
export interface SteerableAgent {
|
|
31
|
+
readonly session?: {
|
|
32
|
+
readonly id?: string;
|
|
33
|
+
};
|
|
34
|
+
/** 推入 `next-step` 并唤醒驱动器:本轮再跑一步。 */
|
|
35
|
+
steer(input: unknown): void;
|
|
36
|
+
}
|
|
37
|
+
/** 注入给 dsh 的 cordis 上下文;只声明本插件用到的能力。 */
|
|
38
|
+
export interface PluginContext {
|
|
39
|
+
/**
|
|
40
|
+
* 监听器的形参随所监听的事件而异,只能声明成 any[]:换成 unknown[] 会因函数
|
|
41
|
+
* 参数逆变,导致各监听器的具体签名无法赋值。
|
|
42
|
+
*/
|
|
43
|
+
on(name: string, listener: (...args: any[]) => unknown, options?: {
|
|
44
|
+
global?: boolean;
|
|
45
|
+
}): void;
|
|
46
|
+
}
|
|
47
|
+
/** 跨两次监听保留的拦截状态。 */
|
|
48
|
+
export interface GuardState {
|
|
49
|
+
/** 发生过截断、等待下一步续跑的会话 id。 */
|
|
50
|
+
readonly pending: Set<string>;
|
|
51
|
+
}
|
package/lib/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-repeat-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "流式输出退化拦截:检测碎片复读,掐断本次生成并提醒模型直接执行工具调用",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"src",
|
|
18
|
+
"cordis.patch.yml"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"prepack": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "yuqing",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"typescript": "^7.0.2"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 复读退化判定:查表。
|
|
3
|
+
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**。判定就是"思考里有没有
|
|
5
|
+
* 独占一行的表项",整行与表项全等,不拆解行的内部。
|
|
6
|
+
*
|
|
7
|
+
* 增删条目直接改下面的 FRAGMENTS 即可,改完重新构建。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 会被复读的空话短句表,标点照原样写。
|
|
12
|
+
*
|
|
13
|
+
* 英文条目一律写小写,判定时会把整行转成小写再比。
|
|
14
|
+
*/
|
|
15
|
+
const FRAGMENTS: readonly string[] = [
|
|
16
|
+
// 中文
|
|
17
|
+
'好。',
|
|
18
|
+
'好的。',
|
|
19
|
+
'好嘞。',
|
|
20
|
+
'对。',
|
|
21
|
+
'对的。',
|
|
22
|
+
'是。',
|
|
23
|
+
'是的。',
|
|
24
|
+
'行。',
|
|
25
|
+
'嗯。',
|
|
26
|
+
'可以。',
|
|
27
|
+
'明白。',
|
|
28
|
+
'收到。',
|
|
29
|
+
'了解。',
|
|
30
|
+
'执行。',
|
|
31
|
+
'继续。',
|
|
32
|
+
'确认。',
|
|
33
|
+
'完成。',
|
|
34
|
+
'搞定。',
|
|
35
|
+
'写。',
|
|
36
|
+
'查。',
|
|
37
|
+
'看。',
|
|
38
|
+
// 英文
|
|
39
|
+
'ok.',
|
|
40
|
+
'okay.',
|
|
41
|
+
'sure.',
|
|
42
|
+
'alright.',
|
|
43
|
+
'right.',
|
|
44
|
+
'yes.',
|
|
45
|
+
'done.',
|
|
46
|
+
'got it.',
|
|
47
|
+
'let me go.',
|
|
48
|
+
'let me do it.',
|
|
49
|
+
'check it.',
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** 查表用的集合。 */
|
|
53
|
+
const FRAGMENT_SET = new Set(FRAGMENTS);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 找出退化的碎片行。
|
|
57
|
+
* @param text - 已生成的思考文本。
|
|
58
|
+
* @returns 命中返回该行的原文,未命中返回 null。
|
|
59
|
+
*/
|
|
60
|
+
export function findDegenerateLine(text: string): string | null {
|
|
61
|
+
for (const raw of text.split('\n')) {
|
|
62
|
+
const line = raw.trim();
|
|
63
|
+
if (line !== '' && FRAGMENT_SET.has(line.toLowerCase())) {
|
|
64
|
+
return line;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// dsh-repeat-guard — host 侧插件入口:拦截"复读"型退化输出。
|
|
2
|
+
//
|
|
3
|
+
// 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
|
|
4
|
+
// (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
|
|
5
|
+
// 闭合思考块,再补一个 finish(stop),然后直接结束流。agent-loop 的 BlockAssembler 会把
|
|
6
|
+
// 这种"没有 finish 就结束"的流当作正常 stop(assembler.js: `this._finish ?? {kind:'stop'}`),
|
|
7
|
+
// 于是本次调用被当成正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
8
|
+
//
|
|
9
|
+
// 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
|
|
10
|
+
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
11
|
+
//
|
|
12
|
+
// 判定做什么:思考段里出现单独成行的一句空话(中文一到两个汉字,如"好。""执行。";
|
|
13
|
+
// 英文不超过三个单词,如"OK." "Let me go.")就掐断。判据的取舍见 detect.ts 的注释——
|
|
14
|
+
// 关键是不能把"同样的碎片出现了几次"当作复读信号,那样会误伤正常的短句。
|
|
15
|
+
//
|
|
16
|
+
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
17
|
+
// 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
|
|
18
|
+
//
|
|
19
|
+
// 依赖约束(重要):不得 import 任何外部包,包括 @deepseek-ai/cordis。
|
|
20
|
+
// cordis-plugin-loader 解析裸 specifier 时直接 `import(name)`,锚点是 loader 自身的
|
|
21
|
+
// 文件位置(dsh 安装目录内部),从 profile 或全局顶层加载时都可能解析不到插件的依赖。
|
|
22
|
+
// **相对路径 import 不受此限**:它由 Node 原生 ESM 按当前文件位置解析,所以本插件内部
|
|
23
|
+
// 按职责拆出的这些模块可以正常互相导入。新增模块时照此办理:只连相对路径。
|
|
24
|
+
//
|
|
25
|
+
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
26
|
+
|
|
27
|
+
import { createStreamGuard } from './stream-guard.js';
|
|
28
|
+
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
29
|
+
import type { GuardState, PluginContext } from './types.js';
|
|
30
|
+
|
|
31
|
+
/** dsh 跑在 Node 上;只声明本插件用到的全局,避免为此引入 @types/node。 */
|
|
32
|
+
declare const console: { log(...args: unknown[]): void };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 函数式插件入口。ctx 为 cordis 上下文,注册的监听随插件卸载自动释放。
|
|
36
|
+
* @param ctx - 宿主 cordis 上下文。
|
|
37
|
+
*/
|
|
38
|
+
export default function repeatGuard(ctx: PluginContext): void {
|
|
39
|
+
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
40
|
+
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
41
|
+
const state: GuardState = { pending: new Set() };
|
|
42
|
+
ctx.on('llm/stream', createStreamGuard(state), { global: true });
|
|
43
|
+
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
|
|
44
|
+
}
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 续跑:截断之后让本轮不要就此关闭,而是再跑一步。
|
|
3
|
+
*
|
|
4
|
+
* 机制在 dsh 侧:`agent/turn-stopping` 是 serial 事件,在本轮边界提交之前被 await,
|
|
5
|
+
* 之后收件箱会被重新读一次(dsh-agent-loop/lib/index.js:967-973):
|
|
6
|
+
*
|
|
7
|
+
* if (turnEnds && this.inbox.nextStep.length === 0) {
|
|
8
|
+
* await this.dispatch.serial("agent/turn-stopping", { turn, signal });
|
|
9
|
+
* }
|
|
10
|
+
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
|
+
*
|
|
12
|
+
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
+
* 用户输入。dsh 自己在 dsh-hooks-claude-code/lib/index.js:292 有同样的用法。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { GuardState, SteerableAgent } from './types.js';
|
|
17
|
+
|
|
18
|
+
/** dsh 跑在 Node 上;只声明本插件用到的全局,避免为此引入 @types/node。 */
|
|
19
|
+
declare const crypto: { randomUUID(): string };
|
|
20
|
+
declare const console: { log(...args: unknown[]): void };
|
|
21
|
+
|
|
22
|
+
/** 续跑时推给模型的指令正文。 */
|
|
23
|
+
const RESUME_TEXT = [
|
|
24
|
+
'[复读拦截]',
|
|
25
|
+
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
26
|
+
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
27
|
+
].join('\n');
|
|
28
|
+
|
|
29
|
+
/** 折叠行显示的一行摘要;空字符串会让客户端回退成 opaque 渲染,故不可为空。 */
|
|
30
|
+
const RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 构造推给模型的一条输入,结构与 dsh 的 createUserMessage 对齐。
|
|
34
|
+
* @returns 一条插件来源的 user 消息,客户端会渲染成折叠的 context 行。
|
|
35
|
+
*/
|
|
36
|
+
function resumeMessage(): Record<string, unknown> {
|
|
37
|
+
return {
|
|
38
|
+
id: crypto.randomUUID(),
|
|
39
|
+
role: 'user',
|
|
40
|
+
content: [{ type: 'text', text: RESUME_TEXT }],
|
|
41
|
+
source: {
|
|
42
|
+
kind: 'plugin',
|
|
43
|
+
plugin: 'repeat-guard',
|
|
44
|
+
form: 'notice',
|
|
45
|
+
summary: RESUME_SUMMARY,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
52
|
+
* @param state - 跨监听保留的拦截状态。
|
|
53
|
+
* @param agent - 本轮所属的 agent 句柄。
|
|
54
|
+
*/
|
|
55
|
+
export function reviveTurn(state: GuardState, agent: SteerableAgent | undefined): void {
|
|
56
|
+
// 三个提前返回都要留痕:不打印就无法区分"没触发""id 对不上""没有标记"。
|
|
57
|
+
if (agent === undefined || typeof agent.steer !== 'function') {
|
|
58
|
+
console.log('[repeat-guard] turn-stopping:载荷里没有 agent 句柄,不续跑');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const sessionId = agent.session?.id;
|
|
62
|
+
if (sessionId === undefined) {
|
|
63
|
+
console.log('[repeat-guard] turn-stopping:agent 上没有会话 id,不续跑');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!state.pending.has(sessionId)) {
|
|
67
|
+
console.log(
|
|
68
|
+
`[repeat-guard] turn-stopping:会话 ${sessionId} 没有待续跑标记,不续跑` +
|
|
69
|
+
`(当前有标记的会话:${[...state.pending].join(',') || '无'})`,
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
state.pending.delete(sessionId);
|
|
74
|
+
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
|
|
75
|
+
agent.steer(resumeMessage());
|
|
76
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `llm/stream` 监听器:包一层上游流,思考段命中复读退化就补完流并提前结束。
|
|
3
|
+
*
|
|
4
|
+
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { findDegenerateLine } from './detect.js';
|
|
8
|
+
import type { GenerateOptions, GuardState, StreamChunk } from './types.js';
|
|
9
|
+
|
|
10
|
+
/** dsh 跑在 Node 上;只声明本插件用到的全局,避免为此引入 @types/node。 */
|
|
11
|
+
declare const console: { log(...args: unknown[]): void };
|
|
12
|
+
|
|
13
|
+
/** `llm/stream` 的监听器签名。 */
|
|
14
|
+
export type StreamListener = (
|
|
15
|
+
options: GenerateOptions,
|
|
16
|
+
next: () => AsyncIterable<StreamChunk>,
|
|
17
|
+
) => AsyncIterable<StreamChunk>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 逐 chunk 检查上游流的思考段,命中复读退化时补完这段流。
|
|
21
|
+
*
|
|
22
|
+
* 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
|
|
23
|
+
* 所以累积文本用一个字符串即可,不需要按 index 分开存。
|
|
24
|
+
*
|
|
25
|
+
* @param downstream - 上游模型的流。
|
|
26
|
+
* @param sessionId - 当前会话 id;有值才登记待续跑标记。
|
|
27
|
+
* @param state - 跨监听保留的拦截状态。
|
|
28
|
+
* @returns 包好的流。
|
|
29
|
+
*/
|
|
30
|
+
async function* guardStream(
|
|
31
|
+
downstream: AsyncIterable<StreamChunk>,
|
|
32
|
+
sessionId: string | undefined,
|
|
33
|
+
state: GuardState,
|
|
34
|
+
): AsyncGenerator<StreamChunk> {
|
|
35
|
+
const iterator = downstream[Symbol.asyncIterator]();
|
|
36
|
+
let accumulated = '';
|
|
37
|
+
try {
|
|
38
|
+
for (;;) {
|
|
39
|
+
const step = await iterator.next();
|
|
40
|
+
if (step.done) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const chunk = step.value;
|
|
44
|
+
if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') {
|
|
45
|
+
accumulated += chunk.text;
|
|
46
|
+
const hit = findDegenerateLine(accumulated);
|
|
47
|
+
if (hit !== null) {
|
|
48
|
+
if (sessionId !== undefined) {
|
|
49
|
+
state.pending.add(sessionId);
|
|
50
|
+
}
|
|
51
|
+
console.log(
|
|
52
|
+
`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
|
|
53
|
+
);
|
|
54
|
+
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
55
|
+
const index = chunk.index ?? 0;
|
|
56
|
+
yield chunk;
|
|
57
|
+
yield { type: 'block-end', index, block: { type: 'reasoning', text: accumulated } };
|
|
58
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
yield chunk;
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
// 提前 return 时必须显式向下游传播:不补这一步上游 HTTP 流会继续跑到结束,
|
|
66
|
+
// 供应商照常计满 token,且连接悬挂。上游可能已经结束/关闭,其异常忽略。
|
|
67
|
+
try {
|
|
68
|
+
await iterator.return?.();
|
|
69
|
+
} catch {
|
|
70
|
+
/* 上游已经结束或关闭,无需处理 */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 造一个 `llm/stream` 监听器。
|
|
77
|
+
* @param state - 跨监听保留的拦截状态。
|
|
78
|
+
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
79
|
+
*/
|
|
80
|
+
export function createStreamGuard(state: GuardState): StreamListener {
|
|
81
|
+
return (options, next) => {
|
|
82
|
+
// 辅助调用(上下文压缩、会话标题)不参与检测。
|
|
83
|
+
if (options.purpose !== undefined) {
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
return guardStream(next(), options.sessionId, state);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent/turn-stopping` 监听器:把上一步掐断欠下的续跑补上。
|
|
3
|
+
*
|
|
4
|
+
* 监听器的返回值不被使用(该事件是 serial 的副作用钩子),实现方式是调
|
|
5
|
+
* `agent.steer(...)`,见 resume.ts 的说明。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { reviveTurn } from './resume.js';
|
|
9
|
+
import type { GuardState, TurnStoppingPayload } from './types.js';
|
|
10
|
+
|
|
11
|
+
/** `agent/turn-stopping` 的监听器签名。 */
|
|
12
|
+
export type TurnStoppingListener = (payload: unknown) => void;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 造一个 `agent/turn-stopping` 监听器。
|
|
16
|
+
* @param state - 跨监听保留的拦截状态。
|
|
17
|
+
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
18
|
+
*/
|
|
19
|
+
export function createTurnStoppingGuard(state: GuardState): TurnStoppingListener {
|
|
20
|
+
return (rawPayload) => {
|
|
21
|
+
const payload = rawPayload as TurnStoppingPayload;
|
|
22
|
+
reviveTurn(state, payload?.agent);
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 与本插件交互的 dsh 接口声明。
|
|
3
|
+
*
|
|
4
|
+
* 这些类型一律在本插件内声明,不 import dsh 的类型:那样还要为 tsc 配 paths 指向
|
|
5
|
+
* dsh 安装目录,路径里带 node 版本号,dsh 一升级就失效。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 一个流式 chunk。本插件只读 reasoning-delta 的 index/text,其余字段原样透传。 */
|
|
9
|
+
export interface StreamChunk {
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly index?: number;
|
|
12
|
+
readonly text?: string;
|
|
13
|
+
readonly [key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 一次模型调用的参数;只声明本插件读到的两个字段。 */
|
|
17
|
+
export interface GenerateOptions {
|
|
18
|
+
readonly purpose?: string;
|
|
19
|
+
readonly sessionId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `agent/turn-stopping` 的载荷。
|
|
24
|
+
*
|
|
25
|
+
* dsh 的注释写明:本轮即将关闭(模型不再欠响应)时、在边界提交之前 await 这个事件;
|
|
26
|
+
* 监听器若不同意关闭,就调 `agent.steer(...)` 推入新的输入,机器会重读收件箱——
|
|
27
|
+
* 有新的 steering 就再跑一步,没有才真正关闭本轮。
|
|
28
|
+
*/
|
|
29
|
+
export interface TurnStoppingPayload {
|
|
30
|
+
readonly agent?: SteerableAgent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 能往本轮收件箱推输入、从而让本轮继续的 agent 句柄。 */
|
|
34
|
+
export interface SteerableAgent {
|
|
35
|
+
readonly session?: { readonly id?: string };
|
|
36
|
+
/** 推入 `next-step` 并唤醒驱动器:本轮再跑一步。 */
|
|
37
|
+
steer(input: unknown): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 注入给 dsh 的 cordis 上下文;只声明本插件用到的能力。 */
|
|
41
|
+
export interface PluginContext {
|
|
42
|
+
/**
|
|
43
|
+
* 监听器的形参随所监听的事件而异,只能声明成 any[]:换成 unknown[] 会因函数
|
|
44
|
+
* 参数逆变,导致各监听器的具体签名无法赋值。
|
|
45
|
+
*/
|
|
46
|
+
on(
|
|
47
|
+
name: string,
|
|
48
|
+
listener: (...args: any[]) => unknown,
|
|
49
|
+
options?: { global?: boolean },
|
|
50
|
+
): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 跨两次监听保留的拦截状态。 */
|
|
54
|
+
export interface GuardState {
|
|
55
|
+
/** 发生过截断、等待下一步续跑的会话 id。 */
|
|
56
|
+
readonly pending: Set<string>;
|
|
57
|
+
}
|