@max-null/dsh-allostasis 0.2.0 → 0.3.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/dist/index.js CHANGED
@@ -1,11 +1,29 @@
1
1
  /**
2
2
  * 应变(allostasis):会话状态的自我调节。
3
3
  *
4
- * 两期能力都落在同一个 `agent/pre-step` 上,因为它们读的是同一份输入——最近一条思考:
4
+ * 三个能力落在两个挂载点上:
5
5
  *
6
- * · **一期 · 中文锚定**:判定为语言漂移(英文功能词密度越线)就追加一条中文锚定消息。
7
- * · **二期 · 退化提醒**:判定为推理退化(重复率越线且连续若干步成立)就追加减速提醒,
8
- * 并向会话日志 append 一条 `allostasis/degeneration` 记录当时的判定依据。
6
+ * · **一期 · 中文锚定**(`agent/pre-step`):判定为语言漂移(英文功能词密度越线)就追加
7
+ * 一条中文锚定消息。
8
+ * · **二期 · 退化提醒**(`agent/pre-step`):判定为推理退化(窗口内累计若干步重复率越线)
9
+ * 就追加减速提醒。
10
+ * · **空回合检测**(`agent/turn-stopping`):回合收尾时末条助手消息没有非空文本即判定成立,
11
+ * `steer` 档位下追加一次补生成。
12
+ *
13
+ * **判定结果不写会话日志**。会话日志的事件词汇表 `KNOWN_SESSION_EVENT_TYPES` 由内核在构建期
14
+ * 生成,下游插件的事件类型不在其中;`Session.append()` 的封装
15
+ * (`packages/core/session/src/index.ts:744-750`)没有 `ignorable` 通道,而
16
+ * `SessionEvent.ignorable` 的契约要求这类「丢失不影响重建」的记录必须自带该标记
17
+ * (`packages/core/session/src/types.ts:501-511`)。缺标记的自定义类型会让**整份**日志在下次
18
+ * 加载时被拒读(`packages/session/session-persistence/src/storage-contract.ts:75-80`)——
19
+ * 2026-10-01 实测:dev 与装版各有会话因此打不开。判定输入本来就可重放(推理原文在
20
+ * `reasoning-chunks.texts`、回合边界在 `turn/start` / `turn/end`),留痕因此改由本模块的
21
+ * `console.debug` 诊断行与浏览器半边的提示承担;浏览器半边自行从事件流折叠判定,
22
+ * 见 `client/silent-turn.ts`。
23
+ *
24
+ * 前两个共用 `agent/pre-step`,因为它们读同一份输入——最近一条思考。空回合读的是另一种
25
+ * 输入(这一轮最终产出了什么),而且**它之后没有下一步**,`pre-step` 不会被再次调用,
26
+ * 判定只能落在回合收尾。
9
27
  *
10
28
  * **平时不出现、异常时才出现**——按需出现是它作为信号的前提。但异常持续时也不每个 step
11
29
  * 都提醒:同一 turn 至多一次,见 `throttle.ts`。两类提醒各有独立的一份节流状态:判据无关,
@@ -13,30 +31,32 @@
13
31
  *
14
32
  * 为什么不挂到 system prompt 前缀上:那是 `dsh-chinese-thinking` 的位置,它作为基线
15
33
  * 永远在场。而基线在长会话里会失效(固定前缀离输出最远,语言模式受近因支配)。本插件
16
- * 补的是「**你正在漂移 / 正在打转**」这个纠偏信号,因此必须落在近因位置。
34
+ * 补的是「**你正在漂移 / 正在打转 / 这一轮什么也没说**」这类纠偏信号,因此必须落在近因
35
+ * 或收尾位置。
17
36
  *
18
37
  * 为什么用 `agent/pre-step` 而不是 `systemPrompt.context()`:前者直接给出 `turn` /
19
38
  * `step`,且追加的是一条独立消息,落点比快照里的一个段更靠后。用法先例见官方
20
39
  * `packages/context/time-context/src/index.ts:180-220`。
21
40
  *
22
- * 阈值全部走 `config.ts` 的配置面。**压缩动作按设计方案 §九 暂缓**:数据指向退化样本
23
- * 散布在整段退化区间,而 `compactRegion` 只压指定区间,能否打断循环尚未验证。
41
+ * 阈值与档位全部走 `config.ts` 的配置面。**压缩动作按设计方案 §九 暂缓**:数据指向退化
42
+ * 样本散布在整段退化区间,而 `compactRegion` 只压指定区间,能否打断循环尚未验证。
24
43
  *
25
44
  * 设计出处:`docs/设计/2026-09-20-应变-设计方案.md`、
26
- * `docs/设计/2026-09-28-应变二期-退化检测与自动干预.md`
45
+ * `docs/设计/2026-09-28-应变二期-退化检测与自动干预.md`、
46
+ * `docs/设计/2026-09-30-空回合检测与可见化.md`
27
47
  * @module @max-null/dsh-allostasis
28
48
  */
29
49
  import { anchorText } from "./anchor.js";
30
50
  import { Config, resolveConfig } from "./config.js";
31
51
  import { measureThinking, verdict } from "./drift.js";
32
- import { degenerationEvent } from "./events.js";
33
- import { degenerationText, pluginNotice } from "./messages.js";
52
+ import { degenerationText, pluginNotice, silentTurnText } from "./messages.js";
34
53
  import { name } from "./name.js";
