dsh-session-bridge 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "会话桥(dsh-session-bridge):通过提示词创建新的主会话(顶层 UI 会话)、向任意会话发送消息、等待会话的下一条回复、读取会话消息,并按会话名或 id 跨工作区查找会话;此外支持监控/调度主任务与归档会话。",
5
5
  "private": false,
6
6
  "type": "module",
package/src/core.ts CHANGED
@@ -60,6 +60,32 @@ export interface BridgeFindItem {
60
60
  agentPreset?: string
61
61
  }
62
62
 
63
+ /**
64
+ * 兼容读取不同 dsh-session 版本上的会话事件:
65
+ * - 旧版 `Session` 暴露 `get events(): readonly SessionEvent[]`;
66
+ * - 新版(如宿主实际运行的 0.1.2-rc.x)将 `get events` 改为
67
+ * `snapshotEvents(fromSeq?, toSeqExclusive?)` 方法——`.events` 直接读取会
68
+ * 得到 `undefined`,对 for...of 迭代即抛 `events is not iterable`。
69
+ * 两者都读不到(或 session 不存在)时回退为空数组,绝不抛迭代错误。
70
+ */
71
+ export function sessionEvents(session: unknown): readonly SessionEvent[] {
72
+ const s = session as {
73
+ events?: readonly SessionEvent[]
74
+ snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly SessionEvent[]
75
+ } | null | undefined
76
+ if (s === null || s === undefined) return []
77
+ if (Array.isArray(s.events)) return s.events
78
+ if (typeof s.snapshotEvents === 'function') {
79
+ try { return s.snapshotEvents() } catch { return [] }
80
+ }
81
+ return []
82
+ }
83
+
84
+ /** 规约任意"事件列表"值为数组(非数组 → []),避免 events is not iterable。 */
85
+ export function asEventList(events: unknown): readonly SessionEvent[] {
86
+ return Array.isArray(events) ? events : []
87
+ }
88
+
63
89
  /** agent.followup / steer 接受的用户消息值。 */
