dsh-loop-rescue 0.0.0-stage → 0.2.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 +149 -2
- package/README.zh-CN.md +150 -0
- package/cordis.patch.yml +15 -0
- package/package.json +59 -4
- package/src/detector.js +255 -0
- package/src/i18n.js +64 -0
- package/src/index.js +259 -0
- package/tools/clean-session.cmd +4 -0
- package/tools/clean-session.mjs +293 -0
- package/tools/scan-session.mjs +95 -0
- package/tools/zstd-log.mjs +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 citydirector
|
|
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
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-loop-rescue
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[English](README.md) · [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
[](https://github.com/citydirector/dsh-loop-rescue/releases) [](LICENSE) [](https://github.com/topics/dsh-plugin)
|
|
6
|
+
|
|
7
|
+
A loop-rescue plugin for DSH (DeepSeek Harness). When the model falls into repetition (`Now. / OK. / Emit. / Producing.` rotations, the same paragraph over and over), it cuts the stream from the inside, **prunes the loop out of what gets written to the session log**, and resumes automatically.
|
|
8
|
+
|
|
9
|
+
The difference from a plain cut-off: the interrupted message that goes into the next turn's context **contains no loop**, so the model never re-reads its own repetition and carries on repeating.
|
|
10
|
+
|
|
11
|
+
Requires **DSH 0.2.2-alpha.2 or newer 0.2.x**. Pure JS, no build step.
|
|
12
|
+
|
|
13
|
+
## npm installation and updates
|
|
14
|
+
|
|
15
|
+
Requires DSH 0.2.2-alpha.2 or a newer 0.2.x and Node.js 24.9+. Enter `dsh-loop-rescue` in **Plugins → Add plugin**, or run:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
dsh plugin --profile web add dsh-loop-rescue
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For an existing GitHub installation, back up the profile's `cordis.patch.yml`, remove the old package in Plugins, and add the npm package above in the same profile. Keep the configuration row IDs and verify the saved settings after reinstalling. Subsequent updates use DSH's version list and update operation. Updates are handled entirely by DSH.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
Use this **or** `@mrweicodes/dsh-loop-guard`, not both: with both on, loops get cut twice.
|
|
26
|
+
|
|
27
|
+
1. **Install**: sidebar **Plugins** → Add plugin, and give it
|
|
28
|
+
```
|
|
29
|
+
dsh-loop-rescue
|
|
30
|
+
```
|
|
31
|
+
Switch it on.
|
|
32
|
+
2. The defaults work as they are. To watch before it acts, set `mode` to `observe` (see Configuration).
|
|
33
|
+
|
|
34
|
+
From the command line:
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
dsh plugin --profile web remove @mrweicodes/dsh-loop-guard
|
|
38
|
+
dsh plugin --profile web add dsh-loop-rescue
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Restart `dsh web` after a command-line install. No pnpm build scripts need approving.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
The defaults work as they are. To change them, write to the profile's `cordis.patch.yml`:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
- set:
|
|
49
|
+
- id: loop-rescue
|
|
50
|
+
config:
|
|
51
|
+
mode: rescue # rescue: cut + prune + resume | cut: cut + prune | observe: log only
|
|
52
|
+
watch: both # both | reasoning | text
|
|
53
|
+
marker: true # leave a "〔N chars of repeated reasoning removed〕" line where it pruned
|
|
54
|
+
maxConsecutiveRescues: 3 # cap on back-to-back rescues; one normal call resets it
|
|
55
|
+
stallCalls: 3 # nudge after this many reasoning-only calls with no output; 0 = off
|
|
56
|
+
stallMinReasoningChars: 1024
|
|
57
|
+
retryTemperature: 1.0 # temperature for the resume request; unset = unchanged
|
|
58
|
+
retryReasoningEffort: low # reasoning effort for the resume request; unset = unchanged
|
|
59
|
+
retryRequests: 1 # how many requests after a resume use the two settings above
|
|
60
|
+
language: auto # auto | zh | en
|
|
61
|
+
detector: # detection thresholds; rarely need changing
|
|
62
|
+
poolWindow: 30
|
|
63
|
+
poolMinCoverage: 0.75
|
|
64
|
+
cycleMinChars: 600
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Consider running `mode: observe` for a day or two first, check that every `loop-rescue: ... loop detected` line in the log is a real loop, then switch to `rescue`.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
When the agent loop assembles an `assistant/message`, each block's final content comes from the `block-end` payload, not from the deltas streamed before it. The plugin wraps the model call in the `llm/stream` waterfall:
|
|
72
|
+
|
|
73
|
+
1. It runs a streaming detector on every reasoning and text block.
|
|
74
|
+
2. On a hit it closes every open block: the looping block is replaced by its content **pruned back to where the loop started** (optionally with a one-line marker), and the stream ends with `stop`.
|
|
75
|
+
3. The message written to the log holds only the useful content before the loop. The raw stream is still kept in full in the event's `stream` record, where the trajectory view can show it.
|
|
76
|
+
4. Once the turn has really ended (`whenIdle()`), it sends a resume message with a correction via `followup()`.
|
|
77
|
+
5. The first N requests after a resume can use a different set of sampling parameters.
|
|
78
|
+
6. After the cap on back-to-back rescues it stops resuming and leaves a note.
|
|
79
|
+
|
|
80
|
+
Why not edit the session afterwards: an `assistant/message` cannot carry `sourceEventSeqs`, so the session surface's replace operation cannot swap one assistant message for another. Pruning at `block-end` is the only way that keeps message roles and tool-call pairing intact.
|
|
81
|
+
|
|
82
|
+
### Detection rules
|
|
83
|
+
|
|
84
|
+
| Rule | Catches | Prunes from |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| Phrase pool `pool` | ≥75% of the last 30 sentences are a handful of recurring short phrases (in any order) | Back to where that batch of phrases first appeared |
|
|
87
|
+
| Verbatim cycle `cycle` | The tail is the same text repeated verbatim, period 16–4000 chars, ≥3 repeats, ≥600 chars in total | Keeps the first copy, prunes from the second |
|
|
88
|
+
|
|
89
|
+
Guarding against false positives:
|
|
90
|
+
|
|
91
|
+
- A Chinese full stop always ends a sentence; an English period only when followed by whitespace.
|
|
92
|
+
- Symbol-only lines (`|---|`, `}`) are not counted.
|
|
93
|
+
- List items, table rows and quote lines in the text are not counted.
|
|
94
|
+
- The inside of ``` code blocks in the text is skipped entirely.
|
|
95
|
+
- The phrase pool needs at least two different phrases rotating.
|
|
96
|
+
- It never cuts while an open block holds a tool call: closing a half-written tool call would make the host run it with incomplete arguments.
|
|
97
|
+
|
|
98
|
+
## Offline scan
|
|
99
|
+
|
|
100
|
+
Replay past sessions through the same detector the plugin uses, to see where it would cut:
|
|
101
|
+
|
|
102
|
+
```powershell
|
|
103
|
+
node tools/scan-session.mjs <session file or directory> # hits only
|
|
104
|
+
node tools/scan-session.mjs <session file or directory> --all # hits and misses
|
|
105
|
+
node tools/scan-session.mjs <session file> --json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Reads plain jsonl and zstd-compressed session files (zstd needs Node 22.15+). The script only depends on `src/detector.js` and runs on plain node.
|
|
109
|
+
|
|
110
|
+
## Cleaning loops already in the history
|
|
111
|
+
|
|
112
|
+
The plugin only stops new loops; loops already committed to the history keep being imitated by the model. What DSH sends the model is a frozen projection of the session log, which a plugin cannot change, so the session files have to be cleaned offline **with DSH fully closed**. This works for old sessions that never had the plugin too.
|
|
113
|
+
|
|
114
|
+
The recommended tool is [dsh-session-manager](https://github.com/citydirector/dsh-session-manager) (`dsm loops`, a single-file exe on Windows): the same detection rules, plus upgrading, archiving and repairing broken sessions.
|
|
115
|
+
|
|
116
|
+
This repository also ships a lightweight script (Node 22.15+, v4 sessions only):
|
|
117
|
+
|
|
118
|
+
```powershell
|
|
119
|
+
node tools/clean-session.mjs # list sessions with loops, pick by number; Enter = newest
|
|
120
|
+
node tools/clean-session.mjs --dry-run # show what would change
|
|
121
|
+
node tools/clean-session.mjs --undo # restore from the backup
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- On Windows you can also double-click `tools\clean-session.cmd`, or drop a session folder onto it; if it cannot find the data directory, pass `--data <data directory>`.
|
|
125
|
+
- Only matching `assistant/message` events change: text and reasoning are cut back to where the loop started, `data.stream` is cut to match, and the message's `source.replayState` is removed. Every other line stays byte-for-byte the same.
|
|
126
|
+
- It backs up to `<data directory>\sessions-loop-rescue-backup\` first, and refuses while DSH is running (`--force` skips that check; not recommended).
|
|
127
|
+
- Versions before 2026-10-17 read only the first frame of a compressed log and wrote the result as a single frame, which DSH treats as corrupt and hides. That is fixed; `dsm repair` brings hidden sessions back.
|
|
128
|
+
|
|
129
|
+
## Known limitations
|
|
130
|
+
|
|
131
|
+
- Loop text already streamed into the chat is replaced by the pruned version once the message is committed, but exactly how it redraws depends on the DSH front-end version.
|
|
132
|
+
- Loops that span steps (every step is "say a line → call the same tool") are out of scope; that is the job of the built-in `guard/repeat-tool-reminder`.
|
|
133
|
+
- `retryTemperature` needs an adapter that supports temperature. DSH has no generic field for Ollama's repetition penalty and similar parameters, so they cannot be set from here.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install
|
|
139
|
+
npm test
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Related projects
|
|
143
|
+
|
|
144
|
+
- [dsh-remote-access-proxy](https://github.com/citydirector/dsh-remote-access-proxy): Use DSH from your phone, tablet, or another computer
|
|
145
|
+
- [deepinfra-fusion-proxy](https://github.com/citydirector/deepinfra-fusion-proxy): DeepInfra Standard / Flex switching with automatic fallback, per session
|
|
146
|
+
- [dsh-session-manager](https://github.com/citydirector/dsh-session-manager): Offline session manager: upgrade, archive, trash, loop cleanup, broken-tail repair
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
[MIT](LICENSE)
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# dsh-loop-rescue
|
|
2
|
+
|
|
3
|
+
[English](README.md) · [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
[](https://github.com/citydirector/dsh-loop-rescue/releases) [](LICENSE) [](https://github.com/topics/dsh-plugin)
|
|
6
|
+
|
|
7
|
+
DSH 循环救援插件:模型陷入重复(`Now. / OK. / Emit. / Producing.` 轮转、段落刷屏)时,从流内部切断,**把循环部分从落盘内容里剪掉**,再自动续跑。
|
|
8
|
+
|
|
9
|
+
和单纯截断相比,关键区别是:被切断的那条消息在下一轮上下文里**不含循环**,模型不会再读一遍自己的复读,然后接着复读。
|
|
10
|
+
|
|
11
|
+
需要 **DSH 0.2.2-alpha.2 或更新的 0.2.x**。纯 JS,没有构建步骤。
|
|
12
|
+
|
|
13
|
+
## npm 安装与更新
|
|
14
|
+
|
|
15
|
+
需要 DSH 0.2.2-alpha.2 或更新的 0.2.x,以及 Node.js 24.9+。在 DSH「插件 → 添加插件」输入 `dsh-loop-rescue`,或运行:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
dsh plugin --profile web add dsh-loop-rescue
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
旧版从 GitHub 安装的用户:先备份 profile 的 `cordis.patch.yml`,在插件页移除旧包并用上述 npm 包名重新添加,保持原 profile 与配置行 ID。配置仍由 profile 保存;重新添加后检查已保存的值。此后统一使用 DSH 插件页的版本列表与更新功能。更新完全由 DSH 管理。
|
|
22
|
+
|
|
23
|
+
## 快速开始
|
|
24
|
+
|
|
25
|
+
和 `@mrweicodes/dsh-loop-guard` **二选一**,两者同时开会重复切断。
|
|
26
|
+
|
|
27
|
+
1. **安装**:侧边栏 **插件** → 添加插件,填入
|
|
28
|
+
```
|
|
29
|
+
dsh-loop-rescue
|
|
30
|
+
```
|
|
31
|
+
装完打开开关。
|
|
32
|
+
2. 默认配置即可使用。想先观察,把 `mode` 设成 `observe`(见「配置」)。
|
|
33
|
+
|
|
34
|
+
命令行安装:
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
dsh plugin --profile web remove @mrweicodes/dsh-loop-guard
|
|
38
|
+
dsh plugin --profile web add dsh-loop-rescue
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
命令行装完重启 `dsh web`。不需要放行 pnpm 构建脚本。
|
|
42
|
+
|
|
43
|
+
## 配置
|
|
44
|
+
|
|
45
|
+
默认配置即可使用。需要调整时写进 profile 的 `cordis.patch.yml`:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
- set:
|
|
49
|
+
- id: loop-rescue
|
|
50
|
+
config:
|
|
51
|
+
mode: rescue # rescue:切+剪+续跑 | cut:切+剪 | observe:只记日志
|
|
52
|
+
watch: both # both | reasoning | text
|
|
53
|
+
marker: true # 剪除处留「〔已删除 N 字重复思考〕」
|
|
54
|
+
maxConsecutiveRescues: 3 # 连续救援上限,出现一次正常调用就清零
|
|
55
|
+
stallCalls: 3 # 连续几次只思考无产出就提示继续;0 关闭
|
|
56
|
+
stallMinReasoningChars: 1024
|
|
57
|
+
retryTemperature: 1.0 # 续跑请求改用的温度;不填不改
|
|
58
|
+
retryReasoningEffort: low # 续跑请求改用的推理强度;不填不改
|
|
59
|
+
retryRequests: 1 # 续跑后几次请求使用上面两项
|
|
60
|
+
language: auto # auto | zh | en
|
|
61
|
+
detector: # 检测阈值,一般不用动
|
|
62
|
+
poolWindow: 30
|
|
63
|
+
poolMinCoverage: 0.75
|
|
64
|
+
cycleMinChars: 600
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
建议先用 `mode: observe` 跑一两天,看日志里的 `loop-rescue: ... loop detected` 是不是都是真循环,再切到 `rescue`。
|
|
68
|
+
|
|
69
|
+
## 工作原理
|
|
70
|
+
|
|
71
|
+
agent-loop 组装 `assistant/message` 时,每个 block 的最终内容取自 `block-end` 的载荷,而不是之前流出的 delta。插件在 `llm/stream` 瀑布里包装模型调用:
|
|
72
|
+
|
|
73
|
+
1. 对每个推理/正文 block 跑流式检测器;
|
|
74
|
+
2. 命中后闭合所有打开的 block:循环所在的 block 换成**剪到循环起点**的内容(可留一行标记),以 `stop` 结束流;
|
|
75
|
+
3. 落盘的消息里只剩循环之前的有效内容。原始流仍完整保存在事件的 `stream` 记录里,轨迹视图可以查看;
|
|
76
|
+
4. 等回合真正结束(`whenIdle()`)后,用 `followup()` 发一条带纠正语的续跑消息;
|
|
77
|
+
5. 续跑的前 N 次请求可选地改用另一组采样参数;
|
|
78
|
+
6. 连续救援达到上限就不再续跑,留一条提示。
|
|
79
|
+
|
|
80
|
+
为什么不事后改会话:`assistant/message` 不能携带 `sourceEventSeqs`,会话面的 replace 操作没法用 assistant 消息替换 assistant 消息。在 `block-end` 处剪,是唯一不破坏消息角色和工具调用配对的做法。
|
|
81
|
+
|
|
82
|
+
### 检测规则
|
|
83
|
+
|
|
84
|
+
| 规则 | 抓什么 | 剪在哪 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| 短句池 `pool` | 最近 30 句里 ≥75% 是几种反复出现的短句(顺序可以变) | 回溯到这批短句第一次出现的位置 |
|
|
87
|
+
| 逐字周期 `cycle` | 尾部是同一段文字的逐字重复,周期 16–4000 字符,重复 ≥3 遍,总长 ≥600 | 保留第一份,从第二份开始剪 |
|
|
88
|
+
|
|
89
|
+
防误伤:
|
|
90
|
+
|
|
91
|
+
- 中文句号直接断句,英文句号后面要跟空白才断;
|
|
92
|
+
- 纯符号行(`|---|`、`}`)不计入统计;
|
|
93
|
+
- 正文中的列表项、表格行、引用行不计入统计;
|
|
94
|
+
- 正文中的 ``` 代码块内部完全跳过;
|
|
95
|
+
- 短句池至少要有两种句子在轮转;
|
|
96
|
+
- 打开的 block 里有工具调用时不切,因为闭合半截工具调用会让宿主执行参数不完整的调用。
|
|
97
|
+
|
|
98
|
+
## 离线扫描
|
|
99
|
+
|
|
100
|
+
用插件同一份检测器复跑历史会话,看会在哪里剪:
|
|
101
|
+
|
|
102
|
+
```powershell
|
|
103
|
+
node tools/scan-session.mjs <会话文件或目录> # 只列命中
|
|
104
|
+
node tools/scan-session.mjs <会话文件或目录> --all # 连未命中一起列
|
|
105
|
+
node tools/scan-session.mjs <会话文件> --json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
支持纯 jsonl 和 zstd 压缩的会话文件(zstd 需要 Node 22.15+)。脚本只依赖 `src/detector.js`,普通 node 就能运行。
|
|
109
|
+
|
|
110
|
+
## 清洗历史里已经提交的循环
|
|
111
|
+
|
|
112
|
+
插件只能拦住新的循环;历史里已经提交的循环会被模型继续模仿。DSH 发给模型的请求是会话日志的冻结投影,插件改不了,只能在 **DSH 完全退出后** 离线清洗会话文件。没装过插件的老会话同样适用。
|
|
113
|
+
|
|
114
|
+
推荐用 [dsh-session-manager](https://github.com/citydirector/dsh-session-manager)(`dsm loops`,Windows 有单文件 exe),检测规则和本插件同一套,另外还能升级、归档、修复烂尾会话。
|
|
115
|
+
|
|
116
|
+
本仓库也带一个轻量脚本(需要 Node 22.15+,只支持 v4 会话):
|
|
117
|
+
|
|
118
|
+
```powershell
|
|
119
|
+
node tools/clean-session.mjs # 列出有循环的会话,按序号选,回车=最新
|
|
120
|
+
node tools/clean-session.mjs --dry-run # 只看会改哪里
|
|
121
|
+
node tools/clean-session.mjs --undo # 从备份恢复
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- Windows 下也可以双击 `tools\clean-session.cmd`,或把会话文件夹拖到它上面;找不到数据目录时用 `--data <数据目录>` 指定。
|
|
125
|
+
- 只改命中的 `assistant/message`:正文 / 推理截到循环起点,`data.stream` 同步截断,移除该消息的 `source.replayState`。其余行逐字节不变。
|
|
126
|
+
- 改之前备份到 `<数据目录>\sessions-loop-rescue-backup\`;DSH 在运行时拒绝执行(`--force` 可跳过,不推荐)。
|
|
127
|
+
- 2026-10-17 之前的版本只读得到压缩日志的第一帧,并会把结果写成单帧(DSH 会把这种会话当损坏隐藏)。已修复;被隐藏的会话可以用 `dsm repair` 找回。
|
|
128
|
+
|
|
129
|
+
## 已知限制
|
|
130
|
+
|
|
131
|
+
- 聊天区已经流出的循环文字在消息提交后会被替换成剪过的版本,但具体怎么重绘取决于 DSH 前端版本;
|
|
132
|
+
- 跨步骤的循环(每一步都是「说一句 → 调同一个工具」)不在本插件范围内,那是内置 `guard/repeat-tool-reminder` 的职责;
|
|
133
|
+
- `retryTemperature` 需要适配器支持 temperature;Ollama 的重复惩罚等参数 DSH 目前没有通用字段,无法从这里设置。
|
|
134
|
+
|
|
135
|
+
## 开发与测试
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install
|
|
139
|
+
npm test
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## 相关项目
|
|
143
|
+
|
|
144
|
+
- [dsh-remote-access-proxy](https://github.com/citydirector/dsh-remote-access-proxy):在手机、平板或另一台电脑上使用 DSH
|
|
145
|
+
- [deepinfra-fusion-proxy](https://github.com/citydirector/deepinfra-fusion-proxy):DeepInfra Standard / Flex 按会话切换与自动回退
|
|
146
|
+
- [dsh-session-manager](https://github.com/citydirector/dsh-session-manager):离线会话管理器,可升级、归档、清洗循环、修复烂尾会话
|
|
147
|
+
|
|
148
|
+
## 许可证
|
|
149
|
+
|
|
150
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# dsh-loop-rescue 的 bundle 补丁:默认配置即可挂载。
|
|
2
|
+
# 与 @mrweicodes/dsh-loop-guard 二选一:两者都包装 llm/stream,同时开会重复切断。
|
|
3
|
+
#
|
|
4
|
+
# 按需在 profile 里覆盖,例如:
|
|
5
|
+
#
|
|
6
|
+
# - set:
|
|
7
|
+
# - id: loop-rescue
|
|
8
|
+
# config:
|
|
9
|
+
# mode: rescue # rescue | cut | observe
|
|
10
|
+
# retryTemperature: 1.0 # 续跑那一次请求换个温度
|
|
11
|
+
# maxConsecutiveRescues: 3
|
|
12
|
+
|
|
13
|
+
- insert:
|
|
14
|
+
- id: loop-rescue
|
|
15
|
+
name: 'dsh-loop-rescue'
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-loop-rescue",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH plugin that cuts model repetition loops mid-stream, prunes the loop out of the committed message, and resumes automatically.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./detector": "./src/detector.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"tools",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.zh-CN.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.spec.mjs",
|
|
22
|
+
"scan": "node tools/scan-session.mjs"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"dsh",
|
|
26
|
+
"dsh-plugin",
|
|
27
|
+
"deepseek-harness",
|
|
28
|
+
"loop",
|
|
29
|
+
"repetition",
|
|
30
|
+
"guard"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/citydirector/dsh-loop-rescue.git"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@deepseek-ai/cordis": "^4.0.6-alpha.1",
|
|
44
|
+
"@deepseek-ai/dsh-llm": ">=0.2.2-alpha.2 <0.3.0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@deepseek-ai/cordis": "4.0.6-alpha.1",
|
|
51
|
+
"@deepseek-ai/dsh-llm": "0.2.2-alpha.2"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=24.9.0",
|
|
55
|
+
"dsh": ">=0.2.2-alpha.2 <0.3.0"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public",
|
|
59
|
+
"registry": "https://registry.npmjs.org/"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/detector.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯函数检测器:不依赖任何 @deepseek-ai/* 包,插件和离线扫描脚本共用同一份判定。
|
|
3
|
+
*
|
|
4
|
+
* 两条规则,都在流内逐段运行,并且都能给出「循环从哪里开始」的精确位置,
|
|
5
|
+
* 这是本插件能把循环从落盘内容里剪掉的前提:
|
|
6
|
+
*
|
|
7
|
+
* - **短句池轮转(pool)**:最近 N 个句子几乎全由少数几个反复出现的短句组成,
|
|
8
|
+
* 例如 `Now. / OK. / Enough. / Let me write. / Emit. / Producing.`。
|
|
9
|
+
* 这是 DeepSeek 退化时最常见的形态,逐字周期检测抓不到,因为轮转顺序会变。
|
|
10
|
+
* - **逐字周期(cycle)**:尾部是一段文字的逐字重复(段落、长句、代码块被原样
|
|
11
|
+
* 刷屏)。用尾部指纹的历史出现位置当候选周期,代价与流长度无关。
|
|
12
|
+
*
|
|
13
|
+
* 句子切分:句末标点(. ! ? 。!?…)后接空白,或换行。纯符号句子(表格分隔线、
|
|
14
|
+
* `}`、`---`)不计入统计,也不打断循环段,避免把表格、日志、代码误判成循环。
|
|
15
|
+
* 正文模式下 ``` 代码块内部完全跳过。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-loop-rescue/detector
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 默认阈值。按「宁可晚切一点,也不误伤正常输出」标定。 */
|
|
21
|
+
export const DEFAULTS = Object.freeze({
|
|
22
|
+
/** 短句池:参与判定的最近句子数。 */
|
|
23
|
+
poolWindow: 30,
|
|
24
|
+
/** 短句池:窗口内句子的平均长度上限(字符)。 */
|
|
25
|
+
poolMaxMeanChars: 60,
|
|
26
|
+
/** 短句池:窗口内总长度下限(汉字按 3 计),太短不判。 */
|
|
27
|
+
poolMinChars: 120,
|
|
28
|
+
/** 短句池:一个句子在窗口内出现几次才算「反复出现」。 */
|
|
29
|
+
poolMinRepeats: 3,
|
|
30
|
+
/** 短句池:反复出现的句子占窗口的比例下限。 */
|
|
31
|
+
poolMinCoverage: 0.75,
|
|
32
|
+
/** 回溯循环起点时,允许夹杂的连续新句子数。 */
|
|
33
|
+
poolGapAllowance: 2,
|
|
34
|
+
/** 逐字周期:最短周期(字符)。 */
|
|
35
|
+
cycleMinPeriod: 16,
|
|
36
|
+
/** 逐字周期:最长周期(字符)。 */
|
|
37
|
+
cycleMaxPeriod: 4000,
|
|
38
|
+
/** 逐字周期:重复部分总长下限(字符)。 */
|
|
39
|
+
cycleMinChars: 600,
|
|
40
|
+
/** 逐字周期:至少重复几遍。 */
|
|
41
|
+
cycleMinReps: 3,
|
|
42
|
+
/** 逐字周期:每新增多少字符检查一次。 */
|
|
43
|
+
cycleCheckEvery: 128,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const ASCII_TERMINATOR = /[.!?]/
|
|
47
|
+
const CJK_TERMINATOR = /[。!?…]/
|
|
48
|
+
const HAS_WORD = /[\p{L}\p{N}]/u
|
|
49
|
+
/** 正文里的列表项、表格行、引用行:结构化内容,天然重复,不计入短句池。 */
|
|
50
|
+
const STRUCTURED_LINE = /^(?:[-*+|>]|\d+[.)])\s?/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把句子规范成比较用的键:小写、合并空白、去掉首尾标点。
|
|
54
|
+
* 数字保留原样:编号不同的行(表格、清单)本来就不是同一句话。
|
|
55
|
+
* @param {string} s
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeUnit(s) {
|
|
59
|
+
return s
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/\s+/g, ' ')
|
|
62
|
+
.replace(/^[\s\p{P}\p{S}]+|[\s\p{P}\p{S}]+$/gu, '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 长度估算:CJK 字符信息密度高,按 3 个字符计,使中英文阈值大致可比。
|
|
67
|
+
* @param {string} s
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
export function weightedLength(s) {
|
|
71
|
+
let n = 0
|
|
72
|
+
for (const ch of s) n += /[\u3400-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/.test(ch) ? 3 : 1
|
|
73
|
+
return n
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {object} Detection
|
|
78
|
+
* @property {'pool' | 'cycle'} rule 命中的规则
|
|
79
|
+
* @property {number} cut 循环起点(在累计文本里的 UTF-16 下标),保留 `text.slice(0, cut)`
|
|
80
|
+
* @property {number} repeatedChars 被判为循环的字符数(`text.length - cut`)
|
|
81
|
+
* @property {number} [period] 逐字周期的周期长度
|
|
82
|
+
* @property {string[]} [pool] 短句池里的句子(规范化后)
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 单个 block(一段推理或一段正文)的流式循环检测器。
|
|
87
|
+
*/
|
|
88
|
+
export class StreamLoopDetector {
|
|
89
|
+
/**
|
|
90
|
+
* @param {'reasoning' | 'text'} kind 推理还是正文;正文会跳过代码块
|
|
91
|
+
* @param {Partial<typeof DEFAULTS>} [options]
|
|
92
|
+
*/
|
|
93
|
+
constructor(kind, options = {}) {
|
|
94
|
+
this.kind = kind
|
|
95
|
+
this.o = { ...DEFAULTS, ...options }
|
|
96
|
+
/** 累计文本。 */
|
|
97
|
+
this.text = ''
|
|
98
|
+
/** @type {{start: number, end: number, key: string}[]} 已切出的计数句子 */
|
|
99
|
+
this.units = []
|
|
100
|
+
this.unitStart = 0
|
|
101
|
+
this.inFence = false
|
|
102
|
+
this.lastCycleCheck = 0
|
|
103
|
+
/** @type {Detection | undefined} */
|
|
104
|
+
this.detection = undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 追加一段增量。返回第一次命中的检测结果;命中后不再变化。
|
|
109
|
+
* @param {string} delta
|
|
110
|
+
* @returns {Detection | undefined}
|
|
111
|
+
*/
|
|
112
|
+
push(delta) {
|
|
113
|
+
if (this.detection !== undefined || delta.length === 0) return this.detection
|
|
114
|
+
const from = this.text.length
|
|
115
|
+
this.text += delta
|
|
116
|
+
for (let i = from; i < this.text.length; i++) {
|
|
117
|
+
const ch = this.text[i]
|
|
118
|
+
const next = this.text[i + 1]
|
|
119
|
+
const atLineEnd = ch === '\n'
|
|
120
|
+
// 中文句末标点直接切;英文标点后面跟空白才切(避免切开 1.5、a.b),
|
|
121
|
+
// 流尾的英文标点等下一段增量再说。
|
|
122
|
+
const atSentenceEnd = CJK_TERMINATOR.test(ch)
|
|
123
|
+
|| (ASCII_TERMINATOR.test(ch) && next !== undefined && /\s/.test(next))
|
|
124
|
+
if (atLineEnd || atSentenceEnd) {
|
|
125
|
+
this.closeUnit(i + 1)
|
|
126
|
+
if (this.detection !== undefined) return this.detection
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (this.text.length - this.lastCycleCheck >= this.o.cycleCheckEvery) {
|
|
130
|
+
this.lastCycleCheck = this.text.length
|
|
131
|
+
const hit = this.checkCycle()
|
|
132
|
+
if (hit !== undefined) this.detection = hit
|
|
133
|
+
}
|
|
134
|
+
return this.detection
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {number} end */
|
|
138
|
+
closeUnit(end) {
|
|
139
|
+
const raw = this.text.slice(this.unitStart, end)
|
|
140
|
+
const start = this.unitStart
|
|
141
|
+
this.unitStart = end
|
|
142
|
+
const trimmed = raw.trim()
|
|
143
|
+
if (this.kind === 'text' && trimmed.startsWith('```')) {
|
|
144
|
+
this.inFence = !this.inFence
|
|
145
|
+
this.units = []
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (this.inFence) return
|
|
149
|
+
if (!HAS_WORD.test(trimmed)) return // 纯符号:中性,不计也不断
|
|
150
|
+
if (this.kind === 'text' && STRUCTURED_LINE.test(trimmed)) return // 列表/表格:中性
|
|
151
|
+
this.units.push({ start: start + raw.indexOf(trimmed), end, key: normalizeUnit(trimmed) })
|
|
152
|
+
if (this.units.length > this.o.poolWindow * 4) this.units.splice(0, this.units.length - this.o.poolWindow * 4)
|
|
153
|
+
const hit = this.checkPool()
|
|
154
|
+
if (hit !== undefined) this.detection = hit
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @returns {Detection | undefined} */
|
|
158
|
+
checkPool() {
|
|
159
|
+
const o = this.o
|
|
160
|
+
if (this.units.length < o.poolWindow) return undefined
|
|
161
|
+
const win = this.units.slice(-o.poolWindow)
|
|
162
|
+
let chars = 0
|
|
163
|
+
const counts = new Map()
|
|
164
|
+
for (const u of win) {
|
|
165
|
+
chars += weightedLength(u.key)
|
|
166
|
+
counts.set(u.key, (counts.get(u.key) ?? 0) + 1)
|
|
167
|
+
}
|
|
168
|
+
if (chars < o.poolMinChars) return undefined
|
|
169
|
+
if (chars / win.length > o.poolMaxMeanChars) return undefined
|
|
170
|
+
const pool = new Set([...counts].filter(([, n]) => n >= o.poolMinRepeats).map(([k]) => k))
|
|
171
|
+
// 只有一个句子在刷屏属于逐字周期的范畴,这里要求至少两种句子在轮转。
|
|
172
|
+
if (pool.size < 2) return undefined
|
|
173
|
+
let covered = 0
|
|
174
|
+
for (const u of win) if (pool.has(u.key)) covered++
|
|
175
|
+
if (covered / win.length < o.poolMinCoverage) return undefined
|
|
176
|
+
|
|
177
|
+
// 回溯:从尾部往前走,经过池内句子,允许夹杂少量新句子。
|
|
178
|
+
let first = this.units.length - 1
|
|
179
|
+
let gap = 0
|
|
180
|
+
for (let i = this.units.length - 1; i >= 0; i--) {
|
|
181
|
+
if (pool.has(this.units[i].key)) {
|
|
182
|
+
first = i
|
|
183
|
+
gap = 0
|
|
184
|
+
} else if (++gap > o.poolGapAllowance) break
|
|
185
|
+
}
|
|
186
|
+
const cut = this.units[first].start
|
|
187
|
+
return { rule: 'pool', cut, repeatedChars: this.text.length - cut, pool: [...pool] }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @returns {Detection | undefined} */
|
|
191
|
+
checkCycle() {
|
|
192
|
+
const o = this.o
|
|
193
|
+
const s = this.text
|
|
194
|
+
const n = s.length
|
|
195
|
+
if (n < o.cycleMinChars) return undefined
|
|
196
|
+
// 正文里代码块内部的重复(生成数据、样例)不归本规则管。
|
|
197
|
+
if (this.kind === 'text' && this.inFence) return undefined
|
|
198
|
+
const probe = Math.min(32, o.cycleMinPeriod)
|
|
199
|
+
const tail = s.slice(n - probe)
|
|
200
|
+
const windowStart = Math.max(0, n - o.cycleMaxPeriod - probe)
|
|
201
|
+
// 尾部指纹在之前出现的位置 → 候选周期。取最近的几个。
|
|
202
|
+
const candidates = []
|
|
203
|
+
let at = n - probe - 1
|
|
204
|
+
while (candidates.length < 6) {
|
|
205
|
+
const idx = s.lastIndexOf(tail, at)
|
|
206
|
+
if (idx < windowStart || idx < 0) break
|
|
207
|
+
const period = n - probe - idx
|
|
208
|
+
if (period >= o.cycleMinPeriod) candidates.push(period)
|
|
209
|
+
at = idx - 1
|
|
210
|
+
}
|
|
211
|
+
for (const p of candidates) {
|
|
212
|
+
// 从尾部往前数,s[i] === s[i + p] 连续成立的长度。
|
|
213
|
+
let i = n - 1 - p
|
|
214
|
+
while (i >= 0 && s[i] === s[i + p]) i--
|
|
215
|
+
const runStart = i + 1 // [runStart, n) 以周期 p 自重复
|
|
216
|
+
const span = n - runStart
|
|
217
|
+
const reps = span / p + 1
|
|
218
|
+
if (span + p < o.cycleMinChars || reps < o.cycleMinReps) continue
|
|
219
|
+
// 第一份拷贝本身可能是正常内容,保留它,从第二份起剪。
|
|
220
|
+
// 周期回溯会多吃掉前文里恰好相同的几个字符(如 ".\n"),把切点吸附到下一个换行之后。
|
|
221
|
+
let cut = Math.min(n, runStart + p)
|
|
222
|
+
const nl = s.indexOf('\n', cut - 1)
|
|
223
|
+
if (nl !== -1 && nl - cut < Math.min(p, 120)) cut = nl + 1
|
|
224
|
+
return { rule: 'cycle', cut, repeatedChars: n - cut, period: p }
|
|
225
|
+
}
|
|
226
|
+
return undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 流结束后的最终检查(尾部不足 cycleCheckEvery 的部分也查一遍,并把未闭合的最后一句计入)。
|
|
231
|
+
* @returns {Detection | undefined}
|
|
232
|
+
*/
|
|
233
|
+
finish() {
|
|
234
|
+
if (this.detection !== undefined) return this.detection
|
|
235
|
+
if (this.unitStart < this.text.length) this.closeUnit(this.text.length)
|
|
236
|
+
if (this.detection === undefined) this.detection = this.checkCycle()
|
|
237
|
+
return this.detection
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 对一段完整文本跑一遍检测(离线扫描与测试用)。
|
|
243
|
+
* @param {'reasoning' | 'text'} kind
|
|
244
|
+
* @param {string} text
|
|
245
|
+
* @param {Partial<typeof DEFAULTS>} [options]
|
|
246
|
+
* @param {number} [chunk] 模拟流的切片大小
|
|
247
|
+
* @returns {Detection | undefined}
|
|
248
|
+
*/
|
|
249
|
+
export function detectInText(kind, text, options, chunk = 24) {
|
|
250
|
+
const d = new StreamLoopDetector(kind, options)
|
|
251
|
+
for (let i = 0; i < text.length; i += chunk) {
|
|
252
|
+
if (d.push(text.slice(i, i + chunk)) !== undefined) return d.detection
|
|
253
|
+
}
|
|
254
|
+
return d.finish()
|
|
255
|
+
}
|