35
54
  import { measureRepetition, repetitionVerdict, trackLoop } from "./repetition.js";
55
+ import { measureTail, tailSamples, tailVerdict } from "./tail.js";
36
56
  import { latestThinking } from "./thinking.js";
37
57
  import { admitPerTurn } from "./throttle.js";
38
58
  export { anchorText, Config, name };
39
- /** 需要 `agents` 服务来接收 `agent/pre-step` 事件。 */
59
+ /** 需要 `agents` 服务来接收 `agent/pre-step` 与 `agent/turn-stopping` 事件。 */
40
60
  export const inject = ['agents'];
41
61
  /**
42
62
  * 组装一期的语言漂移提醒。
@@ -57,21 +77,21 @@ function driftMessage(session, sample, resolved, throttles) {
57
77
  return pluginNotice(anchorText(sample.turn, sample.step, metrics, advanced.count), `语言漂移 · turn ${sample.turn} · 第 ${advanced.count} 次`);
58
78
  }
59
79
  /**
60
- * 组装二期的推理退化提醒,并落下判定依据。
80
+ * 组装二期的推理退化提醒。
61
81
  *
62
- * 追踪状态**每步都推进**,包括被节流挡住的那几步:计数描述的是退化本身持续了多久,
82
+ * 追踪状态**每步都推进**,包括被节流挡住的那几步:窗口描述的是退化本身分布在哪几步,
63
83
  * 与「这一步有没有说出口」无关。
64
84
  * @param session - 产出该思考的会话。
65
85
  * @param sample - 最近一条思考。
66
86
  * @param resolved - 已校验的配置。
67
- * @param trackers - 连续越线的追踪状态表。
87
+ * @param trackers - 越线窗口的追踪状态表。
68
88
  * @param throttles - 退化提醒的节流状态表。
69
89
  * @returns 应当追加的消息;本步不提醒时返回 `undefined`。
70
90
  */
71
91
  function loopMessage(session, sample, resolved, trackers, throttles) {
72
92
  const metrics = measureRepetition(sample.text);
73
93
  const stepVerdict = repetitionVerdict(metrics, resolved.repetitionThreshold);
74
- const tracked = trackLoop(trackers.get(session), sample.turn, stepVerdict, resolved.consecutiveSteps);
94
+ const tracked = trackLoop(trackers.get(session), stepVerdict, resolved.loopWindowSteps, resolved.loopWindowHits);
75
95
  trackers.set(session, tracked.state);
76
96
  if (!tracked.fire)
77
97
  return undefined;
@@ -79,16 +99,64 @@ function loopMessage(session, sample, resolved, trackers, throttles) {
79
99
  if (advanced === undefined)
80
100
  return undefined;
81
101
  throttles.set(session, advanced);
82
- session.append('allostasis/degeneration', degenerationEvent({
83
- turn: sample.turn,
84
- step: sample.step,
85
- metrics,
86
- consecutive: tracked.state.consecutive,
87
- threshold: resolved.repetitionThreshold,
88
- required: resolved.consecutiveSteps,
89
- }));
90
102
  return pluginNotice(degenerationText(sample.turn, sample.step, metrics, advanced.count), `推理退化 · turn ${sample.turn} · 重复率 ${(metrics.ratio * 100).toFixed(0)}%`);
91
103
  }
104
+ /**
105
+ * 注册回合收尾监听:检出空回合;`steer` 档位下追加一次补生成。
106
+ *
107
+ * **为什么整段包 try/catch**:内核的契约测试写明该事件里抛出的异常会让 turn 以 error
108
+ * 结束(`packages/core/agent-loop/tests/contract-regressions.spec.ts:357`,loop 本身
109
+ * 存活)。判据失败不得升级成回合失败——与二期对 `compactRegion` 的要求同源。这里记
110
+ * `warn` 而不是 `debug`:它意味着判据本身坏了,与「这一步判成了什么」不是一类。
111
+ *
112
+ * **为什么要排除取消与子代理**:`aborted` / `interrupted` 回合的沉默是用户中止的预期
113
+ * 结果(实测 22 轮里 12 轮),子代理的回合属于父回复的中间产物——两者都不该计入空回合,
114
+ * 前者由 `signal.aborted` 判、后者由会话头的 `parentSession` 判,两条先例见
115
+ * `@changfenhuang/dsh-genui` 的 `fenceFeedback`。
116
+ *
117
+ * **补生成的记账发生在发送之前**:`turn-stopping` 是可能重入的边界,先记账才能保证同一次
118
+ * 补生成不会被投两遍——与 `fenceFeedback` 同一条约束。
119
+ *
120
+ * **补生成单独包一层 catch**:判定已经成立,补生成失败不该升级成回合失败。
121
+ * @param ctx - 插件上下文。
122
+ * @param resolved - 已校验的配置。
123
+ * @param steers - 补生成的节流状态表,按会话各一份。
124
+ */
125
+ function installSilentTurn(ctx, resolved, steers) {
126
+ ctx.on('agent/turn-stopping', ({ agent, turn, signal }) => {
127
+ try {
128
+ if (resolved.silentTurn === 'off')
129
+ return;
130
+ if (signal.aborted)
131
+ return;
132
+ if (agent.session.header.parentSession !== undefined)
133
+ return;
134
+ const { session } = agent;
135
+ const shape = measureTail(tailSamples(session.snapshotEvents()), turn);
136
+ if (shape === undefined || tailVerdict(shape) !== 'silent')
137
+ return;
138
+ const plan = resolved.silentTurn === 'steer' ? admitPerTurn(steers.get(session), turn) : undefined;
139
+ if (plan !== undefined)
140
+ steers.set(session, plan);
141
+ console.debug(`[${name}] 空回合 · turn ${turn} step ${shape.step}`
142
+ + ` · reasoning ${shape.reasoningChars} 字`
143
+ + ` blocks=reasoning×${shape.blocks.reasoning} text×${shape.blocks.text}`
144
+ + ` tool×${shape.blocks.toolCalls}`
145
+ + ` steered=${plan !== undefined}`);
146
+ if (plan === undefined)
147
+ return;
148
+ try {
149
+ agent.steer(pluginNotice(silentTurnText(turn, shape), `空回合 · turn ${turn} · 补一次生成`));
150
+ }
151
+ catch (error) {
152
+ ctx.logger?.warn?.(`${name}: silent-turn steer failed (${error instanceof Error ? error.message : String(error)})`);
153
+ }
154
+ }
155
+ catch (error) {
156
+ ctx.logger?.warn?.(`${name}: silent-turn check failed (${error instanceof Error ? error.message : String(error)})`);
157
+ }
158
+ });
159
+ }
92
160
  /**
93
161
  * 注册 pre-step 监听器;监听器随 `ctx` 生命周期销毁。
94
162
  *
@@ -104,11 +172,13 @@ export function apply(ctx, config = {}) {
104
172
  // 判定行走 debug,默认静默、排查时打开即可,不必为了看一眼判定去改阈值试。
105
173
  console.info(`[${name}] loaded · driftThreshold=${resolved.driftThreshold}`
106
174
  + ` repetitionThreshold=${resolved.repetitionThreshold}`
107
- + ` consecutiveSteps=${resolved.consecutiveSteps}`);
175
+ + ` loopWindow=${resolved.loopWindowHits}/${resolved.loopWindowSteps}`
176
+ + ` silentTurn=${resolved.silentTurn}`);
108
177
  /** 每个会话各一份状态;用 WeakMap 以免会话销毁后残留。 */
109
178
  const anchorThrottles = new WeakMap();
110
179
  const loopThrottles = new WeakMap();
111
180
  const loopTrackers = new WeakMap();
181
+ const silentSteers = new WeakMap();
112
182
  ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
113
183
  const decision = await next();
114
184
  if (decision.kind === 'reject' || signal.aborted)
@@ -136,4 +206,5 @@ export function apply(ctx, config = {}) {
136
206
  return decision;
137
207
  return { ...decision, messages: [...decision.messages, ...appended] };
138
208
  }, { prepend: true });
