dsh-repeat-guard 0.1.0 → 0.1.2

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/src/index.ts CHANGED
@@ -2,43 +2,40 @@
2
2
  //
3
3
  // 做法:包一层 `llm/stream`(模型调用的流式 waterfall),逐 chunk 观察思考段
4
4
  // (reasoning-delta)。命中退化时,把触发退化的那个 delta 照常放行,补一个 block-end
5
- // 闭合思考块,再补一个 finish(stop),然后直接结束流。agent-loop 的 BlockAssembler 会把
6
- // 这种"没有 finish 就结束"的流当作正常 stop(assembler.js: `this._finish ?? {kind:'stop'}`),
7
- // 于是本次调用被当成正常完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
5
+ // 闭合思考块,再补一个 finish(stop),然后直接结束流。对 agent-loop 而言这就是一次正常
6
+ // 完成:已生成的思考照常落盘,本轮该走什么流程就走什么流程。
8
7
  //
9
8
  // 只检测思考段:正文 text-delta 不参与判定。复读退化先出现在思考段,那时正文往往还没
10
9
  // 开始产出;等正文也碎掉再掐,思考段已经白烧了一遍。
11
10
  //
12
- // 判定做什么:思考段里出现单独成行的一句空话(中文一到两个汉字,如"好。""执行。";
13
- // 英文不超过三个单词,如"OK." "Let me go.")就掐断。判据的取舍见 detect.ts 的注释——
14
- // 关键是不能把"同样的碎片出现了几次"当作复读信号,那样会误伤正常的短句。
11
+ // 判定做什么:思考段里出现独占一行的表项(如"好。""执行。""Let me go.")就掐断,
12
+ // 短句表与阈值都可配置,见 config.ts。不做形状归纳——归纳出的规则总会外溢误伤,表项则是
13
+ // 具体、可增删、可审计的。
15
14
  //
16
15
  // 掐断之后要让本轮继续,而不是停下来等用户输入:挂在 `agent/turn-stopping` 上,在本轮
17
16
  // 边界提交之前调 `agent.steer(...)` 推一条输入,机器就会再跑一步。详见 resume.ts。
18
17
  //
19
- // 依赖约束(重要):不得 import 任何外部包,包括 @deepseek-ai/cordis。
20
- // cordis-plugin-loader 解析裸 specifier 时直接 `import(name)`,锚点是 loader 自身的
21
- // 文件位置(dsh 安装目录内部),从 profile 或全局顶层加载时都可能解析不到插件的依赖。
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';
25
+ import { createConfigSource } from './config.js';
27
26
  import { createStreamGuard } from './stream-guard.js';
28
27
  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 };
28
+ import type { GuardState } from './types.js';
33
29
 
34
30
  /**
35
31
  * 函数式插件入口。ctx 为 cordis 上下文,注册的监听随插件卸载自动释放。
36
32
  * @param ctx - 宿主 cordis 上下文。
37
33
  */
38
- export default function repeatGuard(ctx: PluginContext): void {
34
+ export default function repeatGuard(ctx: Context): void {
39
35
  // 直接写 stdout:dsh 把插件的 stdout 收进 journal,便于确认插件确实被加载。
40
36
  console.log('[repeat-guard] 已加载,复读拦截生效');
41
37
  const state: GuardState = { pending: new Set() };
42
- ctx.on('llm/stream', createStreamGuard(state), { global: true });
38
+ const readConfig = createConfigSource(ctx);
39
+ ctx.on('llm/stream', createStreamGuard(state, readConfig), { global: true });
43
40
  ctx.on('agent/turn-stopping', createTurnStoppingGuard(state));
44
41
  }
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 自己在 dsh-hooks-claude-code/lib/index.js:292 有同样的用法。
13
+ * 用户输入。dsh 自己的 dsh-hooks-claude-code/lib/index.js:300 就是这么用的。
14
14
  */
15
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 };
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
- * 构造推给模型的一条输入,结构与 dsh 的 createUserMessage 对齐。
34
- * @returns 一条插件来源的 user 消息,客户端会渲染成折叠的 context 行。
28
+ * 这条注入消息的来源标记。
29
+ * `form: 'notice'` 要求同时给出 `summary`(dsh-llm 的 ContextFormed),
30
+ * 客户端据此把它渲染成折叠的 context 行,而不是用户气泡。
35
31
  */
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
- }
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: 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
- }
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(resumeMessage());
51
+ agent.steer(
52
+ createUserMessage({
53
+ content: [{ type: 'text', text: RESUME_TEXT }],
54
+ source: PLUGIN_SOURCE,
55
+ }),
56
+ );
76
57
  }
@@ -4,14 +4,13 @@
4
4
  * 只检测 reasoning-delta(思考段)。正文 text-delta 不参与判定,原样透传。
