dsh-session-bridge 0.2.1 → 0.3.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 +52 -7
- package/README.zh.md +35 -5
- package/dsh.plugin.json +3 -2
- package/lib/index.js +675 -58
- package/package.json +20 -20
- package/src/core.ts +246 -9
- package/src/monitor.ts +126 -1
- package/src/tools.ts +163 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "会话桥(dsh-session-bridge):通过提示词创建新的主会话(顶层 UI 会话)、向任意会话发送消息、等待会话的下一条回复、读取会话消息,并按会话名或 id 跨工作区查找会话;此外支持监控/调度主任务与归档会话。",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -52,28 +52,28 @@
|
|
|
52
52
|
},
|
|
53
53
|
"license": "MIT",
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
56
|
-
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
57
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
58
|
-
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
59
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
60
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
61
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
62
|
-
"@deepseek-ai/dsh-workspace": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
55
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
56
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
57
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
58
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
59
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
60
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
61
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
62
|
+
"@deepseek-ai/dsh-workspace": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
63
63
|
"cordis": "^4.0.0-rc.7"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
-
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
67
|
-
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
68
|
-
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
69
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
70
|
-
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
71
|
-
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
72
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
73
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
74
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
75
|
-
"@deepseek-ai/dsh-util-values": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
76
|
-
"@deepseek-ai/dsh-workspace": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0",
|
|
66
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
67
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
68
|
+
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
69
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
70
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
71
|
+
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
72
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
73
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
74
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
75
|
+
"@deepseek-ai/dsh-util-values": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
76
|
+
"@deepseek-ai/dsh-workspace": "^0.1.0-rc.6 || ^0.1.1-0 || ^0.1.2-0 || ^0.1.5-0",
|
|
77
77
|
"@types/node": "^24.0.0",
|
|
78
78
|
"cordis": "^4.0.0-rc.7",
|
|
79
79
|
"tsdown": "^0.22.14",
|
package/src/core.ts
CHANGED
|
@@ -21,7 +21,9 @@ export interface LiveAgentLike {
|
|
|
21
21
|
whenIdle?(): Promise<void>
|
|
22
22
|
readonly ctx: Context
|
|
23
23
|
readonly inbox?: {
|
|
24
|
-
hasPending
|
|
24
|
+
// dsh 0.1.2 及之前:`hasPending` getter;dsh 0.1.5 起已删除,仅有
|
|
25
|
+
// nextTurn / nextStep 只读数组(Inbox 成为 driver 持有的接口)。
|
|
26
|
+
hasPending?: boolean
|
|
25
27
|
nextTurn?: readonly unknown[]
|
|
26
28
|
nextStep?: readonly unknown[]
|
|
27
29
|
}
|
|
@@ -86,6 +88,34 @@ export function asEventList(events: unknown): readonly SessionEvent[] {
|
|
|
86
88
|
return Array.isArray(events) ? events : []
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* 读取一个持久化(离线)会话的事件日志与头部元数据。兼容两代 persistence API:
|
|
93
|
+
* - 旧版:`sessionPersistence.inspect(id)` 直接返回 { events, meta };
|
|
94
|
+
* - dsh 0.1.5 之后:`inspect` 已删除,改用 `open(id, 'read')` → `handle.read()`
|
|
95
|
+
* (+ `handle.header`),读句柄不抢写所有权,读后必须 close()。
|
|
96
|
+
* 会话不存在时两个 API 都抛错,调用方按原语义处理。
|
|
97
|
+
*/
|
|
98
|
+
export async function inspectPersistedSession(ctx: Context, id: string): Promise<{ events: readonly SessionEvent[]; meta: { cwd?: string } }> {
|
|
99
|
+
const persistence = ctx.sessionPersistence as unknown as {
|
|
100
|
+
inspect?: (sid: string) => Promise<{ events: readonly SessionEvent[]; meta: { cwd?: string } }>
|
|
101
|
+
open?: (sid: string, access: 'read') => Promise<{
|
|
102
|
+
header: { cwd?: string }
|
|
103
|
+
read(): Promise<{ events: readonly SessionEvent[] }>
|
|
104
|
+
close(): Promise<void>
|
|
105
|
+
}>
|
|
106
|
+
}
|
|
107
|
+
if (typeof persistence.inspect === 'function') {
|
|
108
|
+
return await persistence.inspect(id)
|
|
109
|
+
}
|
|
110
|
+
const handle = await persistence.open!(id, 'read')
|
|
111
|
+
try {
|
|
112
|
+
const { events } = await handle.read()
|
|
113
|
+
return { events, meta: handle.header }
|
|
114
|
+
} finally {
|
|
115
|
+
await handle.close()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
/** agent.followup / steer 接受的用户消息值。 */
|
|
90
120
|
export function userMessage(text: string): unknown {
|
|
91
121
|
return {
|
|
@@ -108,6 +138,19 @@ function blockText(content: unknown): string | undefined {
|
|
|
108
138
|
return parts.length === 0 ? undefined : parts.join('\n')
|
|
109
139
|
}
|
|
110
140
|
|
|
141
|
+
/** 提取指定类型的 content block 文本(如 reasoning),用于从消息 content 里读出思维链。 */
|
|
142
|
+
function blockTextByType(content: unknown, wantedType: string): string | undefined {
|
|
143
|
+
if (!Array.isArray(content)) return undefined
|
|
144
|
+
const parts: string[] = []
|
|
145
|
+
for (const raw of content) {
|
|
146
|
+
const block = raw as { type?: unknown; text?: unknown } | null
|
|
147
|
+
if (block !== null && typeof block === 'object' && block.type === wantedType && typeof block.text === 'string') {
|
|
148
|
+
if (block.text !== '') parts.push(block.text)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return parts.length === 0 ? undefined : parts.join('\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
111
154
|
function countImages(content: unknown): number {
|
|
112
155
|
if (!Array.isArray(content)) return 0
|
|
113
156
|
let n = 0
|
|
@@ -142,7 +185,9 @@ export function foldMessages(events: readonly SessionEvent[]): BridgeMessageRow[
|
|
|
142
185
|
} else if (event.type === 'assistant/message') {
|
|
143
186
|
const message = event.data.message as { content?: unknown; reasoning?: unknown; toolCalls?: unknown } | null
|
|
144
187
|
const text = message === null ? undefined : blockText(message.content)
|
|
145
|
-
|
|
188
|
+
// 思维链是 content 里 type:'reasoning' 的块;老版本曾把取舍放在 message.reasoning 字段,
|
|
189
|
+
// 两者都读,优先 content 块(当前 dsh-llm 的 AssistantMessage 无独立 reasoning 字段)。
|
|
190
|
+
const reasoning = message === null ? undefined : (blockTextByType(message.content, 'reasoning') ?? blockText(message.reasoning))
|
|
146
191
|
const images = message === null ? 0 : countImages(message.content)
|
|
147
192
|
const toolCalls = message === null ? [] : toolNamesOf(message.toolCalls)
|
|
148
193
|
if (text === undefined && reasoning === undefined && toolCalls.length === 0) continue
|
|
@@ -156,6 +201,166 @@ export function foldMessages(events: readonly SessionEvent[]): BridgeMessageRow[
|
|
|
156
201
|
return rows
|
|
157
202
|
}
|
|
158
203
|
|
|
204
|
+
/** 一个已完成的输出段落:一次 assistant step 定型的 assistant/message(turn 中途即可读,无需等整个 turn)。 */
|
|
205
|
+
export interface BridgeSegment {
|
|
206
|
+
seq: number
|
|
207
|
+
time: number
|
|
208
|
+
turn: number
|
|
209
|
+
step: number
|
|
210
|
+
text?: string
|
|
211
|
+
reasoning?: string
|
|
212
|
+
toolCalls: string[]
|
|
213
|
+
openTurn: boolean
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 按已完成输出段落折叠事件日志:每个 assistant/message 割一段,返回 sinceSeq 后的所有段落(事件序)。 */
|
|
217
|
+
export function segmentsSince(events: readonly SessionEvent[], sinceSeq = 0): BridgeSegment[] {
|
|
218
|
+
const list = asEventList(events)
|
|
219
|
+
const out: BridgeSegment[] = []
|
|
220
|
+
let openTurn = false
|
|
221
|
+
for (const event of list) {
|
|
222
|
+
if (event.type === 'turn/start') { openTurn = true; continue }
|
|
223
|
+
if (event.type === 'turn/end') { openTurn = false; continue }
|
|
224
|
+
if (event.type !== 'assistant/message') continue
|
|
225
|
+
if (event.seq <= sinceSeq) continue
|
|
226
|
+
const message = event.data.message as { content?: unknown; reasoning?: unknown; toolCalls?: unknown } | null
|
|
227
|
+
const text = message === null ? undefined : blockText(message.content)
|
|
228
|
+
const reasoning = message === null ? undefined : (blockTextByType(message.content, 'reasoning') ?? blockText(message.reasoning))
|
|
229
|
+
const toolCalls = message === null ? [] : toolNamesOf(message.toolCalls)
|
|
230
|
+
const turn = (event.data as { turn?: unknown })?.turn
|
|
231
|
+
const step = (event.data as { step?: unknown })?.step
|
|
232
|
+
const seg: BridgeSegment = {
|
|
233
|
+
seq: event.seq,
|
|
234
|
+
time: event.time,
|
|
235
|
+
turn: typeof turn === 'number' ? turn : 0,
|
|
236
|
+
step: typeof step === 'number' ? step : 0,
|
|
237
|
+
toolCalls,
|
|
238
|
+
openTurn,
|
|
239
|
+
}
|
|
240
|
+
if (text !== undefined) seg.text = text
|
|
241
|
+
if (reasoning !== undefined) seg.reasoning = reasoning
|
|
242
|
+
out.push(seg)
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 取自 sinceSeq 之后最新一个已完成输出段落(无则 null)。 */
|
|
248
|
+
export function latestSegmentSince(events: readonly SessionEvent[], sinceSeq = 0): BridgeSegment | null {
|
|
249
|
+
const segs = segmentsSince(events, sinceSeq)
|
|
250
|
+
return segs.length === 0 ? null : (segs[segs.length - 1] ?? null)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 实时思维链切片:一个 live 会话当前"进行中"的增量推理与文本。 */
|
|
254
|
+
export interface CoTLiveSlice {
|
|
255
|
+
/** 产生该推理的 turn。 */
|
|
256
|
+
turn: number
|
|
257
|
+
/** 产生该推理的 step。 */
|
|
258
|
+
step: number
|
|
259
|
+
/** 自最近一条已定型 assistant 消息以来累积的进行中推理文本(reasoning-delta 拼接)。 */
|
|
260
|
+
reasoning: string
|
|
261
|
+
/** 同窗口累积的进行中文本增量(text-delta 拼接)。 */
|
|
262
|
+
text: string
|
|
263
|
+
/** 本切片覆盖到的最大事件 seq(供增量轮询记住游标)。 */
|
|
264
|
+
seq: number
|
|
265
|
+
/** 最近一次事件时间戳。 */
|
|
266
|
+
time: number
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 对一个 live 会话的事件日志折叠出其"最近推理/文本增量流"。兼容两代日志形状:
|
|
271
|
+
* - dsh 0.1.5 之后:turn 内不再发布独立 chunk 事件,流式增量内嵌在已定型
|
|
272
|
+
* assistant/message(或未定型 assistant/attempt)事件的 `stream` 记录里
|
|
273
|
+
* (reasoning-chunks / text-chunks 批量段 + 原始 chunk:reasoning-delta / text-delta);
|
|
274
|
+
* - 0.1.2-rc 之前:独立的 assistant/chunk 事件(turn 中途即可见、增量,
|
|
275
|
+
* 这正是旧版监控思维链并按规则提前终止所需的粒度)。
|
|
276
|
+
* 两者兼读、取最新一份;无任何增量(会话空闲、或该 provider 不流式推理/无推理)
|
|
277
|
+
* 返回 null,调用方应回落为 foldMessages 里已定型的 message.reasoning。
|
|
278
|
+
* 纯读、无副作用;事件形状不做任何假设,缺失/异常一律安全处理。
|
|
279
|
+
*/
|
|
280
|
+
export function liveReasoningSnapshot(events: readonly SessionEvent[]): CoTLiveSlice | null {
|
|
281
|
+
const list = asEventList(events)
|
|
282
|
+
// 旧格式只累积"最近一条已定型 assistant/message 之后"的流式增量(in-flight 窗口);
|
|
283
|
+
// 新格式的 stream 随定型事件落地,直接以最新携带 stream 的事件为准。
|
|
284
|
+
let lastFinalizedSeq = -1
|
|
285
|
+
let reasoning = ''
|
|
286
|
+
let text = ''
|
|
287
|
+
let seq = -1
|
|
288
|
+
let time = 0
|
|
289
|
+
let turn = 0
|
|
290
|
+
let step = 0
|
|
291
|
+
let foundDelta = false
|
|
292
|
+
for (const event of list) {
|
|
293
|
+
const type = event.type as string
|
|
294
|
+
if (type === 'assistant/message' || type === 'assistant/attempt') {
|
|
295
|
+
const data = event.data as { turn?: unknown; step?: unknown; stream?: unknown } | null | undefined
|
|
296
|
+
if (data !== null && data !== undefined && typeof data === 'object' && Array.isArray(data.stream)) {
|
|
297
|
+
let r = ''
|
|
298
|
+
let t = ''
|
|
299
|
+
for (const raw of data.stream as unknown[]) {
|
|
300
|
+
const record = raw as { type?: unknown; texts?: unknown; text?: unknown; chunk?: unknown } | null
|
|
301
|
+
if (record === null || typeof record !== 'object') continue
|
|
302
|
+
if (record.type === 'reasoning-chunks' && Array.isArray(record.texts)) {
|
|
303
|
+
for (const s of record.texts as unknown[]) if (typeof s === 'string') r += s
|
|
304
|
+
} else if (record.type === 'text-chunks' && Array.isArray(record.texts)) {
|
|
305
|
+
for (const s of record.texts as unknown[]) if (typeof s === 'string') t += s
|
|
306
|
+
} else if (record.type === 'chunk') {
|
|
307
|
+
const c = record.chunk as { type?: unknown; text?: unknown } | null
|
|
308
|
+
if (c !== null && typeof c === 'object' && typeof c.type === 'string' && typeof c.text === 'string') {
|
|
309
|
+
if (c.type === 'reasoning-delta') r += c.text
|
|
310
|
+
else if (c.type === 'text-delta') t += c.text
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (r !== '' || t !== '') {
|
|
315
|
+
if (typeof data.turn === 'number') turn = data.turn
|
|
316
|
+
if (typeof data.step === 'number') step = data.step
|
|
317
|
+
reasoning = r
|
|
318
|
+
text = t
|
|
319
|
+
seq = event.seq
|
|
320
|
+
time = event.time
|
|
321
|
+
foundDelta = true
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (type === 'assistant/message') lastFinalizedSeq = event.seq
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
// 旧格式:独立 assistant/chunk 事件。
|
|
328
|
+
if (type !== 'assistant/chunk') continue
|
|
329
|
+
if (event.seq <= lastFinalizedSeq) continue
|
|
330
|
+
const data = event.data as { turn?: unknown; step?: unknown; chunk?: unknown } | null | undefined
|
|
331
|
+
if (data === null || data === undefined || typeof data !== 'object') continue
|
|
332
|
+
const c = data.chunk as { type?: unknown; text?: unknown } | null
|
|
333
|
+
if (c === null || typeof c !== 'object' || typeof c.type !== 'string') continue
|
|
334
|
+
if (c.type === 'reasoning-delta' || c.type === 'text-delta') {
|
|
335
|
+
if (typeof c.text !== 'string') continue
|
|
336
|
+
if (c.type === 'reasoning-delta') reasoning += c.text
|
|
337
|
+
else text += c.text
|
|
338
|
+
if (typeof data.turn === 'number') turn = data.turn
|
|
339
|
+
if (typeof data.step === 'number') step = data.step
|
|
340
|
+
foundDelta = true
|
|
341
|
+
if (event.seq > seq) seq = event.seq
|
|
342
|
+
if (event.time > time) time = event.time
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!foundDelta) return null
|
|
346
|
+
return { turn, step, reasoning, text, seq, time }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 思维链尾巴默认截断上限(字符)。 */
|
|
350
|
+
export const REASONING_TAIL_LIMIT = 4000
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 组装一条受字符上限约束的"思维链尾巴"用于展示/摘要:
|
|
354
|
+
* 优先进行中的 liveReasoning,否则最近已定型的 lastReasoning;
|
|
355
|
+
* 超出上限时截断并附省略标记。两者皆无返回 undefined。
|
|
356
|
+
*/
|
|
357
|
+
export function reasoningTailOf(liveReasoning: string | undefined, lastReasoning: string | undefined, limit = REASONING_TAIL_LIMIT): string | undefined {
|
|
358
|
+
const source = liveReasoning ?? lastReasoning
|
|
359
|
+
if (source === undefined || source === '') return undefined
|
|
360
|
+
if (source.length <= limit) return source
|
|
361
|
+
return source.slice(0, limit) + '…'
|
|
362
|
+
}
|
|
363
|
+
|
|
159
364
|
/**
|
|
160
365
|
* 会话标题:优先 session/title 事件(last-wins,与 UI 一致);
|
|
161
366
|
* 无标题事件时回落为第一条用户消息(截断 80 字符)。
|
|
@@ -203,6 +408,8 @@ export interface WaitForReplyOptions {
|
|
|
203
408
|
timeoutMs: number
|
|
204
409
|
signal?: AbortSignal
|
|
205
410
|
requireTurnEnd?: boolean
|
|
411
|
+
/** true 时等待到 baseline 之后出现一个“新完成的输出段落”(assistant/message),立即返回该段,不等整个 turn。 */
|
|
412
|
+
waitForSegment?: boolean
|
|
206
413
|
}
|
|
207
414
|
|
|
208
415
|
/**
|
|
@@ -221,8 +428,10 @@ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWai
|
|
|
221
428
|
const started = Date.now()
|
|
222
429
|
const deadline = started + opts.timeoutMs
|
|
223
430
|
const requireTurnEnd = opts.requireTurnEnd ?? false
|
|
431
|
+
const waitForSegment = opts.waitForSegment ?? false
|
|
224
432
|
let latest: BridgeMessageRow | null = null
|
|
225
433
|
let textReply: BridgeMessageRow | null = null
|
|
434
|
+
let segment: BridgeSegment | null = null
|
|
226
435
|
let turnEnded = false
|
|
227
436
|
for (;;) {
|
|
228
437
|
if (opts.signal !== undefined && opts.signal.aborted) break
|
|
@@ -232,22 +441,35 @@ export async function waitForReply(opts: WaitForReplyOptions): Promise<BridgeWai
|
|
|
232
441
|
if (latest === null || row.seq > latest.seq) latest = row
|
|
233
442
|
if (row.text !== undefined && (textReply === null || row.seq > textReply.seq)) textReply = row
|
|
234
443
|
}
|
|
444
|
+
if (waitForSegment) {
|
|
445
|
+
const seg = latestSegmentSince(events, opts.baselineSeq)
|
|
446
|
+
if (seg !== null && (segment === null || seg.seq > segment.seq)) segment = seg
|
|
447
|
+
}
|
|
235
448
|
if (requireTurnEnd && latest !== null) {
|
|
236
449
|
for (const event of events) {
|
|
237
450
|
if (event.seq > latest.seq && event.type === 'turn/end') { turnEnded = true; break }
|
|
238
451
|
}
|
|
239
452
|
}
|
|
240
|
-
|
|
453
|
+
let done: boolean
|
|
454
|
+
if (requireTurnEnd) done = latest !== null && turnEnded
|
|
455
|
+
else if (waitForSegment) done = segment !== null
|
|
456
|
+
else done = textReply !== null
|
|
241
457
|
if (done) break
|
|
242
458
|
if (Date.now() >= deadline) break
|
|
243
459
|
await sleep(100)
|
|
244
460
|
}
|
|
245
|
-
|
|
461
|
+
// 段落模式返回该段(把 reasoning 并进返回行,便于“按段落读思维链”);否则返回最新文本行。
|
|
462
|
+
let message: BridgeMessageRow | null = waitForSegment && segment !== null ? {
|
|
463
|
+
seq: segment.seq, time: segment.time, role: 'assistant', images: 0,
|
|
464
|
+
...(segment.text !== undefined ? { text: segment.text } : {}),
|
|
465
|
+
...(segment.reasoning !== undefined ? { reasoning: segment.reasoning } : {}),
|
|
466
|
+
...(segment.toolCalls.length > 0 ? { toolCalls: segment.toolCalls } : {}),
|
|
467
|
+
} : (textReply ?? latest)
|
|
246
468
|
return {
|
|
247
469
|
message,
|
|
248
470
|
seq: message === null ? opts.baselineSeq : message.seq,
|
|
249
471
|
turnEnded,
|
|
250
|
-
timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : textReply === null,
|
|
472
|
+
timedOut: requireTurnEnd ? (latest !== null && !turnEnded) : waitForSegment ? segment === null : textReply === null,
|
|
251
473
|
aborted: opts.signal !== undefined && opts.signal.aborted,
|
|
252
474
|
waitedMs: Date.now() - started,
|
|
253
475
|
}
|
|
@@ -342,6 +564,12 @@ export interface BridgeStatusSnapshot {
|
|
|
342
564
|
nextStepCount: number
|
|
343
565
|
/** 最新一条带文本的 assistant 回复。 */
|
|
344
566
|
lastAssistantText?: string
|
|
567
|
+
/** 最新一条已定型 assistant 消息的推理(reasoning content block)。 */
|
|
568
|
+
lastReasoning?: string
|
|
569
|
+
/** 进行中的实时思维链增量(reasoning-delta 拼接;无进行中流时为 undefined)。 */
|
|
570
|
+
liveReasoning?: string
|
|
571
|
+
/** 极紧凑的思维链尾巴:liveReasoning 优先、否则 lastReasoning,截断至 reasoningTailLimit。 */
|
|
572
|
+
reasoningTail?: string
|
|
345
573
|
/** 折叠后的消息总数。 */
|
|
346
574
|
messageCount: number
|
|
347
575
|
/** 最近几条消息(默认 8)。 */
|
|
@@ -369,13 +597,18 @@ export function statusSnapshot(ctx: Context, agent: LiveAgentLike): BridgeStatus
|
|
|
369
597
|
}
|
|
370
598
|
const recent = rows.slice(-8)
|
|
371
599
|
let lastAssistantText: string | undefined
|
|
600
|
+
let lastReasoning: string | undefined
|
|
372
601
|
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
373
602
|
const row = rows[i]
|
|
374
|
-
if (row !== undefined && row.role === 'assistant'
|
|
375
|
-
lastAssistantText = row.text
|
|
376
|
-
|
|
603
|
+
if (row !== undefined && row.role === 'assistant') {
|
|
604
|
+
if (lastAssistantText === undefined && row.text !== undefined) lastAssistantText = row.text
|
|
605
|
+
if (lastReasoning === undefined && row.reasoning !== undefined) lastReasoning = row.reasoning
|
|
606
|
+
if (lastAssistantText !== undefined && lastReasoning !== undefined) break
|
|
377
607
|
}
|
|
378
608
|
}
|
|
609
|
+
const live = liveReasoningSnapshot(events)
|
|
610
|
+
const liveReasoning = live !== null && live.reasoning !== '' ? live.reasoning : undefined
|
|
611
|
+
const reasoningTail = reasoningTailOf(liveReasoning, lastReasoning)
|
|
379
612
|
const inbox = agent.inbox
|
|
380
613
|
const cwd = agent.session.header.cwd
|
|
381
614
|
return {
|
|
@@ -389,10 +622,14 @@ export function statusSnapshot(ctx: Context, agent: LiveAgentLike): BridgeStatus
|
|
|
389
622
|
lastTurn,
|
|
390
623
|
lastActivityAt,
|
|
391
624
|
stalledMs: lastActivityAt === null ? null : Date.now() - lastActivityAt,
|
|
392
|
-
|
|
625
|
+
// 双兼容:0.1.2 用 hasPending getter;0.1.5 起只有 nextTurn/nextStep 数组。
|
|
626
|
+
pendingWork: inbox?.hasPending === true || (inbox?.nextTurn?.length ?? 0) + (inbox?.nextStep?.length ?? 0) > 0,
|
|
393
627
|
nextTurnCount: inbox?.nextTurn?.length ?? 0,
|
|
394
628
|
nextStepCount: inbox?.nextStep?.length ?? 0,
|
|
395
629
|
...(lastAssistantText === undefined ? {} : { lastAssistantText }),
|
|
630
|
+
...(lastReasoning === undefined ? {} : { lastReasoning }),
|
|
631
|
+
...(liveReasoning === undefined ? {} : { liveReasoning }),
|
|
632
|
+
...(reasoningTail === undefined ? {} : { reasoningTail }),
|
|
396
633
|
messageCount: rows.length,
|
|
397
634
|
recent,
|
|
398
635
|
}
|
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
|