209
+ installSilentTurn(ctx, resolved, silentSteers);
139
210
  }
@@ -20,6 +20,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm';
20
20
  import type { UserMessage } from '@deepseek-ai/dsh-session';
21
21
  import { SOURCE_KIND } from './name.ts';
22
22
  import type { RepetitionMetrics } from './repetition.ts';
23
+ import type { TailShape } from './tail.ts';
23
24
  declare module '@deepseek-ai/dsh-llm' {
24
25
  interface MessageSourceMap {
25
26
  /** 应变注入的即时提醒:语言漂移锚定与推理退化提醒共用这一个生产者身份。 */
@@ -48,3 +49,14 @@ export declare function pluginNotice(text: string, summary: string): UserMessage
48
49
  * @returns 一条退化提醒消息的正文。
49
50
  */
50
51
  export declare function degenerationText(turn: number, step: number, metrics: RepetitionMetrics, reminder?: number): string;
52
+ /**
53
+ * 组装空回合的补生成请求。
54
+ *
55
+ * 措辞对着**用户此刻的处境**写:他看到的是空白,所以先说清发生了什么,再给一条可执行的
56
+ * 出路。两条禁令是必要的——不点明「不要重做工具」,模型很可能把整轮动作再跑一遍,而这一轮
57
+ * 的产出已经不是用户缺的东西了。
58
+ * @param turn - 判定的回合号。
59
+ * @param shape - 该回合末条助手消息的产出形态。
60
+ * @returns 一条补生成请求的正文。
61
+ */
62
+ export declare function silentTurnText(turn: number, shape: TailShape): string;
package/dist/messages.js CHANGED
@@ -60,8 +60,34 @@ export function degenerationText(turn, step, metrics, reminder = 1) {
60
60
  const top = metrics.top.slice(0, TOP_UNITS_SHOWN)
61
61
  .map(entry => `「${entry.unit.length <= TOP_UNIT_MAX_CHARS ? entry.unit : `${entry.unit.slice(0, TOP_UNIT_MAX_CHARS)}…`}」×${entry.count}`)
62
62
  .join('、');
63
- return `${head}${where},最高频的是 ${top}。`
63
+ // `top` 只收出现 ≥ `REPEAT_MIN_COUNT` 次的片段,所以它为空**等价于**重复率为 0。
64
+ // `repetitionThreshold: 0` 是合法配置(阈值校验允许 0,等于「任何一步都提醒」),
65
+ // 那条路径下必须省略这一句,否则会拼出「最高频的是 。」这种残句。
66
+ const highlight = top === '' ? '' : `,最高频的是 ${top}`;
67
+ return `${head}${where}${highlight}。`
64
68
  + '重复不等于想得更细,它是原地打转:这些片段没有带来新信息。'
65
69
  + '现在检查手上已有的信息够不够完成任务——够就直接给结论,'
66
70
  + '不够就换一个与前面不同的动作去取,而不是把同一句话再写一遍。';
67
71
  }
72
+ /**
73
+ * 组装空回合的补生成请求。
74
+ *
75
+ * 措辞对着**用户此刻的处境**写:他看到的是空白,所以先说清发生了什么,再给一条可执行的
76
+ * 出路。两条禁令是必要的——不点明「不要重做工具」,模型很可能把整轮动作再跑一遍,而这一轮
77
+ * 的产出已经不是用户缺的东西了。
78
+ * @param turn - 判定的回合号。
79
+ * @param shape - 该回合末条助手消息的产出形态。
80
+ * @returns 一条补生成请求的正文。
81
+ */
82
+ export function silentTurnText(turn, shape) {
83
+ const produced = shape.reasoningChars > 0
84
+ ? `只生成了推理(${shape.reasoningChars} 字),没有文本`
85
+ : '没有产出任何内容';
86
+ const tools = shape.blocks.toolCalls > 0
87
+ ? `,另有 ${shape.blocks.toolCalls} 个工具调用`
88
+ : ',也没有工具调用';
89
+ return `⚠️ 空回合(应变):turn ${turn} 的最后一步${produced}${tools}。`
90
+ + '用户此刻看到的是一串折叠的操作条,然后什么都没有——他不知道这一轮发生了什么。'
91
+ + '用一两句话补上:这一轮得出的结论、以及下一步需要他做什么。'
92
+ + '不要重做已经执行过的工具调用,也不要复述推理里的过程。';
93
+ }
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * 思考重复度判定(退化检测的判据)。
3
3
  *
4
- * 判据 = 单条推理按换行与中英句读切分后,**出现 ≥3 次的单元占全部单元的比例**,
5
- * 并要求连续若干步越线才触发。
4
+ * 判据由两部分构成。
6
5
  *
7
- * 实测依据(2026-09-28,会话 `session-fabc21b2`,19 轮 416 条助手消息):
8
- * 正常期重复率 0%–35%、退化期 48%–92%,隔离带很宽。阈值取 50% 落在这份单会话样本
9
- * 两端的中点附近,**不是标定结果**——它是第一期可用的起点,标定留给数据积累
10
- * (设计方案 §4.2 与 §五)。
6
+ * **单步度量**:按换行与中英句读切分推理,统计「出现 ≥3 次的单元」占比。只统计**实义
7
+ * 单元**——纯标记(代码围栏、花括号、星号)在写代码时天然高频,把它们计入会让正常步越线。
8
+ * 实测(2026-10-01,本机 `sessions-ssid` 根 37 个 ≥3MB 会话):三个会话的越线全部来自这类
9
+ * 标记,排除后归零;而真退化会话的峰值反而更高——噪声单元出局后真实重复的占比更突出。
11
10
  *
12
- * 连续 N 步是必需的:正常期也会出现单次抖动(实测峰值 35%),只按单步触发会让误报
13
- * 跟着抖动走。代价是延迟——按同一份数据回放,触发会落在 t11/s2 而不是 t10/s44,
14
- * 晚约 3 步(中间夹了一个 48%,低于阈值、计数归零)。
11
+ * **触发条件**:最近若干步内**累计**越线次数,不要求连续。原判据要求连续 2 步,其依据是
12
+ * 单个会话(`fabc21b2`,本机最严重的样本)里退化在同一 turn 内连成片;换成散布型退化时
13
+ * 判据失效——`a8ac8e89` 有 17 次语义越线却凑不出一次连续 2 步,提醒因此几乎不被发出
14
+ * (该会话只触发 1 次、落在 t38;另有 `8fa3b15e` 与 `cb56f381` 两个真退化会话触发 0 次)。
15
+ * 窗口累计在同一批样本上让两个零触发的会话开始触发,而三个假阳性会话(语义口径下越线为
16
+ * 零)在任何窗口下都不触发。窗口**跨 turn** 累计:散布正是跨 turn 的,按 turn 清零会把
17
+ * 它们重新拆散。
15
18
  *
16
- * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600
17
- * 字符),长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
19
+ * `insufficient`(单元数不足)既不记命中也不记未命中:让「推理偶尔写得很短」占位会稀释
20
+ * 窗口、反复推迟触发。
18
21
  *
19
- * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四
22
+ * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600 字符),
23
+ * 长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
24
+ *
25
+ * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四、
26
+ * `docs/设计/2026-10-01-提醒判据修正与实测方案.md`
20
27
  * @module @max-null/dsh-allostasis/repetition