64
90
  export function userMessage(text: string): unknown {
65
91
  return {
@@ -82,6 +108,19 @@ function blockText(content: unknown): string | undefined {
82
108
  return parts.length === 0 ? undefined : parts.join('\n')
83
109
  }
84
110
 
111
+ /** 提取指定类型的 content block 文本(如 reasoning),用于从消息 content 里读出思维链。 */
112
+ function blockTextByType(content: unknown, wantedType: string): string | undefined {
113
+ if (!Array.isArray(content)) return undefined
114
+ const parts: string[] = []
115
+ for (const raw of content) {
116
+ const block = raw as { type?: unknown; text?: unknown } | null
117
+ if (block !== null && typeof block === 'object' && block.type === wantedType && typeof block.text === 'string') {
118
+ if (block.text !== '') parts.push(block.text)
119
+ }
120
+ }
121
+ return parts.length === 0 ? undefined : parts.join('\n')
122
+ }
123
+
85
124
  function countImages(content: unknown): number {
86
125
  if (!Array.isArray(content)) return 0
87
126
  let n = 0
@@ -104,8 +143,9 @@ function toolNamesOf(toolCalls: unknown): string[] {
104
143
 
105
144
  /** 折叠会话事件日志为可读消息行(跳过非 user 来源与空消息)。 */
106
145
  export function foldMessages(events: readonly SessionEvent[]): BridgeMessageRow[] {
146
+ const list = asEventList(events)
107
147
  const rows: BridgeMessageRow[] = []
108
- for (const event of events) {
148
+ for (const event of list) {
109
149
  if (event.type === 'user/message') {
110
150
  const source = (event.data as { source?: { kind?: string } }).source
111
151
  if (source !== undefined && source.kind !== 'user') continue
@@ -115,7 +155,9 @@ export function foldMessages(events: readonly SessionEvent[]): BridgeMessageRow[
115
155
  } else if (event.type === 'assistant/message') {
116
156
  const message = event.data.message as { content?: unknown; reasoning?: unknown; toolCalls?: unknown } | null
117
157
  const text = message === null ? undefined : blockText(message.content)
118
- const reasoning = message === null ? undefined : blockText(message.reasoning)
158
+ // 思维链是 content type:'reasoning' 的块;老版本曾把取舍放在 message.reasoning 字段,
159
+ // 两者都读,优先 content 块(当前 dsh-llm 的 AssistantMessage 无独立 reasoning 字段)。
160
+ const reasoning = message === null ? undefined : (blockTextByType(message.content, 'reasoning') ?? blockText(message.reasoning))
119
161
  const images = message === null ? 0 : countImages(message.content)
120
162
  const toolCalls = message === null ? [] : toolNamesOf(message.toolCalls)
121
163
  if (text === undefined && reasoning === undefined && toolCalls.length === 0) continue
@@ -129,13 +171,141 @@ export function foldMessages(events: readonly SessionEvent[]): BridgeMessageRow[
129
171
  return rows
130
172
  }
131
173
 
174
+ /** 一个已完成的输出段落:一次 assistant step 定型的 assistant/message(turn 中途即可读,无需等整个 turn)。 */
175
+ export interface BridgeSegment {
176
+ seq: number
177
+ time: number
178
+ turn: number
179
+ step: number
180
+ text?: string
181
+ reasoning?: string
182
+ toolCalls: string[]
183
+ openTurn: boolean
184
+ }
185
+
186
+ /** 按已完成输出段落折叠事件日志:每个 assistant/message 割一段,返回 sinceSeq 后的所有段落(事件序)。 */
187
+ export function segmentsSince(events: readonly SessionEvent[], sinceSeq = 0): BridgeSegment[] {
188
+ const list = asEventList(events)
189
+ const out: BridgeSegment[] = []
190
+ let openTurn = false
191
+ for (const event of list) {
192
+ if (event.type === 'turn/start') { openTurn = true; continue }
193
+ if (event.type === 'turn/end') { openTurn = false; continue }
194
+ if (event.type !== 'assistant/message') continue
195
+ if (event.seq <= sinceSeq) continue
196
+ const message = event.data.message as { content?: unknown; reasoning?: unknown; toolCalls?: unknown } | null
197
+ const text = message === null ? undefined : blockText(message.content)
198
+ const reasoning = message === null ? undefined : (blockTextByType(message.content, 'reasoning') ?? blockText(message.reasoning))
199
+ const toolCalls = message === null ? [] : toolNamesOf(message.toolCalls)
200
+ const turn = (event.data as { turn?: unknown })?.turn
201
+ const step = (event.data as { step?: unknown })?.step
202
+ const seg: BridgeSegment = {
203
+ seq: event.seq,
204
+ time: event.time,
205
+ turn: typeof turn === 'number' ? turn : 0,
206
+ step: typeof step === 'number' ? step : 0,
207
+ toolCalls,
208
+ openTurn,
209
+ }
210
+ if (text !== undefined) seg.text = text
211
+ if (reasoning !== undefined) seg.reasoning = reasoning
212
+ out.push(seg)
213
+ }
214
+ return out
215
+ }
216
+
217
+ /** 取自 sinceSeq 之后最新一个已完成输出段落(无则 null)。 */
218
+ export function latestSegmentSince(events: readonly SessionEvent[], sinceSeq = 0): BridgeSegment | null {
219
+ const segs = segmentsSince(events, sinceSeq)
220
+ return segs.length === 0 ? null : (segs[segs.length - 1] ?? null)
221
+ }
222
+
223
+ /** 实时思维链切片:一个 live 会话当前"进行中"的增量推理与文本。 */
224
+ export interface CoTLiveSlice {
225
+ /** 产生该推理的 turn。 */
226
+ turn: number
227
+ /** 产生该推理的 step。 */
228
+ step: number
229
+ /** 自最近一条已定型 assistant 消息以来累积的进行中推理文本(reasoning-delta 拼接)。 */
230
+ reasoning: string
231
+ /** 同窗口累积的进行中文本增量(text-delta 拼接)。 */
232
+ text: string
233
+ /** 本切片覆盖到的最大事件 seq(供增量轮询记住游标)。 */
234
+ seq: number
235
+ /** 最近一次事件时间戳。 */
236
+ time: number
237
+ }
238
+
239
+ /**
240
+ * 对一个 live 会话的事件日志折叠出其"实时思维链":
241
+ * - 优先聚合流式 assistant/chunk 事件里的 reasoning-delta(turn 中途即可见、增量,
242
+ * 这正是监控思维链并按规则提前终止所需的粒度);
243
+ * - 同时聚合同窗口的 text-delta 增量;
244
+ * - 若没有进行中的 chunk(会话空闲、或该 provider 不流式推理/无推理),返回 null,
245
+ * 调用方应回落为 foldMessages 里的已定型 message.reasoning。
246
+ * 纯读、无副作用;事件形状不做任何假设,缺失/异常一律安全处理。
247
+ */
248
+ export function liveReasoningSnapshot(events: readonly SessionEvent[]): CoTLiveSlice | null {
249
+ const list = asEventList(events)
250
+ // 只累积"最近一条已定型 assistant/message 之后"的流式增量(当前 in-flight 窗口),
251
+ // 避免把历史每一轮的思维链都拼接进来。turn/step 记录窗口内最近的 chunk 归属。
252
+ let lastFinalizedSeq = -1
253
+ let reasoning = ''
254
+ let text = ''
255
+ let seq = -1
256
+ let time = 0
257
+ let turn = 0
258
+ let step = 0
259
+ let foundDelta = false
260
+ for (const event of list) {
261
+ if (event.type === 'assistant/message') {
262
+ lastFinalizedSeq = event.seq
263
+ continue
264
+ }
265
+ if (event.type !== 'assistant/chunk') continue
266
+ if (event.seq <= lastFinalizedSeq) continue
267
+ const data = event.data as { turn?: unknown; step?: unknown; chunk?: unknown } | null | undefined
268
+ if (data === null || data === undefined || typeof data !== 'object') continue
269
+ const c = data.chunk as { type?: unknown; text?: unknown } | null
270
+ if (c === null || typeof c !== 'object' || typeof c.type !== 'string') continue
271
+ if (c.type === 'reasoning-delta' || c.type === 'text-delta') {
272
+ if (typeof c.text !== 'string') continue
273
+ if (c.type === 'reasoning-delta') reasoning += c.text
274
+ else text += c.text
275
+ if (typeof data.turn === 'number') turn = data.turn
276
+ if (typeof data.step === 'number') step = data.step
277
+ foundDelta = true
278
+ if (event.seq > seq) seq = event.seq
279
+ if (event.time > time) time = event.time
280
+ }
281
+ }
282
+ if (!foundDelta) return null
283
+ return { turn, step, reasoning, text, seq, time }
284
+ }
285
+
286
+ /** 思维链尾巴默认截断上限(字符)。 */
287
+ export const REASONING_TAIL_LIMIT = 4000
288
+
289
+ /**
290
+ * 组装一条受字符上限约束的"思维链尾巴"用于展示/摘要:
291
+ * 优先进行中的 liveReasoning,否则最近已定型的 lastReasoning;
292
+ * 超出上限时截断并附省略标记。两者皆无返回 undefined。
293
+ */
294
+ export function reasoningTailOf(liveReasoning: string | undefined, lastReasoning: string | undefined, limit = REASONING_TAIL_LIMIT): string | undefined {
295
+ const source = liveReasoning ?? lastReasoning
296
+ if (source === undefined || source === '') return undefined
297
+ if (source.length <= limit) return source
298
+ return source.slice(0, limit) + '…'
299
+ }
300
+
132
301
  /**
133
302
  * 会话标题:优先 session/title 事件(last-wins,与 UI 一致);
134
303
  * 无标题事件时回落为第一条用户消息(截断 80 字符)。
135
304
  */
136
305
  export function titleOf(events: readonly SessionEvent[]): string | undefined {
306
+ const list = asEventList(events)
137
307
  let title: string | undefined
138
- for (const event of events) {
308
+ for (const event of list) {
139
309
  const raw = event as unknown as { type: string; data: { title?: unknown } }
140
310
  if (raw.type === 'session/title') {
141
311
  const t = raw.data.title
@@ -143,7 +313,7 @@ export function titleOf(events: readonly SessionEvent[]): string | undefined {
143
313
  }
144
314
  }
145
315
  if (title !== undefined) return title
146
- for (const event of events) {
316
+ for (const event of list) {
147
317
  if (event.type === 'user/message') {
148
318
  const source = (event.data as { source?: { kind?: string } }).source
149
319
  if (source !== undefined && source.kind !== 'user') continue
@@ -159,8 +329,9 @@ export function titleOf(events: readonly SessionEvent[]): string | undefined {
159
329
 
160
330
  /** 日志最大事件序号。 */
161
331
  export function maxSeq(events: readonly SessionEvent[]): number {
332
+ const list = asEventList(events)
162
333
  let m = -1
163
- for (const event of events) if (event.seq > m) m = event.seq
334
+ for (const event of list) if (event.seq > m) m = event.seq
164
335
  return m
165
336
  }
166
337
 
@@ -174,6 +345,8 @@ export interface WaitForReplyOptions {
174
345
  timeoutMs: number
175
346
  signal?: AbortSignal
176
347
  requireTurnEnd?: boolean
348
+ /** true 时等待到 baseline 之后出现一个“新完成的输出段落”(assistant/message),立即返回该段,不等整个 turn。 */
349
+ waitForSegment?: boolean
177
350
  }
178
351
 
179
352
  /**
@@ -192,33 +365,48 @@ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWai
192
365
  const started = Date.now()
193
366
  const deadline = started + opts.timeoutMs
194
367
  const requireTurnEnd = opts.requireTurnEnd ?? false
368
+ const waitForSegment = opts.waitForSegment ?? false
195
369
  let latest: BridgeMessageRow | null = null
196
370
  let textReply: BridgeMessageRow | null = null
371
+ let segment: BridgeSegment | null = null
197
372
  let turnEnded = false
198
373
  for (;;) {
199
374
  if (opts.signal !== undefined && opts.signal.aborted) break
200
- const events = opts.session.events
375
+ const events = sessionEvents(opts.session)
201
376
  for (const row of foldMessages(events)) {
202
377
  if (row.seq <= opts.baselineSeq || row.role !== 'assistant') continue
203
378
  if (latest === null || row.seq > latest.seq) latest = row
204
379
  if (row.text !== undefined && (textReply === null || row.seq > textReply.seq)) textReply = row
205
380
  }
381
+ if (waitForSegment) {
382
+ const seg = latestSegmentSince(events, opts.baselineSeq)
383
+ if (seg !== null && (segment === null || seg.seq > segment.seq)) segment = seg
384
+ }
206
385
  if (requireTurnEnd && latest !== null) {
207
386
  for (const event of events) {
208
387
  if (event.seq > latest.seq && event.type === 'turn/end') { turnEnded = true; break }
209
388
  }
210
389
  }
211
- const done = requireTurnEnd ? (latest !== null && turnEnded) : textReply !== null
390
+ let done: boolean
391
+ if (requireTurnEnd) done = latest !== null && turnEnded
392
+ else if (waitForSegment) done = segment !== null
393
+ else done = textReply !== null
212
394
  if (done) break
213
395
  if (Date.now() >= deadline) break
214
396
  await sleep(100)
215
397
  }
216
- const message = textReply ?? latest
398
+ // 段落模式返回该段(把 reasoning 并进返回行,便于“按段落读思维链”);否则返回最新文本行。
399
+ let message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
400
+ seq: segment.seq, time: segment.time, role: 'assistant', images: 0,
401
+ ...(segment.text !== undefined ? { text: segment.text } : {}),
402
+ ...(segment.reasoning !== undefined ? { reasoning: segment.reasoning } : {}),
403
+ ...(segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}),
404
+ } : (textReply ?? latest)
217
405
  return {
218
406
  message,
219
407
  seq: message === null ? opts.baselineSeq : message.seq,
220
408
  turnEnded,
221
- timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : textReply === null,
409
+ timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : waitForSegment ? segment === null : textReply === null,
222
410
  aborted: opts.signal !== undefined && opts.signal.aborted,
223
411
  waitedMs: Date.now() - started,
224
412
  }
@@ -313,6 +501,12 @@ export interface BridgeStatusSnapshot {
313
501
  nextStepCount: number
314
502
  /** 最新一条带文本的 assistant 回复。 */
315
503
  lastAssistantText?: string
504
+ /** 最新一条已定型 assistant 消息的推理(reasoning content block)。 */
505
+ lastReasoning?: string
506
+ /** 进行中的实时思维链增量(reasoning-delta 拼接;无进行中流时为 undefined)。 */
507
+ liveReasoning?: string
508
+ /** 极紧凑的思维链尾巴:liveReasoning 优先、否则 lastReasoning,截断至 reasoningTailLimit。 */
509
+ reasoningTail?: string
316
510
  /** 折叠后的消息总数。 */
317
511
  messageCount: number
318
512
  /** 最近几条消息(默认 8)。 */
@@ -324,7 +518,7 @@ export interface BridgeStatusSnapshot {
324
518
  * 纯读,无副作用。用于 `session_bridge_status`。
325
519
  */
326
520
  export function statusSnapshot(ctx: Context, agent: LiveAgentLike): BridgeStatusSnapshot {
327
- const events = agent.session.events
521
+ const events = sessionEvents(agent.session)
328
522
  const rows = foldMessages(events)
329
523
  let lastActivityAt: number | null = null
330
524
  let lastTurn = 0
@@ -340,13 +534,18 @@ export function statusSnapshot(ctx: Context, agent: LiveAgentLike): BridgeStatus
340
534
  }
341
535
  const recent = rows.slice(-8)
342
536
  let lastAssistantText: string | undefined
537
+ let lastReasoning: string | undefined
343
538
  for (let i = rows.length - 1; i >= 0; i -= 1) {
344
539
  const row = rows[i]
345
- if (row !== undefined && row.role === 'assistant' && row.text !== undefined) {
346
- lastAssistantText = row.text
347
- break
540
+ if (row !== undefined && row.role === 'assistant') {
541
+ if (lastAssistantText === undefined && row.text !== undefined) lastAssistantText = row.text
542
+ if (lastReasoning === undefined && row.reasoning !== undefined) lastReasoning = row.reasoning
543
+ if (lastAssistantText !== undefined && lastReasoning !== undefined) break
348
544
  }
349
545
  }
546
+ const live = liveReasoningSnapshot(events)
547
+ const liveReasoning = live !== null && live.reasoning !== '' ? live.reasoning : undefined
548
+ const reasoningTail = reasoningTailOf(liveReasoning, lastReasoning)
350
549
  const inbox = agent.inbox
351
550
  const cwd = agent.session.header.cwd
352
551
  return {
@@ -364,6 +563,9 @@ export function statusSnapshot(ctx: Context, agent: LiveAgentLike): BridgeStatus
364
563
  nextTurnCount: inbox?.nextTurn?.length ?? 0,
365
564
  nextStepCount: inbox?.nextStep?.length ?? 0,
366
565
  ...(lastAssistantText === undefined ? {} : { lastAssistantText }),
566
+ ...(lastReasoning === undefined ? {} : { lastReasoning }),
567
+ ...(liveReasoning === undefined ? {} : { liveReasoning }),
568
+ ...(reasoningTail === undefined ? {} : { reasoningTail }),
367
569
  messageCount: rows.length,
368
570
  recent,
369
571
  }
package/src/monitor.ts CHANGED
@@ -40,6 +40,32 @@ export interface MonitorConfig {
40
40
  logFile?: string
41
41
  /** 监控会话的说明,仅用于展示。 */
42
42
  label?: string
43
+ /** 思维链(CoT)规则:对实时推理/文本做 contains/not-contains 匹配,持续命中达 cotMinHits 后触发 steer 或 cancel。 */
44
+ coTRules?: CoTRule[]
45
+ /** 一条规则连续命中多少个 tick 才触发(默认 1 = 立即)。 */
46
+ cotMinHits?: number
47
+ }
48
+
49
+ /** 一条思维链规则:匹配实时链的内容并触发动作。 */
50
+ export interface CoTRule {
51
+ /** contains = 匹配内容必须包含 value;not-contains = 必须不含 value。 */
52
+ match: 'contains' | 'not-contains'
53
+ /** 在哪个字段上匹配:reasoning(思维链)/ text(回复文本)/ both(任一)。 */
54
+ field: 'reasoning' | 'text' | 'both'
55
+ /** 匹配的关键词(非空)。 */
56
+ value: string
57
+ /** 触发动作:cancel 立即终止会话;steer 注入一条用户消息引导。 */
58
+ action: 'steer' | 'cancel'
59
+ /** steer 时的注入文案(缺省用默认催办文案)。 */
60
+ message?: string
61
+ }
62
+
63
+ /** 一条 CoT 规则的连续命中计数(防单次 tick 误触发 & 防重复触发)。 */
64
+ export interface CoTRuleState {
65
+ hits: number
66
+ lastFiredAt: number | null
67
+ lastMatch: boolean
68
+ lastSubject: string
43
69
  }
44
70
 
45
71
  export interface MonitorEntryState {
@@ -47,11 +73,14 @@ export interface MonitorEntryState {
47
73
  startedAt: number
48
74
  lastTickAt: number
49
75
  stuckCount: number
50
- lastAction: 'none' | 'steer' | 'cancel' | 'done' | 'lost' | 'offtrack' | 'steady'
76
+ lastAction: 'none' | 'steer' | 'cancel' | 'done' | 'lost' | 'offtrack' | 'steady' | 'cot-steer' | 'cot-cancel'
51
77
  lastActionAt: number | null
52
78
  lastNote: string
53
79
  done: boolean
54
80
  cycles: number
81
+ cot: {
82
+ rules: { [key: string]: CoTRuleState }
83
+ }
55
84
  }
56
85
 
57
86
  const SCAN_MS = 5000
@@ -77,6 +106,13 @@ export class SessionMonitor {
77
106
  start(config: MonitorConfig): MonitorEntryState {
78
107
  const existing = this.entries.get(config.sessionId)
79
108
  const now = Date.now()
109
+ const cotInit: MonitorEntryState['cot'] = {
110
+ rules: (config.coTRules ?? []).reduce<Record<string, CoTRuleState>>((acc, rule, index) => {
111
+ const key = String(index) + ':' + rule.match + ':' + rule.field + ':' + rule.value
112
+ acc[key] = { hits: 0, lastFiredAt: null, lastMatch: false, lastSubject: '' }
113
+ return acc
114
+ }, {}),
115
+ }
80
116
  const entry: MonitorEntryState = existing !== undefined
81
117
  ? { ...existing, config, lastTickAt: now }
82
118
  : {
@@ -89,7 +125,9 @@ export class SessionMonitor {
89
125
  lastNote: '监控已启动',
90
126
  done: false,
91
127
  cycles: 0,
128
+ cot: cotInit,
92
129
  }
130
+ if (entry.cot === undefined) entry.cot = cotInit
93
131
  this.entries.set(config.sessionId, entry)
94
132
  this.ensureTimer()
95
133
  this.log(entry, 'monitor start session=' + config.sessionId + ' interval=' + config.intervalMs
@@ -232,6 +270,11 @@ export class SessionMonitor {
232
270
  return
233
271
  }
234
272
 
273
+ // 4b) 思维链(CoT)规则检查:对实时推理/文本做匹配,持续命中则 steer/cancel。
274
+ // 与卡住逻辑正交:即使会话在推进,只要思维链不符合规则也可提前终止/纠偏。
275
+ const cotHandled = this.evaluateCoTRules(entry, snapshot, sessionId)
276
+ if (cotHandled) return
277
+
235
278
  // 5) 正常推进:重置卡住计数。
236
279
  if (entry.stuckCount !== 0) entry.stuckCount = 0
237
280
  entry.lastAction = 'steady'
@@ -262,6 +305,88 @@ export class SessionMonitor {
262
305
  return keywords.some((kw) => kw !== '' && text.includes(kw))
263
306
  }
264
307
 
308
+ /**
309
+ * 评估思维链(CoT)规则。规则只对 live 会话有意义:running 时用进行中的
310
+ * reasoning-delta 流,空闲时回落到已定型 lastReasoning。not-contains 规则作用在
311
+ * reasoning 字段时,若该会话本就没有推理(非推理模型/尚未产出),不判定为命中,
312
+ * 避免误 cancel 一个根本不产生思维链的会话。
313
+ * @returns true 表示本 tick 已被某条规则触发并处理(steer/cancel),调用方应 return。
314
+ */
315
+ private evaluateCoTRules(entry: MonitorEntryState, snapshot: BridgeStatusSnapshot, sessionId: string): boolean {
316
+ const rules = entry.config.coTRules ?? []
317
+ if (rules.length === 0) return false
318
+ const minHits = entry.config.cotMinHits ?? 1
319
+
320
+ // 评估主体:进行中的实时推理/文本,否则已定型的上次推理/回复。
321
+ const hasLive = (snapshot.liveReasoning ?? '') !== ''
322
+ const subject = {
323
+ reasoning: snapshot.liveReasoning ?? snapshot.lastReasoning ?? '',
324
+ text: snapshot.lastAssistantText ?? '',
325
+ hasReasoning: hasLive || (snapshot.lastReasoning ?? '') !== '',
326
+ }
327
+
328
+ const now = Date.now()
329
+ const rulesState = entry.cot
330
+ let engaged = false
331
+ rules.forEach((rule, index) => {
332
+ if (engaged) return
333
+ const key = String(index) + ':' + rule.match + ':' + rule.field + ':' + rule.value
334
+ const st = rulesState.rules[key] ?? { hits: 0, lastFiredAt: null, lastMatch: false }
335
+
336
+ // not-contains 作用在 reasoning 且本就没有推理时:不算命中。
337
+ if (rule.field === 'reasoning' && rule.match === 'not-contains' && !subject.hasReasoning) {
338
+ st.lastMatch = false
339
+ st.hits = 0
340
+ return
341
+ }
342
+
343
+ let hit: boolean
344
+ if (rule.field === 'reasoning') {
345
+ hit = rule.match === 'contains' ? subject.reasoning.includes(rule.value) : !subject.reasoning.includes(rule.value)
346
+ } else if (rule.field === 'text') {
347
+ hit = rule.match === 'contains' ? subject.text.includes(rule.value) : !subject.text.includes(rule.value)
348
+ } else { // 'both'
349
+ const reasonHas = subject.reasoning.includes(rule.value)
350
+ const textHas = subject.text.includes(rule.value)
351
+ hit = rule.match === 'contains' ? (reasonHas || textHas) : (!reasonHas && !textHas)
352
+ }
353
+
354
+ st.lastMatch = hit
355
+ st.hits = hit ? st.hits + 1 : 0
356
+ this.log(entry, 'CoT rule ' + key + ' hit=' + String(hit) + ' hits=' + String(st.hits) + '/' + String(minHits))
357
+
358
+ const shouldFire = hit && st.hits >= minHits
359
+ if (!shouldFire) return
360
+ // 冷却:同一规则在冷却窗口内不重复触发(除非匹配状态先复位再命中)。
361
+ const coolMs = Math.max(entry.config.intervalMs, 5000) * 2
362
+ if (st.lastFiredAt !== null && now - st.lastFiredAt < coolMs) return
363
+
364
+ st.lastFiredAt = now
365
+ if (rule.action === 'cancel') {
366
+ const reason = 'reasoning-rule:' + rule.value
367
+ cancelLiveSession(this.ctx, sessionId, false, reason)
368
+ entry.lastAction = 'cot-cancel'
369
+ entry.lastActionAt = Date.now()
370
+ entry.lastNote = '思维链规则触发 cancel: ' + rule.value
371
+ entry.stuckCount = 0
372
+ this.log(entry, 'COT-CANCEL session=' + sessionId + ' rule=' + key + ' value=' + rule.value)
373
+ } else {
374
+ const steer = rule.message !== undefined && rule.message.trim() !== ''
375
+ ? rule.message.trim()
376
+ : '监控判定:思维链/回复匹配规则「' + rule.value + '」,请据此调整方向并继续推进任务。'
377
+ sendLiveMessage(this.ctx, sessionId, steer, 'steer')
378
+ entry.lastAction = 'cot-steer'
379
+ entry.lastActionAt = Date.now()
380
+ entry.lastNote = '思维链规则触发 steer: ' + rule.value
381
+ entry.stuckCount = 0
382
+ this.log(entry, 'COT-STEER session=' + sessionId + ' rule=' + key + ' steer=' + steer)
383
+ }
384
+ engaged = true
385
+ st.hits = 0
386
+ })
387
+ return engaged
388
+ }
389
+
265
390
  private hasLlm(): boolean {
266
391
  const ctx = this.ctx as unknown as { llm?: LlmService }
267
392
  return ctx.llm !== undefined