5
5
  */
6
6
 
7
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
8
+ import type { ConfigSource } from './config.js';
7
9
  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 };
10
+ import type { GuardState } from './types.js';
12
11
 
13
12
  /** `llm/stream` 的监听器签名。 */
14
- export type StreamListener = (
13
+ type StreamListener = (
15
14
  options: GenerateOptions,
16
15
  next: () => AsyncIterable<StreamChunk>,
17
16
  ) => AsyncIterable<StreamChunk>;
@@ -22,15 +21,21 @@ export type StreamListener = (
22
21
  * 适配器只开一个 reasoning 块(dsh-llm-deepseek: `reasoningBlock` 是单个变量),
23
22
  * 所以累积文本用一个字符串即可,不需要按 index 分开存。
24
23
  *
24
+ * 命中之后先往下探一格,判"思考段是不是就到这儿了":再往下若还是 reasoning-delta,
25
+ * 说明思考在继续复读,该拦;若换成正文、工具调用,或直接收尾,说明命中行本来就是
26
+ * 这段思考的最后一句,属正常收尾,不拦。
27
+ *
25
28
  * @param downstream - 上游模型的流。
26
29
  * @param sessionId - 当前会话 id;有值才登记待续跑标记。
27
30
  * @param state - 跨监听保留的拦截状态。
31
+ * @param readConfig - 取当前配置;每个 chunk 现取,改设置立即生效。
28
32
  * @returns 包好的流。
29
33
  */
30
34
  async function* guardStream(
31
35
  downstream: AsyncIterable<StreamChunk>,
32
- sessionId: string | undefined,
36
+ sessionId: GenerateOptions['sessionId'],
33
37
  state: GuardState,
38
+ readConfig: ConfigSource,
34
39
  ): AsyncGenerator<StreamChunk> {
35
40
  const iterator = downstream[Symbol.asyncIterator]();
36
41
  let accumulated = '';
@@ -41,22 +46,33 @@ async function* guardStream(
41
46
  return;
42
47
  }
43
48
  const chunk = step.value;
44
- if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') {
49
+ if (chunk.type === 'reasoning-delta') {
45
50
  accumulated += chunk.text;
46
- const hit = findDegenerateLine(accumulated);
51
+ const config = readConfig();
52
+ const hit = findDegenerateLine(accumulated, config.fragments, config.threshold);
47
53
  if (hit !== null) {
48
- if (sessionId !== undefined) {
49
- state.pending.add(sessionId);
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;
50
67
  }
51
68
  console.log(
52
- `[repeat-guard] 检出思考段复读,已掐断本次生成 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
69
+ `[repeat-guard] 命中行位于思考段末尾,未拦截 | 会话=${sessionId ?? '无'} | 命中行=${JSON.stringify(hit)}`,
53
70
  );
54
- // 触发点本身已经产生了,照常放行;要掐掉的是它之后的思考。
55
- const index = chunk.index ?? 0;
56
71
  yield chunk;
57
- yield { type: 'block-end', index, block: { type: 'reasoning', text: accumulated } };
58
- yield { type: 'finish', reason: { kind: 'stop' } };
59
- return;
72
+ if (!probe.done) {
73
+ yield probe.value;
74
+ }
75
+ continue;
60
76
  }
61
77
  }
62
78
  yield chunk;
@@ -75,14 +91,15 @@ async function* guardStream(
75
91
  /**
76
92
  * 造一个 `llm/stream` 监听器。
77
93
  * @param state - 跨监听保留的拦截状态。
94
+ * @param readConfig - 取当前配置。
78
95
  * @returns 监听器;辅助调用直接透传,其余包一层复读检测。
79
96
  */
80
- export function createStreamGuard(state: GuardState): StreamListener {
97
+ export function createStreamGuard(state: GuardState, readConfig: ConfigSource): StreamListener {
81
98
  return (options, next) => {
82
99
  // 辅助调用(上下文压缩、会话标题)不参与检测。
83
100
  if (options.purpose !== undefined) {
84
101
  return next();
85
102
  }
86
- return guardStream(next(), options.sessionId, state);
103
+ return guardStream(next(), options.sessionId, state, readConfig);
87
104
  };
88
105
  }
@@ -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, TurnStoppingPayload } from './types.js';
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): TurnStoppingListener {
20
- return (rawPayload) => {
21
- const payload = rawPayload as TurnStoppingPayload;
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
- * 与本插件交互的 dsh 接口声明。
2
+ * 本插件自有的类型。
3
3
  *
4
- * 这些类型一律在本插件内声明,不 import dsh 的类型:那样还要为 tsc 配 paths 指向
5
- * dsh 安装目录,路径里带 node 版本号,dsh 一升级就失效。
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。 */