21
28
  */
22
29
  /** 单元重复达到此次数才计入「重复单元」。 */
@@ -25,11 +32,13 @@ export declare const REPEAT_MIN_COUNT = 3;
25
32
  export declare const MIN_UNITS = 12;
26
33
  /** 重复率判定阈值——起点值,非标定值。 */
27
34
  export declare const REPETITION_THRESHOLD = 0.5;
28
- /** 触发所需的连续越线步数。 */
29
- export declare const CONSECUTIVE_STEPS = 2;
35
+ /** 触发所需的观察窗口,以步为单位(默认 5)。 */
36
+ export declare const LOOP_WINDOW_STEPS = 5;
37
+ /** 窗口内需要累计的越线步数(默认 2)。取 3 会漏掉「少而猛」型退化。 */
38
+ export declare const LOOP_WINDOW_HITS = 2;
30
39
  /** 一条思考的重复度量化结果。 */
31
40
  export interface RepetitionMetrics {
32
- /** 切分后的单元总数(去空白后)。 */
41
+ /** 计入统计的实义单元总数。 */
33
42
  units: number;
34
43
  /** 重复单元的**总出现次数**(同一单元出现 5 次计 5,不是计 1)。 */
35
44
  repeated: number;
@@ -46,8 +55,9 @@ export type RepetitionVerdict = 'loop' | 'normal' | 'insufficient';
46
55
  /**
47
56
  * 统计一条思考文本的重复度。空文本返回全零。
48
57
  *
49
- * 单元按 `UNIT_SEPARATOR` 切分并去掉空白,因此纯空行的段落不参与统计。
50
- * `top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序稳定排序。
58
+ * 单元按 `UNIT_SEPARATOR` 切分、去掉空白、滤掉非实义单元,因此纯空行的段落与代码标记
59
+ * 都不参与统计。`top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序
60
+ * 稳定排序。
51
61
  * @param text - 推理原文。
52
62
  * @returns 量化结果。
53
63
  */
@@ -55,37 +65,33 @@ export declare function measureRepetition(text: string): RepetitionMetrics;
55
65
  /**
56
66
  * 对一次测量下判定。
57
67
  *
58
- * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步
59
- * 既不算越线也不算清白(见 `trackLoop`)。
68
+ * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步既不算
69
+ * 越线也不算清白(见 `trackLoop`)。
60
70
  * @param metrics - 量化结果。
61
71
  * @param threshold - 重复率阈值;缺省用 {@link REPETITION_THRESHOLD}。
62
72
  * @returns 三态判定。
63
73
  */
64
74
  export declare function repetitionVerdict(metrics: RepetitionMetrics, threshold?: number): RepetitionVerdict;
65
- /** 连续越线的追踪状态。不可变——每次推进都返回一份新状态。 */
75
+ /** 越线窗口的追踪状态。不可变——每次推进都返回一份新状态。 */
66
76
  export interface LoopTrackerState {
67
- /** 当前已连续越线的步数。 */
68
- consecutive: number;
69
- /** 最近一次判定的 turn,用于识别「换了一轮」。 */
70
- lastTurn: number;
77
+ /** 最近若干步的越线标记,最近的在后;长度不超过窗口步数。 */
78
+ readonly recent: readonly boolean[];
71
79
  }
72
80
  /**
73
- * 推进连续越线计数,并判定本次是否触发。
74
- *
75
- * **状态不跨 turn 延续**:新一轮的第一条推理重新起算。turn 是用户可感知的边界,
76
- * 而且实测里退化在同一个 turn 内就已连成片(t10 的 44%–64% 全在一轮内),跨轮累加
77
- * 只会让触发更晚。
81
+ * 推进越线窗口,并判定本次是否触发。
78
82
  *
79
- * `insufficient` 的样本**保持计数不变**:它既不是越线也不是清白,算作清零会让
80
- * 「推理偶尔写得很短」反复推迟触发。
83
+ * 窗口**不按 turn 清零**:退化样本常常散布在若干轮里,按轮清零会把它们重新拆散——这正是
84
+ * 原「连续 2 步」判据在 `a8ac8e89` 上失效的原因。
81
85
  *
86
+ * `insufficient` 的样本**既不记命中也不记未命中**:让它占位会稀释窗口。代价是窗口可能
87
+ * 跨过多个步才填满,但触发条件本身是「累计」而非「密度」,不受影响。
82
88
  * @param state - 上一次的状态;首次调用传 `undefined`。
83
- * @param turn - 产出该思考的 turn。
84
89
  * @param verdict - 该步的判定。
85
- * @param required - 触发所需的连续越线步数;缺省用 {@link CONSECUTIVE_STEPS}。
90
+ * @param windowSteps - 窗口大小,以步为单位;缺省用 {@link LOOP_WINDOW_STEPS}。
91
+ * @param windowHits - 触发所需的窗口内越线步数;缺省用 {@link LOOP_WINDOW_HITS}。
86
92
  * @returns `state` 为推进后的新状态;`fire` 为本次是否达到触发条件。
87
93
  */
88
- export declare function trackLoop(state: LoopTrackerState | undefined, turn: number, verdict: RepetitionVerdict, required?: number): {
94
+ export declare function trackLoop(state: LoopTrackerState | undefined, verdict: RepetitionVerdict, windowSteps?: number, windowHits?: number): {
89
95
  state: LoopTrackerState;
90
96
  fire: boolean;
91
97
  };
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * 思考重复度判定(退化检测的判据)。
3
3
  *
4
- * 判据 = 单条推理按换行与中英句读切分后,**出现 ≥3 次的单元占全部单元的比例**,
5
- * 并要求连续若干步越线才触发。
4
+ * 判据由两部分构成。
6
5
  *
7
- * 实测依据(2026-09-28,会话 `session-fabc21b2`,19 轮 416 条助手消息):
8
- * 正常期重复率 0%–35%、退化期 48%–92%,隔离带很宽。阈值取 50% 落在这份单会话样本
9
- * 两端的中点附近,**不是标定结果**——它是第一期可用的起点,标定留给数据积累
10
- * (设计方案 §4.2 与 §五)。
6
+ * **单步度量**:按换行与中英句读切分推理,统计「出现 ≥3 次的单元」占比。只统计**实义
7
+ * 单元**——纯标记(代码围栏、花括号、星号)在写代码时天然高频,把它们计入会让正常步越线。
8
+ * 实测(2026-10-01,本机 `sessions-ssid` 根 37 个 ≥3MB 会话):三个会话的越线全部来自这类
9
+ * 标记,排除后归零;而真退化会话的峰值反而更高——噪声单元出局后真实重复的占比更突出。
11
10
  *
12
- * 连续 N 步是必需的:正常期也会出现单次抖动(实测峰值 35%),只按单步触发会让误报
13
- * 跟着抖动走。代价是延迟——按同一份数据回放,触发会落在 t11/s2 而不是 t10/s44,
14
- * 晚约 3 步(中间夹了一个 48%,低于阈值、计数归零)。
11
+ * **触发条件**:最近若干步内**累计**越线次数,不要求连续。原判据要求连续 2 步,其依据是
12
+ * 单个会话(`fabc21b2`,本机最严重的样本)里退化在同一 turn 内连成片;换成散布型退化时
13
+ * 判据失效——`a8ac8e89` 有 17 次语义越线却凑不出一次连续 2 步,提醒因此几乎不被发出
14
+ * (该会话只触发 1 次、落在 t38;另有 `8fa3b15e` 与 `cb56f381` 两个真退化会话触发 0 次)。
15
+ * 窗口累计在同一批样本上让两个零触发的会话开始触发,而三个假阳性会话(语义口径下越线为
16
+ * 零)在任何窗口下都不触发。窗口**跨 turn** 累计:散布正是跨 turn 的,按 turn 清零会把
17
+ * 它们重新拆散。
15
18
  *
16
- * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600
17
- * 字符),长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
19
+ * `insufficient`(单元数不足)既不记命中也不记未命中:让「推理偶尔写得很短」占位会稀释
20
+ * 窗口、反复推迟触发。
18
21
  *
19
- * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四
22
+ * 为什么不用推理长度当辅助判据:退化期的单条推理并不特别长(实测 1,000–2,600 字符),
23
+ * 长度不区分两群;重复率本身已经把「这段内容有多少信息」量化了。
24
+ *
25
+ * 设计出处:`docs/设计/2026-09-28-应变二期-退化检测与自动干预.md` §四、
26
+ * `docs/设计/2026-10-01-提醒判据修正与实测方案.md`
20
27
  * @module @max-null/dsh-allostasis/repetition
21
28
  */
22
29
  /** 单元重复达到此次数才计入「重复单元」。 */
@@ -25,22 +32,39 @@ export const REPEAT_MIN_COUNT = 3;
25
32
  export const MIN_UNITS = 12;
26
33
  /** 重复率判定阈值——起点值,非标定值。 */
27
34
  export const REPETITION_THRESHOLD = 0.5;
28
- /** 触发所需的连续越线步数。 */
29
- export const CONSECUTIVE_STEPS = 2;
35
+ /** 触发所需的观察窗口,以步为单位(默认 5)。 */
36
+ export const LOOP_WINDOW_STEPS = 5;
37
+ /** 窗口内需要累计的越线步数(默认 2)。取 3 会漏掉「少而猛」型退化。 */
38
+ export const LOOP_WINDOW_HITS = 2;
30
39
  /** 切分单元用的分隔符:换行与中英句读。 */
31
40
  const UNIT_SEPARATOR = /[\n。!?]/;
41
+ /**
42
+ * 一个单元是否计入重复统计。
43
+ *
44
+ * 排除两类:以代码围栏开头的整段(` ``` ` 与 ` ```ts `),以及不含实义文字的纯标记单元。
45
+ * 实义 = 至少一个汉字,或至少两个连续拉丁字母——单个字母会让 `}`、`*`、`|` 这类符号的
46
+ * 邻接字母混进来。
47
+ * @param unit - 切分并去空白后的单元。
48
+ * @returns 该单元是否计入。
49
+ */
50
+ function isSemanticUnit(unit) {
51
+ if (unit.startsWith('```'))
52
+ return false;
53
+ return /[\p{Script=Han}]|[A-Za-z]{2}/u.test(unit);
54
+ }
32
55
  /**
33
56
  * 统计一条思考文本的重复度。空文本返回全零。
34
57
  *
35
- * 单元按 `UNIT_SEPARATOR` 切分并去掉空白,因此纯空行的段落不参与统计。
36
- * `top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序稳定排序。
58
+ * 单元按 `UNIT_SEPARATOR` 切分、去掉空白、滤掉非实义单元,因此纯空行的段落与代码标记
59
+ * 都不参与统计。`top` 只收达到 `REPEAT_MIN_COUNT` 的单元,最多 5 条,同次数按单元字典序
60
+ * 稳定排序。
37
61
  * @param text - 推理原文。
38
62
  * @returns 量化结果。
39
63
  */
40
64
  export function measureRepetition(text) {
41
65
  const units = text.split(UNIT_SEPARATOR)
42
66
  .map(part => part.trim())
43
- .filter(part => part !== '');
67
+ .filter(part => part !== '' && isSemanticUnit(part));
44
68
  const counts = new Map();
45
69
  for (const unit of units)
46
70
  counts.set(unit, (counts.get(unit) ?? 0) + 1);
@@ -63,8 +87,8 @@ export function measureRepetition(text) {
63
87
  /**
64
88
  * 对一次测量下判定。
65
89
  *
66
- * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步
67
- * 既不算越线也不算清白(见 `trackLoop`)。
90
+ * 单元数不足 `MIN_UNITS` 时返回 `insufficient`——**不下结论**。调用方据此决定该步既不算
91
+ * 越线也不算清白(见 `trackLoop`)。
68
92
  * @param metrics - 量化结果。
69
93
  * @param threshold - 重复率阈值;缺省用 {@link REPETITION_THRESHOLD}。
70
94
  * @returns 三态判定。
@@ -75,26 +99,24 @@ export function repetitionVerdict(metrics, threshold = REPETITION_THRESHOLD) {
75
99
  return metrics.ratio >= threshold ? 'loop' : 'normal';
76
100
  }
77
101
  /**
78
- * 推进连续越线计数,并判定本次是否触发。
79
- *
80
- * **状态不跨 turn 延续**:新一轮的第一条推理重新起算。turn 是用户可感知的边界,
81
- * 而且实测里退化在同一个 turn 内就已连成片(t10 的 44%–64% 全在一轮内),跨轮累加
82
- * 只会让触发更晚。
102
+ * 推进越线窗口,并判定本次是否触发。
83
103
  *
84
- * `insufficient` 的样本**保持计数不变**:它既不是越线也不是清白,算作清零会让
85
- * 「推理偶尔写得很短」反复推迟触发。
104
+ * 窗口**不按 turn 清零**:退化样本常常散布在若干轮里,按轮清零会把它们重新拆散——这正是
105
+ * 原「连续 2 步」判据在 `a8ac8e89` 上失效的原因。
86
106
  *
107
+ * `insufficient` 的样本**既不记命中也不记未命中**:让它占位会稀释窗口。代价是窗口可能
108
+ * 跨过多个步才填满,但触发条件本身是「累计」而非「密度」,不受影响。
87
109
  * @param state - 上一次的状态;首次调用传 `undefined`。
88
- * @param turn - 产出该思考的 turn。
89
110
  * @param verdict - 该步的判定。
90
- * @param required - 触发所需的连续越线步数;缺省用 {@link CONSECUTIVE_STEPS}。
111
+ * @param windowSteps - 窗口大小,以步为单位;缺省用 {@link LOOP_WINDOW_STEPS}。
112
+ * @param windowHits - 触发所需的窗口内越线步数;缺省用 {@link LOOP_WINDOW_HITS}。
91
113
  * @returns `state` 为推进后的新状态;`fire` 为本次是否达到触发条件。
92
114
  */
93
- export function trackLoop(state, turn, verdict, required = CONSECUTIVE_STEPS) {
94
- const current = state ?? { consecutive: 0, lastTurn: Number.NaN };
95
- const base = current.lastTurn === turn ? current.consecutive : 0;
115
+ export function trackLoop(state, verdict, windowSteps = LOOP_WINDOW_STEPS, windowHits = LOOP_WINDOW_HITS) {
116
+ const current = state ?? { recent: [] };
96
117
  if (verdict === 'insufficient')
97
- return { state: { consecutive: base, lastTurn: turn }, fire: false };
98
- const consecutive = verdict === 'loop' ? base + 1 : 0;
99
- return { state: { consecutive, lastTurn: turn }, fire: consecutive >= required };
118
+ return { state: current, fire: false };
119
+ const recent = [...current.recent, verdict === 'loop'].slice(-windowSteps);
120
+ const hits = recent.filter(Boolean).length;
121
+ return { state: { recent }, fire: hits >= windowHits };
100
122
  }
package/dist/tail.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 回合末步的产出形态判定(空回合检测的判据)。
3
+ *
4
+ * 落点:`assistant/message` 事件的 `message.content` —— 与 `thinking.ts` 读同一个事件的
5
+ * 另一个字段(那边读 `stream` 里的 `reasoning-chunks`,这里读 `content` 的块构成)。
6
+ *
7
+ * 判据 = **本 turn 最后一条助手消息里没有非空 text 块**。
8
+ *
9
+ * 实测依据(2026-09-30,14 天内 1202 个 `completed` 回合):完成态结尾沉默 34 轮
10
+ * (2.8%),其中 **32 轮的末步只有 `reasoning`** —— 判据纯度 94%,误报面小。
11
+ * `aborted` / `interrupted` 的沉默另有 15 轮,但那是用户中止的预期结果,**不在判据内**:
12
+ * 调用方按 `signal.aborted` 排除,见 `index.ts` 的 turn-stopping 监听。
13
+ *
14
+ * 为什么读 `content` 而不是 `stream`:`stream` 里的 `text-chunks` 回答「模型有没有生成过
15
+ * 文本」,`content` 回答「这一步最终产出了什么」。判据要的是后者,前者只在排查时用来
16
+ * 交叉验证(设计文档 §2.2)。
17
+ *
18
+ * 设计出处:`docs/设计/2026-09-30-空回合检测与可见化.md` §二
19
+ * @module @max-null/dsh-allostasis/tail
20
+ */
21
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
22
+ /** 空回合兜底的档位:`off` 不判定,`observe` 判定并留痕,`steer` 额外补一次生成。 */
23
+ export type SilentTurnMode = 'off' | 'observe' | 'steer';
24
+ /** 全部档位,供配置校验枚举。 */
25
+ export declare const SILENT_TURN_MODES: readonly SilentTurnMode[];
26
+ /**
27
+ * 空回合兜底的档位缺省值。
28
+ *
29
+ * `observe` 而不是 `off`:判定与留痕必须先跑起来,否则「这个现象有多频繁、判据准不准」
30
+ * 永远没有数据。补生成(`steer`)要等到有数据说明它值得开之后再加。
31
+ */
32
+ export declare const SILENT_TURN_MODE: SilentTurnMode;
33
+ /** 判定只需要块的类型与文本;用最小形状接收,判据模块因此不依赖会话包的内容类型。 */
34
+ export interface TailBlock {
35
+ /** 块类型:`reasoning` / `text` / `tool-call`。 */
36
+ readonly type: string;
37
+ /** 文本与推理块的正文;工具调用块没有这个字段。 */
38
+ readonly text?: string;
39
+ }
40
+ /** 一条助手消息的产出形态。 */
41
+ export interface TailShape {
42
+ /** 该消息所属的 turn。 */
43
+ readonly turn: number;
44
+ /** 该消息所属的 step。 */
45
+ readonly step: number;
46
+ /** 非空 `text` 块的总字符数。空回合恒为 0。 */
47
+ readonly textChars: number;
48
+ /** `reasoning` 块的总字符数——区分「想了很久没说」与「完全没输出」。 */
49
+ readonly reasoningChars: number;
50
+ /** 按类型的块计数。 */
51
+ readonly blocks: {
52
+ readonly reasoning: number;
53
+ readonly text: number;
54
+ readonly toolCalls: number;
55
+ };
56
+ }
57
+ /**
58
+ * 三态判定。`unknown` 用于「本回合没有助手消息」——比如用户消息刚落下、助手还没产出,
59
+ * 此时既不能说它说了话,也不能说它是空回合。
60
+ */
61
+ export type TailVerdict = 'spoke' | 'silent' | 'unknown';
62
+ /**
63
+ * 统计一条助手消息的块构成。
64
+ *
65
+ * `text` 按去掉首尾空白后的长度计入:只含空白的文本块在界面上不产生任何可见内容,
66
+ * 把它算作「说了话」会让判据漏掉真实的空回合。
67
+ * @param turn - 该消息所属的 turn。
68
+ * @param step - 该消息所属的 step。
69
+ * @param content - 助手消息的内容块,按出现顺序。
70
+ * @returns 该消息的产出形态。
71
+ */
72
+ export declare function shapeOf(turn: number, step: number, content: readonly TailBlock[]): TailShape;
73
+ /** 从会话事件里取出的助手消息样本:判定只读这三个字段。 */
74
+ export interface TailSample {
75
+ /** 该消息所属的 turn。 */
76
+ readonly turn: number;
77
+ /** 该消息所属的 step。 */
78
+ readonly step: number;
79
+ /** 内容块,按出现顺序。 */
80
+ readonly content: readonly TailBlock[];
81
+ }
82
+ /**
83
+ * 把会话事件收窄成判定样本。
84
+ *
85
+ * 与 {@link measureTail} 分成两步,是为了让判定完全落在纯函数上:`SessionEvent` 是
86
+ * 所有事件的联合,拿它当参数就得在测试里伪造整个事件(含 `usage` / `stream`),
87
+ * 或者用类型断言把缺口盖掉。收窄只在这里做一次。
88
+ * @param events - 会话事件,按 seq 升序。
89
+ * @returns 助手消息样本,保持原顺序;其它类型的事件被跳过。
90
+ */
91
+ export declare function tailSamples(events: readonly SessionEvent[]): TailSample[];
92
+ /**
93
+ * 取本 turn **最后一条**助手消息的产出形态。
94
+ *
95
+ * 倒扫而不是取「最后一条样本」:会话里可能夹着别的 turn 的助手消息(中断后的残留、
96
+ * 子代理的投递),而判定要问的是「这一轮结束时用户看到了什么」。
97
+ * @param samples - {@link tailSamples} 的产出。
98
+ * @param turn - 要检查的 turn。
99
+ * @returns 末条助手消息的形态;该 turn 没有助手消息时返回 `undefined`。
100
+ */
101
+ export declare function measureTail(samples: readonly TailSample[], turn: number): TailShape | undefined;
102
+ /**
103
+ * 对一次测量下判定。
104
+ *
105
+ * 只有「有助手消息且它没有非空文本」才算空回合。工具调用**不**改变判定:一个以
106
+ * `tool-call` 结尾的完成态回合同样是异常(工具执行完还会再走一步,除非回合已经结束)。
107
+ * @param shape - 末条助手消息的形态;没有助手消息时传 `undefined`。
108
+ * @returns 三态判定。
109
+ */
110
+ export declare function tailVerdict(shape: TailShape | undefined): TailVerdict;