dsh-repeat-guard 0.1.1 → 0.1.3
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 +69 -24
- package/lib/client.d.ts +18 -0
- package/lib/client.js +299 -0
- package/lib/config.d.ts +28 -0
- package/lib/config.js +73 -0
- package/lib/detect.d.ts +8 -5
- package/lib/detect.js +17 -50
- package/lib/index.js +4 -2
- package/lib/stream-guard.d.ts +3 -1
- package/lib/stream-guard.js +27 -11
- package/package.json +24 -3
- package/src/client.tsx +332 -0
- package/src/config.ts +94 -0
- package/src/detect.ts +20 -52
- package/src/index.ts +4 -2
- package/src/stream-guard.ts +30 -10
package/src/config.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件配置:拦截短句表与连续命中阈值。
|
|
3
|
+
*
|
|
4
|
+
* 配置放在 dsh 的 settings 服务里(命名空间 repeat-guard):宿主侧在这边注册
|
|
5
|
+
* schema,客户端设置页写同一个命名空间。判定时现取,改完立即生效,不用重启。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
+
// 空导入:只为加载它的 declaration merging(往 cordis 的 Context 上补 settings 服务)。
|
|
10
|
+
import type {} from '@deepseek-ai/dsh-settings';
|
|
11
|
+
import z from '@deepseek-ai/schemastery';
|
|
12
|
+
|
|
13
|
+
/** settings 命名空间。客户端设置页必须写同一个值。 */
|
|
14
|
+
export const SETTINGS_NS = 'repeat-guard';
|
|
15
|
+
|
|
16
|
+
/** 默认的拦截短句表,标点照原样写。 */
|
|
17
|
+
export const DEFAULT_FRAGMENTS: readonly string[] = [
|
|
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
|
+
/** 默认阈值:1 表示发现即拦。 */
|
|
53
|
+
export const DEFAULT_THRESHOLD = 1;
|
|
54
|
+
|
|
55
|
+
const SCHEMA = z.object({
|
|
56
|
+
fragments: z.array(z.string()).default([...DEFAULT_FRAGMENTS]),
|
|
57
|
+
threshold: z.number().default(DEFAULT_THRESHOLD),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** 一份生效中的配置,已编译成判定时直接可用的形式。 */
|
|
61
|
+
export interface RepeatGuardConfig {
|
|
62
|
+
/** 短句表,已全部小写化。 */
|
|
63
|
+
readonly fragments: ReadonlySet<string>;
|
|
64
|
+
/** 连续命中多少行才拦。 */
|
|
65
|
+
readonly threshold: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 取当前配置。 */
|
|
69
|
+
export type ConfigSource = () => RepeatGuardConfig;
|
|
70
|
+
|
|
71
|
+
function compile(value: { fragments: readonly string[]; threshold: number }): RepeatGuardConfig {
|
|
72
|
+
return {
|
|
73
|
+
fragments: new Set(value.fragments.map((fragment) => fragment.toLowerCase())),
|
|
74
|
+
threshold: value.threshold,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 注册配置命名空间,并返回取当前配置的函数。
|
|
80
|
+
* @param ctx - 宿主 cordis 上下文。
|
|
81
|
+
* @returns 每次调用都返回最新配置。
|
|
82
|
+
*/
|
|
83
|
+
export function createConfigSource(ctx: Context): ConfigSource {
|
|
84
|
+
let current = compile({ fragments: DEFAULT_FRAGMENTS, threshold: DEFAULT_THRESHOLD });
|
|
85
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
86
|
+
const scope = settingsCtx.settings.register(SETTINGS_NS, SCHEMA);
|
|
87
|
+
const sync = (): void => {
|
|
88
|
+
current = compile(scope.get());
|
|
89
|
+
};
|
|
90
|
+
sync();
|
|
91
|
+
scope.watch(sync);
|
|
92
|
+
});
|
|
93
|
+
return () => current;
|
|
94
|
+
}
|
package/src/detect.ts
CHANGED
|
@@ -1,67 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 复读退化判定:查表。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 表里是已知会被模型复读的空话短句,**连标点一起写**,表本身可配置(见 config.ts)。
|
|
5
|
+
* 判定是"思考里有没有独占一行的表项":整行与表项全等,不拆解行的内部。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 阈值是"连续命中多少行才拦":默认 1 表示发现即拦;调到 n 时,要连着 n 行都命中
|
|
8
|
+
* (句子可以各不相同)才拦,中间夹一行不命中的就重新计数。
|
|
8
9
|
*/
|
|
9
10
|
|
|
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
11
|
/**
|
|
56
12
|
* 找出退化的碎片行。
|
|
57
13
|
* @param text - 已生成的思考文本。
|
|
58
|
-
* @
|
|
14
|
+
* @param fragments - 小写化的短句表。
|
|
15
|
+
* @param threshold - 连续命中多少行才判定为复读。
|
|
16
|
+
* @returns 计数成立时返回那一行的原文,否则返回 null。
|
|
59
17
|
*/
|
|
60
|
-
export function findDegenerateLine(
|
|
18
|
+
export function findDegenerateLine(
|
|
19
|
+
text: string,
|
|
20
|
+
fragments: ReadonlySet<string>,
|
|
21
|
+
threshold: number,
|
|
22
|
+
): string | null {
|
|
23
|
+
let run = 0;
|
|
61
24
|
for (const raw of text.split('\n')) {
|
|
62
25
|
const line = raw.trim();
|
|
63
|
-
if (line !== '' &&
|
|
64
|
-
|
|
26
|
+
if (line !== '' && fragments.has(line.toLowerCase())) {
|
|
27
|
+
run += 1;
|
|
28
|
+
if (run >= threshold) {
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
run = 0;
|
|
65
33
|
}
|
|
66
34
|
}
|
|
67
35
|
return null;
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
|
|
10
10
|
//
|
|
11
11
|
// 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
|
|
12
|
-
//
|
|
12
|
+
// 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
|
|
13
13
|
// 具体、可增删、可审计的。
|
|
14
14
|
//
|
|
15
15
|
// 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// 本文件只做装配,具体逻辑在各自的模块里。
|
|
23
23
|
|
|
24
24
|
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
+
import { createConfigSource } from './config.js';
|
|
25
26
|
import { createStreamGuard } from './stream-guard.js';
|
|
26
27
|
import { createTurnStoppingGuard } from './turn-stopping-guard.js';
|
|
27
28
|
import type { GuardState } from './types.js';
|
|
@@ -34,6 +35,7 @@ export default function repeatGuard(ctx: Context): void {
|
|
|
34
35
|
// 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
|
|
35
36
|
console.log('[repeat-guard] 已加载,复读拦截生效');
|
|
36
37
|
const state: GuardState = { pending: new Set() };
|
|
37
|
-
|
|
38
|
+
const readConfig = createConfigSource(ctx);
|
|
39
|
+
ctx.on('llm/stream', createStreamGuard(state, readConfig), { global: true });
|
|
38
40
|
ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
|
|
39
41
|
}
|
package/src/stream-guard.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
8
|
+
import type { ConfigSource } from './config.js';
|
|
8
9
|
import { findDegenerateLine } from './detect.js';
|
|
9
10
|
import type { GuardState } from './types.js';
|
|
10
11
|
|
|
@@ -20,15 +21,21 @@ type StreamListener = (
|
|
|
20
21
|
* 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
|
|
21
22
|
* 所以累积文本用一个字符串即可,不需要按 index 分开存。
|
|
22
23
|
*
|
|
24
|
+
* 命中之后先往下探一格,判"思考段是不是就到这儿了":再往下若还是 reasoning-delta,
|
|
25
|
+
* 说明思考在继续复读,该拦;若换成正文、工具调用,或直接收尾,说明命中行本来就是
|
|
26
|
+
* 这段思考的最后一句,属正常收尾,不拦。
|
|
27
|
+
*
|
|
23
28
|
* @param downstream - 上游模型的流。
|
|
24
29
|
* @param sessionId - 当前会话 id;有值才登记待续跑标记。
|
|
25
30
|
* @param state - 跨监听保留的拦截状态。
|
|
31
|
+
* @param readConfig - 取当前配置;每个 chunk 现取,改设置立即生效。
|
|
26
32
|
* @returns 包好的流。
|
|
27
33
|
*/
|
|
28
34
|
async function* guardStream(
|
|
29
35
|
downstream: AsyncIterable<StreamChunk>,
|
|
30
36
|
sessionId: GenerateOptions['sessionId'],
|
|
31
37
|
state: GuardState,
|
|
38
|
+
readConfig: ConfigSource,
|
|
32
39
|
): AsyncGenerator<StreamChunk> {
|
|
33
40
|
const iterator = downstream[Symbol.asyncIterator]();
|
|
34
41
|
let accumulated = '';
|
|
@@ -41,19 +48,31 @@ async function* guardStream(
|
|
|
41
48
|
const chunk = step.value;
|
|
42
49
|
if (chunk.type === 'reasoning-delta') {
|
|
43
50
|
accumulated += chunk.text;
|
|
44
|
-
const
|
|
51
|
+
const config = readConfig();
|
|
52
|
+
const hit = findDegenerateLine(accumulated, config.fragments, config.threshold);
|
|
45
53
|
if (hit !== null) {
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
const probe = await iterator.next();
|
|
55
|
+
if (!probe.done && probe.value.type === 'reasoning-delta') {
|
|
56
|
+
if (sessionId !== undefined) {
|
|
57
|
+
state.pending.add(sessionId);
|
|
58
|
+
}
|
|
59
|
+
console.log(
|
|
60
|
+
`[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
|
|
61
|
+
);
|
|
62
|
+
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
63
|
+
yield chunk;
|
|
64
|
+
yield { type: 'block-end', index: chunk.index, block: { type: 'reasoning', text: accumulated } };
|
|
65
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
66
|
+
return;
|
|
48
67
|
}
|
|
49
68
|
console.log(
|
|
50
|
-
`[repeat-guard]
|
|
69
|
+
`[repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
|
|
51
70
|
);
|
|
52
|
-
// 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
|
|
53
71
|
yield chunk;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
if (!probe.done) {
|
|
73
|
+
yield probe.value;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
57
76
|
}
|
|
58
77
|
}
|
|
59
78
|
yield chunk;
|
|
@@ -72,14 +91,15 @@ async function* guardStream(
|
|
|
72
91
|
/**
|
|
73
92
|
* 造一个 `llm/stream` 监听器。
|
|
74
93
|
* @param state - 跨监听保留的拦截状态。
|
|
94
|
+
* @param readConfig - 取当前配置。
|
|
75
95
|
* @returns 监听器;辅助调用直接透传,其余包一层复读检测。
|
|
76
96
|
*/
|
|
77
|
-
export function createStreamGuard(state: GuardState): StreamListener {
|
|
97
|
+
export function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener {
|
|
78
98
|
return (options, next) => {
|
|
79
99
|
// 辅助调用(上下文压缩、会话标题)不参与检测。
|
|
80
100
|
if (options.purpose !== undefined) {
|
|
81
101
|
return next();
|
|
82
102
|
}
|
|
83
|
-
return guardStream(next(), options.sessionId, state);
|
|
103
|
+
return guardStream(next(), options.sessionId, state, readConfig);
|
|
84
104
|
};
|
|
85
105
|
}
|