dsh-repeat-guard 0.1.0 → 0.1.1
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/README.md +10 -10
- package/lib/index.d.ts +2 -2
- package/lib/index.js +8 -11
- package/lib/resume.d.ts +4 -3
- package/lib/resume.js +16 -31
- package/lib/stream-guard.d.ts +4 -2
- package/lib/stream-guard.js +2 -3
- package/lib/turn-stopping-guard.d.ts +4 -3
- package/lib/turn-stopping-guard.js +2 -3
- package/lib/types.d.ts +4 -43
- package/lib/types.js +4 -3
- package/package.json +10 -1
- package/src/index.ts +11 -16
- package/src/resume.ts +21 -40
- package/src/stream-guard.ts +6 -9
- package/src/turn-stopping-guard.ts +5 -8
- package/src/types.ts +4 -48
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
## 工作原理
|
|
58
58
|
|
|
59
59
|
1. 包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察 `reasoning-delta`,累积本次响应已产出的思考文本。
|
|
60
|
-
2. 命中退化时,把触发退化的那个 delta 照常放行,补一个 `block-end` 闭合思考块,再补一个 `finish(stop)
|
|
60
|
+
2. 命中退化时,把触发退化的那个 delta 照常放行,补一个 `block-end` 闭合思考块,再补一个 `finish(stop)`,然后结束流。对 agent-loop 而言这就是一次正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
61
61
|
3. 提前结束流时显式向下游传播 `iterator.return()`,否则上游 HTTP 流会继续跑到结束,供应商照常计满 token,且连接悬挂。
|
|
62
62
|
4. 光截断只会让本轮就此结束、停下来等用户输入。所以再挂一个 `agent/turn-stopping` 监听器,在本轮边界提交之前调 `agent.steer(...)` 推一条输入,本轮就会接着再跑一步。
|
|
63
63
|
|
|
@@ -147,16 +147,16 @@ journalctl --user -u dsh-web --no-pager | grep -a repeat-guard
|
|
|
147
147
|
|
|
148
148
|
⚠️ 插件加载失败会让 **dsh 启动直接失败**,而 dsh-web 是本机 GUI 的唯一通道。改完先离线验证再重启:用假上下文 import 产物调一次入口,或喂一段真实 chunk 序列。
|
|
149
149
|
|
|
150
|
-
##
|
|
150
|
+
## 依赖
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
dsh 会把 bundle 声明的 `dependencies` 与 `peerDependencies` 从安装目录软链进 profile(`dsh-app-boot` 的 `healProfileModuleFallback`,依赖名取自 `profileDependencyNames(manifest)`,注释原文是 "dependency names that may be imported by a loader-visible plugin")。软链落在 `~/.dsh/profiles/node_modules/`,Node 按常规向上查找即可解析到。
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
所以:
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
-
|
|
156
|
+
- **宿主提供的包写 `peerDependencies`**:`@deepseek-ai/cordis`、`@deepseek-ai/dsh-llm`、`@deepseek-ai/dsh-agent`。它们不在 profile 里重复安装,用 dsh 自带的那份。
|
|
157
|
+
- **类型一律从官方引**,不在本地重抄。`Context`、`StreamChunk`、`GenerateOptions`、`Agent`、`UserMessage` 都是 dsh 导出的;本地抄一份只会在 dsh 升级后于运行时暴露字段对不上,官方声明则会在 `tsc` 阶段直接报错。
|
|
158
|
+
- `src/types.ts` 只放本插件自有的类型(当前是 `GuardState`)。
|
|
159
|
+
- 仍然只用 TypeScript 写源码,由 `tsc` 产出 `lib/` 供 dsh 加载。dsh 的 loader 是原生 ESM import,**没有转译层**,运行时读到的永远是编译产物——改完 `src/` 必须重新构建。`lib/` 只随 npm 包发布,不进版本库。
|
|
160
160
|
|
|
161
161
|
## 代码约定
|
|
162
162
|
|
|
@@ -177,8 +177,8 @@ journalctl --user -u dsh-web --no-pager | grep -a repeat-guard
|
|
|
177
177
|
.
|
|
178
178
|
├── src/
|
|
179
179
|
│ ├── index.ts 插件入口:装配状态,注册两个监听器(只做装配)
|
|
180
|
-
│ ├── types.ts
|
|
181
|
-
│ ├── detect.ts
|
|
180
|
+
│ ├── types.ts 本插件自有的类型(dsh 的接口一律从 @deepseek-ai/* 引)
|
|
181
|
+
│ ├── detect.ts 复读判定:FRAGMENTS 表 + 查表纯函数,不碰会话状态
|
|
182
182
|
│ ├── resume.ts 续跑:注入文案、消息构造、推送
|
|
183
183
|
│ ├── stream-guard.ts llm/stream 监听器:思考段检测与掐断
|
|
184
184
|
│ └── turn-stopping-guard.ts agent/turn-stopping 监听器:让本轮继续
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
/**
|
|
3
3
|
* 函数式插件入口。ctx 为 cordis 上下文,注册的监听随插件卸载自动释放。
|
|
4
4
|
* @param ctx - 宿主 cordis 上下文。
|
|
5
5
|
*/
|
|
6
|
-
export default function repeatGuard(ctx:
|
|
6
|
+
export default function repeatGuard(ctx: Context): void;
|
package/lib/index.js
CHANGED
|
@@ -2,25 +2,22 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
|
|
4
4
|
// (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
|
|
5
|
-
// 闭合思考块,再补一个 finish(stop)
|
|
6
|
-
//
|
|
7
|
-
// 于是本次调用被当成正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
5
|
+
// 闭合思考块,再补一个 finish(stop),然后直接结束流。对 agent-loop 而言这就是一次正常
|
|
6
|
+
// 完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
8
7
|
//
|
|
9
8
|
// 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
|
|
10
9
|
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
11
10
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
|
|
12
|
+
// 表在 detect.ts 的 FRAGMENTS 里。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
|
|
13
|
+
// 具体、可增删、可审计的。
|
|
15
14
|
//
|
|
16
15
|
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
17
16
|
// 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
|
|
18
17
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// **相对路径 import 不受此限**:它由 Node 原生 ESM 按当前文件位置解析,所以本插件内部
|
|
23
|
-
// 按职责拆出的这些模块可以正常互相导入。新增模块时照此办理:只连相对路径。
|
|
18
|
+
// 依赖:dsh 把 bundle 的 dependencies / peerDependencies 从安装目录软链进 profile
|
|
19
|
+
// (dsh-app-boot 的 healProfileModuleFallback),所以本插件可以正常 import
|
|
20
|
+
// `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
|
|
24
21
|
//
|
|
25
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
26
23
|
import { createStreamGuard } from './stream-guard.js';
|
package/lib/resume.d.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
11
|
*
|
|
12
12
|
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
-
* 用户输入。dsh
|
|
13
|
+
* 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
|
|
14
14
|
*/
|
|
15
|
-
import type {
|
|
15
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
16
|
+
import type { GuardState } from './types.js';
|
|
16
17
|
/**
|
|
17
18
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
18
19
|
* @param state - 跨监听保留的拦截状态。
|
|
19
20
|
* @param agent - 本轮所属的 agent 句柄。
|
|
20
21
|
*/
|
|
21
|
-
export declare function reviveTurn(state: GuardState, agent:
|
|
22
|
+
export declare function reviveTurn(state: GuardState, agent: Agent): void;
|
package/lib/resume.js
CHANGED
|
@@ -10,55 +10,40 @@
|
|
|
10
10
|
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
11
|
*
|
|
12
12
|
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
-
* 用户输入。dsh
|
|
13
|
+
* 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
|
|
14
14
|
*/
|
|
15
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
15
16
|
/** 续跑时推给模型的指令正文。 */
|
|
16
17
|
const RESUME_TEXT = [
|
|
17
18
|
'[复读拦截]',
|
|
18
19
|
'你上一段思考退化成了碎片复读(反复输出"好。"一类的空话),已被系统截断。',
|
|
19
20
|
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
20
21
|
].join('\n');
|
|
21
|
-
/** 折叠行显示的一行摘要;空字符串会让客户端回退成 opaque 渲染,故不可为空。 */
|
|
22
|
-
const RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* 这条注入消息的来源标记。
|
|
24
|
+
* `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
25
|
+
* 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
26
26
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
kind: 'plugin',
|
|
34
|
-
plugin: 'repeat-guard',
|
|
35
|
-
form: 'notice',
|
|
36
|
-
summary: RESUME_SUMMARY,
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
}
|
|
27
|
+
const PLUGIN_SOURCE = {
|
|
28
|
+
kind: 'plugin',
|
|
29
|
+
plugin: 'repeat-guard',
|
|
30
|
+
form: 'notice',
|
|
31
|
+
summary: '复读已截断:请继续执行',
|
|
32
|
+
};
|
|
40
33
|
/**
|
|
41
34
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
42
35
|
* @param state - 跨监听保留的拦截状态。
|
|
43
36
|
* @param agent - 本轮所属的 agent 句柄。
|
|
44
37
|
*/
|
|
45
38
|
export function reviveTurn(state, agent) {
|
|
46
|
-
|
|
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
|
-
}
|
|
39
|
+
const sessionId = agent.session.id;
|
|
56
40
|
if (!state.pending.has(sessionId)) {
|
|
57
|
-
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 没有待续跑标记,不续跑` +
|
|
58
|
-
`(当前有标记的会话:${[...state.pending].join(',') || '无'})`);
|
|
59
41
|
return;
|
|
60
42
|
}
|
|
61
43
|
state.pending.delete(sessionId);
|
|
62
44
|
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
|
|
63
|
-
agent.steer(
|
|
45
|
+
agent.steer(createUserMessage({
|
|
46
|
+
content: [{ type: 'text', text: RESUME_TEXT }],
|
|
47
|
+
source: PLUGIN_SOURCE,
|
|
48
|
+
}));
|
|
64
49
|
}
|
package/lib/stream-guard.d.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
5
|
*/
|
|
6
|
-
import type { GenerateOptions,
|
|
6
|
+
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
7
|
+
import type { GuardState } from './types.js';
|
|
7
8
|
/** `llm/stream` 的监听器签名。 */
|
|
8
|
-
|
|
9
|
+
type StreamListener = (options: GenerateOptions, next: () => AsyncIterable<StreamChunk>) => AsyncIterable<StreamChunk>;
|
|
9
10
|
/**
|
|
10
11
|
* 造一个 `llm/stream` 监听器。
|
|
11
12
|
* @param state - 跨监听保留的拦截状态。
|
|
12
13
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
13
14
|
*/
|
|
14
15
|
export declare function createStreamGuard(state: GuardState): StreamListener;
|
|
16
|
+
export {};
|
package/lib/stream-guard.js
CHANGED
|
@@ -25,7 +25,7 @@ async function* guardStream(downstream, sessionId, state) {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
const chunk = step.value;
|
|
28
|
-
if (chunk
|
|
28
|
+
if (chunk.type === 'reasoning-delta') {
|
|
29
29
|
accumulated += chunk.text;
|
|
30
30
|
const hit = findDegenerateLine(accumulated);
|
|
31
31
|
if (hit !== null) {
|
|
@@ -34,9 +34,8 @@ async function* guardStream(downstream, sessionId, state) {
|
|
|
34
34
|
}
|
|
35
35
|
console.log(`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`);
|
|
36
36
|
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
37
|
-
const index = chunk.index ?? 0;
|
|
38
37
|
yield chunk;
|
|
39
|
-
yield { type: 'block-end', index, block: { type: 'reasoning', text: accumulated } };
|
|
38
|
+
yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
|
|
40
39
|
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
41
40
|
return;
|
|
42
41
|
}
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* 监听器的返回值不被使用(该事件是 serial 的副作用钩子),实现方式是调
|
|
5
5
|
* `agent.steer(...)`,见 resume.ts 的说明。
|
|
6
6
|
*/
|
|
7
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
7
8
|
import type { GuardState } from './types.js';
|
|
8
|
-
/** `agent/turn-stopping` 的监听器签名。 */
|
|
9
|
-
export type TurnStoppingListener = (payload: unknown) => void;
|
|
10
9
|
/**
|
|
11
10
|
* 造一个 `agent/turn-stopping` 监听器。
|
|
12
11
|
* @param state - 跨监听保留的拦截状态。
|
|
13
12
|
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
14
13
|
*/
|
|
15
|
-
export declare function createTurnStoppingGuard(state: GuardState):
|
|
14
|
+
export declare function createTurnStoppingGuard(state: GuardState): (payload: {
|
|
15
|
+
agent: Agent;
|
|
16
|
+
}) => void;
|
|
@@ -11,8 +11,7 @@ import { reviveTurn } from './resume.js';
|
|
|
11
11
|
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
12
12
|
*/
|
|
13
13
|
export function createTurnStoppingGuard(state) {
|
|
14
|
-
return (
|
|
15
|
-
|
|
16
|
-
reviveTurn(state, payload?.agent);
|
|
14
|
+
return (payload) => {
|
|
15
|
+
reviveTurn(state, payload.agent);
|
|
17
16
|
};
|
|
18
17
|
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,49 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 本插件自有的类型。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 与 dsh 交互的类型不在这里重抄:`Context`、`StreamChunk`、`GenerateOptions`、
|
|
5
|
+
* `Agent`、`UserMessage` 一律从 `@deepseek-ai/*` 引。本地抄一份会在 dsh 升级时
|
|
6
|
+
* 静默过期,而官方声明会让它在构建期就报出来。
|
|
6
7
|
*/
|
|
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
8
|
/** 跨两次监听保留的拦截状态。 */
|
|
48
9
|
export interface GuardState {
|
|
49
10
|
/** 发生过截断、等待下一步续跑的会话 id。 */
|
package/lib/types.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 本插件自有的类型。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 与 dsh 交互的类型不在这里重抄:`Context`、`StreamChunk`、`GenerateOptions`、
|
|
5
|
+
* `Agent`、`UserMessage` 一律从 `@deepseek-ai/*` 引。本地抄一份会在 dsh 升级时
|
|
6
|
+
* 静默过期,而官方声明会让它在构建期就报出来。
|
|
6
7
|
*/
|
|
7
8
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-repeat-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "流式输出退化拦截:检测碎片复读,掐断本次生成并提醒模型直接执行工具调用",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -28,7 +28,16 @@
|
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"author": "yuqing",
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
33
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
|
34
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.2"
|
|
35
|
+
},
|
|
31
36
|
"devDependencies": {
|
|
37
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
38
|
+
"@deepseek-ai/dsh-agent": "0.1.5-rc.2",
|
|
39
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
32
41
|
"typescript": "^7.0.2"
|
|
33
42
|
}
|
|
34
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,40 +2,35 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
|
|
4
4
|
// (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
|
|
5
|
-
// 闭合思考块,再补一个 finish(stop)
|
|
6
|
-
//
|
|
7
|
-
// 于是本次调用被当成正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
5
|
+
// 闭合思考块,再补一个 finish(stop),然后直接结束流。对 agent-loop 而言这就是一次正常
|
|
6
|
+
// 完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
|
|
8
7
|
//
|
|
9
8
|
// 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
|
|
10
9
|
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
11
10
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
|
|
12
|
+
// 表在 detect.ts 的 FRAGMENTS 里。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
|
|
13
|
+
// 具体、可增删、可审计的。
|
|
15
14
|
//
|
|
16
15
|
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
17
16
|
// 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
|
|
18
17
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// **相对路径 import 不受此限**:它由 Node 原生 ESM 按当前文件位置解析,所以本插件内部
|
|
23
|
-
// 按职责拆出的这些模块可以正常互相导入。新增模块时照此办理:只连相对路径。
|
|
18
|
+
// 依赖:dsh 把 bundle 的 dependencies / peerDependencies 从安装目录软链进 profile
|
|
19
|
+
// (dsh-app-boot 的 healProfileModuleFallback),所以本插件可以正常 import
|
|
20
|
+
// `@deepseek-ai/*`——它们声明在 peerDependencies 里,由宿主提供。
|
|
24
21
|
//
|
|
25
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
26
23
|
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
27
25
|
import { createStreamGuard } from './stream-guard.js';
|
|
28
26
|
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
29
|
-
import type { GuardState
|
|
30
|
-
|
|
31
|
-
/** dsh 跑在 Node 上;只声明本插件用到的全局,避免为此引入 @types/node。 */
|
|
32
|
-
declare const console: { log(...args: unknown[]): void };
|
|
27
|
+
import type { GuardState } from './types.js';
|
|
33
28
|
|
|
34
29
|
/**
|
|
35
30
|
* 函数式插件入口。ctx 为 cordis 上下文,注册的监听随插件卸载自动释放。
|
|
36
31
|
* @param ctx - 宿主 cordis 上下文。
|
|
37
32
|
*/
|
|
38
|
-
export default function repeatGuard(ctx:
|
|
33
|
+
export default function repeatGuard(ctx: Context): void {
|
|
39
34
|
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
40
35
|
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
41
36
|
const state: GuardState = { pending: new Set() };
|
package/src/resume.ts
CHANGED
|
@@ -10,14 +10,12 @@
|
|
|
10
10
|
* if (turnEnds && this.inbox.nextStep.length === 0) break; // 重读,读到东西就不 break
|
|
11
11
|
*
|
|
12
12
|
* 所以在监听器里调 `agent.steer(...)` 推入一条输入,本轮就会再跑一步,而不是停下来等
|
|
13
|
-
* 用户输入。dsh
|
|
13
|
+
* 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
declare const crypto: { randomUUID(): string };
|
|
20
|
-
declare const console: { log(...args: unknown[]): void };
|
|
16
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
17
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
18
|
+
import type { GuardState } from './types.js';
|
|
21
19
|
|
|
22
20
|
/** 续跑时推给模型的指令正文。 */
|
|
23
21
|
const RESUME_TEXT = [
|
|
@@ -26,51 +24,34 @@ const RESUME_TEXT = [
|
|
|
26
24
|
'请直接继续执行下一步,不要再输出任何确认语、寒暄或空话。',
|
|
27
25
|
].join('\n');
|
|
28
26
|
|
|
29
|
-
/** 折叠行显示的一行摘要;空字符串会让客户端回退成 opaque 渲染,故不可为空。 */
|
|
30
|
-
const RESUME_SUMMARY = '复读已截断:请继续执行';
|
|
31
|
-
|
|
32
27
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* 这条注入消息的来源标记。
|
|
29
|
+
* `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
|
|
30
|
+
* 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
|
|
35
31
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
kind: 'plugin',
|
|
43
|
-
plugin: 'repeat-guard',
|
|
44
|
-
form: 'notice',
|
|
45
|
-
summary: RESUME_SUMMARY,
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
32
|
+
const PLUGIN_SOURCE = {
|
|
33
|
+
kind: 'plugin',
|
|
34
|
+
plugin: 'repeat-guard',
|
|
35
|
+
form: 'notice',
|
|
36
|
+
summary: '复读已截断:请继续执行',
|
|
37
|
+
} as const;
|
|
49
38
|
|
|
50
39
|
/**
|
|
51
40
|
* 本轮即将关闭时,若上一步刚被掐断过,就推一条输入让本轮继续。
|
|
52
41
|
* @param state - 跨监听保留的拦截状态。
|
|
53
42
|
* @param agent - 本轮所属的 agent 句柄。
|
|
54
43
|
*/
|
|
55
|
-
export function reviveTurn(state: GuardState, agent:
|
|
56
|
-
|
|
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
|
-
}
|
|
44
|
+
export function reviveTurn(state: GuardState, agent: Agent): void {
|
|
45
|
+
const sessionId = agent.session.id;
|
|
66
46
|
if (!state.pending.has(sessionId)) {
|
|
67
|
-
console.log(
|
|
68
|
-
`[repeat-guard] turn-stopping:会话 ${sessionId} 没有待续跑标记,不续跑` +
|
|
69
|
-
`(当前有标记的会话:${[...state.pending].join(',') || '无'})`,
|
|
70
|
-
);
|
|
71
47
|
return;
|
|
72
48
|
}
|
|
73
49
|
state.pending.delete(sessionId);
|
|
74
50
|
console.log(`[repeat-guard] turn-stopping:会话 ${sessionId} 续跑一步`);
|
|
75
|
-
agent.steer(
|
|
51
|
+
agent.steer(
|
|
52
|
+
createUserMessage({
|
|
53
|
+
content: [{ type: 'text', text: RESUME_TEXT }],
|
|
54
|
+
source: PLUGIN_SOURCE,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
76
57
|
}
|
package/src/stream-guard.ts
CHANGED
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
* 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
7
8
|
import { findDegenerateLine } from './detect.js';
|
|
8
|
-
import type {
|
|
9
|
-
|
|
10
|
-
/** dsh 跑在 Node 上;只声明本插件用到的全局,避免为此引入 @types/node。 */
|
|
11
|
-
declare const console: { log(...args: unknown[]): void };
|
|
9
|
+
import type { GuardState } from './types.js';
|
|
12
10
|
|
|
13
11
|
/** `llm/stream` 的监听器签名。 */
|
|
14
|
-
|
|
12
|
+
type StreamListener = (
|
|
15
13
|
options: GenerateOptions,
|
|
16
14
|
next: () => AsyncIterable<StreamChunk>,
|
|
17
15
|
) => AsyncIterable<StreamChunk>;
|
|
@@ -29,7 +27,7 @@ export type StreamListener = (
|
|
|
29
27
|
*/
|
|
30
28
|
async function* guardStream(
|
|
31
29
|
downstream: AsyncIterable<StreamChunk>,
|
|
32
|
-
sessionId:
|
|
30
|
+
sessionId: GenerateOptions['sessionId'],
|
|
33
31
|
state: GuardState,
|
|
34
32
|
): AsyncGenerator<StreamChunk> {
|
|
35
33
|
const iterator = downstream[Symbol.asyncIterator]();
|
|
@@ -41,7 +39,7 @@ async function* guardStream(
|
|
|
41
39
|
return;
|
|
42
40
|
}
|
|
43
41
|
const chunk = step.value;
|
|
44
|
-
if (chunk
|
|
42
|
+
if (chunk.type === 'reasoning-delta') {
|
|
45
43
|
accumulated += chunk.text;
|
|
46
44
|
const hit = findDegenerateLine(accumulated);
|
|
47
45
|
if (hit !== null) {
|
|
@@ -52,9 +50,8 @@ async function* guardStream(
|
|
|
52
50
|
`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
|
|
53
51
|
);
|
|
54
52
|
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
55
|
-
const index = chunk.index ?? 0;
|
|
56
53
|
yield chunk;
|
|
57
|
-
yield { type: 'block-end', index, block: { type: 'reasoning', text: accumulated } };
|
|
54
|
+
yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
|
|
58
55
|
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
59
56
|
return;
|
|
60
57
|
}
|
|
@@ -5,20 +5,17 @@
|
|
|
5
5
|
* `agent.steer(...)`,见 resume.ts 的说明。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
8
9
|
import { reviveTurn } from './resume.js';
|
|
9
|
-
import type { GuardState
|
|
10
|
-
|
|
11
|
-
/** `agent/turn-stopping` 的监听器签名。 */
|
|
12
|
-
export type TurnStoppingListener = (payload: unknown) => void;
|
|
10
|
+
import type { GuardState } from './types.js';
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* 造一个 `agent/turn-stopping` 监听器。
|
|
16
14
|
* @param state - 跨监听保留的拦截状态。
|
|
17
15
|
* @returns 监听器;只在有待续跑标记时推一条输入。
|
|
18
16
|
*/
|
|
19
|
-
export function createTurnStoppingGuard(state: GuardState)
|
|
20
|
-
return (
|
|
21
|
-
|
|
22
|
-
reviveTurn(state, payload?.agent);
|
|
17
|
+
export function createTurnStoppingGuard(state: GuardState) {
|
|
18
|
+
return (payload: { agent: Agent }): void => {
|
|
19
|
+
reviveTurn(state, payload.agent);
|
|
23
20
|
};
|
|
24
21
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,55 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 本插件自有的类型。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 与 dsh 交互的类型不在这里重抄:`Context`、`StreamChunk`、`GenerateOptions`、
|
|
5
|
+
* `Agent`、`UserMessage` 一律从 `@deepseek-ai/*` 引。本地抄一份会在 dsh 升级时
|
|
6
|
+
* 静默过期,而官方声明会让它在构建期就报出来。
|
|
6
7
|
*/
|
|
7
8
|
|
|
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
9
|
/** 跨两次监听保留的拦截状态。 */
|
|
54
10
|
export interface GuardState {
|
|
55
11
|
/** 发生过截断、等待下一步续跑的会话 id。 */
|