dsh-session-guard 3.2.2 → 3.2.4

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/pause-gate.js CHANGED
@@ -1,524 +1,544 @@
1
- /**
2
- * dsh-session-guard — 自研会话门引擎(脱离 dsh-task-control,全量移植)。
3
- *
4
- * 真实锁定「session 推进」:复用 dsh runtime 自身的原语,不依赖任何外部会话门:
5
- * - `agent.cancel({ kind:'user' }, { keepInbox:true })` —— 停住当前回合(中断推理/在途工具)。
6
- * - `goals.pause(agent, { id, revision })` —— 停住同会话 goal,防 goal-round driver 再排。
7
- * - `ctx.on('session/event')` —— 安全边界:tool/call 记 in-flight,tool/result 落地,
8
- * assistant/message 记录 deferredTools,再落地延迟暂停(queueMicrotask)。
9
- * - `agent.followup(<plugin user message>)` —— 恢复续跑指令(消息由本文件本地构造,见下)。
10
- * - 暂停状态持久化(src/pause-store.js),不写 session log。
11
- *
12
- * 三粒度(对齐 task-control):
13
- * force 立即中断推理+在途工具,记 interruptedTool,resume 需 confirm 选 rerun/skip
14
- * safe + stop 在途工具跑完后再暂停(不中断推理则工具完成后落地)
15
- * safe + wait 不中断推理,assistant/message 后记 deferredTools 再落地(默认)
16
- *
17
- * 设计为目标可单测:ctx / pauseStore / makeFollowupMessage 全部依赖注入,
18
- * 停/续/取消判定不依赖真实 dsh runtime;`getAgent(sessionId)` 经 `ctx.agents.get` 懒取。
19
- *
20
- * DUAL-VERSION(DSH 0.1.1-rc.2 与 0.1.2-rc.1):上述扩展点在两版本中签名一致
21
- * (已逐行核对 `packages/session/session-persistence/src/coordinator.ts`、
22
- * `packages/core/agent-loop/src/agent.ts`、`packages/goal/goal/src/index.ts`)。
23
- * 唯一需要双读的是 `tool/result` 记录的调用 id 形态(见 readToolResultCallId):
24
- * 0.1.1 与 0.1.2 的回放日志都同时保留 `content[].toolCallId` 与 `source.callId` 两种形态。
25
- */
26
- import { readToolResultBlockId, readToolResultCallId } from './tool-call-id.js'
27
-
28
- /**
29
- * 构造一条 plugin-source 的 user 消息(形状与 `dsh-llm` 的 `createUserMessage` 一致:
30
- * `{ id, role:'user', content, source }`)。
31
- *
32
- * **为什么不用 `@deepseek-ai/dsh-llm` 的 `createUserMessage`**:那是平台包值导入,
33
- * 既未在本插件 `dependencies` 中声明(属未声明运行时依赖),也违反本仓库
34
- * 「host 端只 value-import `@deepseek-ai/schemastery`」的约定(findings 8.4)。
35
- * `agent.followup` 只把消息放进 inbox(`send → inbox.splice`),不要求冻结或品牌 id,
36
- * 因此本地构造等价且无依赖。retry.js / wiring.js 已用同一形状。
37
- * @param {{content: unknown, source: object}} input
38
- * @returns {{id: string, role: 'user', content: unknown, source: object}}
39
- */
40
- export function createPluginUserMessage({ content, source }) {
41
- const cryptoObj = globalThis.crypto
42
- const id =
43
- cryptoObj && typeof cryptoObj.randomUUID === 'function'
44
- ? cryptoObj.randomUUID()
45
- : `session-guard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
46
- return { id, role: 'user', content, source }
47
- }
48
-
49
- /**
50
- * 恢复消息入队(0.1.5 compat 双路径)。
51
- *
52
- * 0.1.5 把 Inbox 从独立服务改为 agent-loop 只读投影(消息入队走 session 事件),
53
- * `agent.followup` 是否保留无法静态确认,因此运行时检测:
54
- * 1. `agent.followup(msg)` —— ≤0.1.4 与(若保留的)0.1.5 首选路径;
55
- * 2. `agent.send(msg)` —— followup 被移除时的回退;
56
- * 3. 两者皆无 —— 记 warn 并返回 false,绝不让 resume 因入队失败而抛错。
57
- * @param {object} agent 目标 agent(live agent)
58
- * @param {{id: string, role: 'user', content: unknown, source: object}} message 本地构造的 user 消息
59
- * @param {object} [ctx] host context(仅用于 logger)
60
- * @returns {boolean} 是否成功入队
61
- */
62
- export function enqueueFollowup(agent, message, ctx) {
63
- if (typeof agent.followup === 'function') {
64
- agent.followup(message)
65
- return true
66
- }
67
- if (typeof agent.send === 'function') {
68
- agent.send(message)
69
- return true
70
- }
71
- try {
72
- ctx?.logger?.warn?.('[session-guard] agent has no followup/send; resume message dropped')
73
- } catch {
74
- /* logger 不可用时静默 */
75
- }
76
- return false
77
- }
78
-
79
- /**
80
- * @param {object} deps
81
- * @param {object} deps.ctx host context(含 agents.get / get / logger,可选 goals)
82
- * @param {ReturnType<import('./pause-store.js').createPauseStore>} deps.pauseStore 暂停状态存储
83
- * @param {string} [deps.pluginId] followup 消息的 source.plugin(默认 'session-guard')
84
- * @param {(input: {content: object, source: object}) => unknown} [deps.makeFollowupMessage] 恢复消息构造(默认本地构造;测试注入)
85
- * @param {ReturnType<import('./step-gate.js').createStepGate>} [deps.stepGate] step 级门控(v0.2.0)
86
- *
87
- * **为什么 pause/resume/cancel 必须先释放 step gate(F6 死锁)**:step 门控挂起在
88
- * `agent/pre-step`,此刻既不会产生 `assistant/message` 也不会产生 `tool/result`;
89
- * 而 safe+wait / safe+stop 都靠这两个事件落地暂停 → 两边互等,会话永久卡死。
90
- */
91
- export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', makeFollowupMessage = createPluginUserMessage, stepGate }) {
92
- const state = { inFlight: new Map(), pendingPause: new Map() }
93
- const getAgent = (sessionId) => (ctx && typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined)
94
-
95
- /** 先解开 step 门(若存在),再走 turn 级逻辑;任何异常都不阻断暂停动作。 */
96
- function releaseStepGate(sessionId, reason) {
97
- if (!stepGate || typeof stepGate.release !== 'function') return
98
- try {
99
- stepGate.release(sessionId, reason)
100
- } catch (e) {
101
- ctx?.logger?.warn?.('[session-guard] step gate release failed: ' + String(e))
102
- }
103
- }
104
-
105
- /** 会话门(dsh-task-control)当前是否可用(兼容探测;自研后始终视为可用)。 */
106
- const taskControlAvailable = () => true
107
-
108
- // ── in-flight 工具跟踪 ──────────────────────────────────────────────
109
-
110
- function inflightOf(sessionId) {
111
- let map = state.inFlight.get(sessionId)
112
- if (map === undefined) {
113
- map = new Map()
114
- state.inFlight.set(sessionId, map)
115
- }
116
- return map
117
- }
118
-
119
- function latestInflight(sessionId) {
120
- const map = inflightOf(sessionId)
121
- const entries = [...map.values()]
122
- return entries.length > 0 ? entries[entries.length - 1] : null
123
- }
124
-
125
- function clearInflight(sessionId) {
126
- state.inFlight.delete(sessionId)
127
- }
128
-
129
- // ── 工具描述 / 结果查阅(resume 断点决策) ────────────────────────────
130
-
131
- /** 描述工具目的:优先 model 写的 description,再 bash command,再 compact args。 */
132
- function describeToolPurpose(info) {
133
- if (info === undefined || info === null) return '未知工具'
134
- const raw = info.arguments
135
- const parseArgs = (text) => {
136
- try {
137
- return JSON.parse(text)
138
- } catch {
139
- return null
140
- }
141
- }
142
- const args = typeof raw === 'string' ? parseArgs(raw) : raw
143
- if (args !== null && typeof args.description === 'string' && args.description.length > 0) return args.description
144
- if (info.name === 'bash' && args !== null && typeof args.command === 'string') return `运行命令:${args.command}`
145
- if (args !== null) {
146
- try {
147
- return JSON.stringify(args).slice(0, 120)
148
- } catch {
149
- /* ignore */
150
- }
151
- }
152
- return `工具 ${info.name}`
153
- }
154
-
155
- function describeTool(info) {
156
- return `${info?.name ?? '未知工具'}(${describeToolPurpose(info)})`
157
- }
158
-
159
- /**
160
- * `tool/result` 调用 id 的双形态读取已抽到 `./tool-call-id.js`(零依赖、可单测):
161
- * 优先 `data.message.content[].toolCallId`,回退 `data.message.source.callId`。
162
- * 两种形态在 DSH 0.1.1-rc.2 与 0.1.2-rc.1 的回放日志里都保留,勿改成单读。
163
- */
164
-
165
- /**
166
- * 读取会话事件数组。DSH 0.1.5 移除了 `session.events` 数组访问器,改为
167
- * `snapshotEvents()`(compatibility-guide §20.3 实测表);本专线以
168
- * snapshotEvents() 为主,events 数组仅作回退防御,两者皆缺时返回 null
169
- * (fail-open,与既有防御语义一致)。
170
- */
171
- function readSessionEvents(session) {
172
- if (!session) return null
173
- if (typeof session.snapshotEvents === 'function') {
174
- try {
175
- const events = session.snapshotEvents()
176
- return Array.isArray(events) ? events : null
177
- } catch {
178
- return null
179
- }
180
- }
181
- return Array.isArray(session.events) ? session.events : null
182
- }
183
-
184
- /** 在 session log 查一个中断工具的实际结果(kernel 会 drain 已启动工具到 tool/result)。 */
185
- function findToolOutcome(agent, callId) {
186
- const events = readSessionEvents(agent?.session)
187
- if (!events) return null
188
- let outcome = null
189
- for (const event of events) {
190
- if (event.type === 'tool/result') {
191
- const message = event.data?.message ?? {}
192
- const block = (Array.isArray(message.content) ? message.content : []).find((b) => b?.type === 'tool-result')
193
- const id = readToolResultCallId(message)
194
- if (id === callId) {
195
- outcome = {
196
- hasResult: true,
197
- isError: block?.isError === true,
198
- abortedBeforeDispatch: event.data?.error?.code === 'ABORTED_BEFORE_DISPATCH',
199
- content: block?.content ?? [],
200
- }
201
- }
202
- } else if (event.type === 'user/message') {
203
- const content = Array.isArray(event.data?.content) ? event.data.content : []
204
- for (const block of content) {
205
- if (readToolResultBlockId(block) === callId) {
206
- outcome = { hasResult: true, isError: block.isError === true, abortedBeforeDispatch: false, content: block.content ?? [] }
207
- }
208
- }
209
- }
210
- }
211
- return outcome
212
- }
213
-
214
- function lastUserPrompt(agent) {
215
- const events = readSessionEvents(agent?.session)
216
- if (!events) return null
217
- for (let index = events.length - 1; index >= 0; index -= 1) {
218
- const event = events[index]
219
- if (event.type !== 'user/message') continue
220
- if (event.data?.source?.kind !== 'user') continue
221
- return event.data.content ?? null
222
- }
223
- return null
224
- }
225
-
226
- // ── 暂停状态读写 ────────────────────────────────────────────────────
227
-
228
- function currentPause(id) {
229
- return pauseStore.current(id)
230
- }
231
-
232
- function markPaused(id, resumeContent, forcedContext, deferredTools) {
233
- const snapshot = {
234
- sessionId: String(id),
235
- paused: true,
236
- resumeContent: resumeContent ?? null,
237
- forced: forcedContext?.forced === true,
238
- interruptedTool: forcedContext?.interruptedTool ?? null,
239
- deferredTools: deferredTools ?? null,
240
- updatedAt: Date.now(),
241
- }
242
- pauseStore.set(id, snapshot)
243
- return snapshot
244
- }
245
-
246
- function clearPaused(id) {
247
- pauseStore.clear(id)
248
- }
249
-
250
- // ── goal 暂停(防 goal-round driver 再排同会话轮次) ──────────────────
251
-
252
- function pauseSessionGoal(agent) {
253
- try {
254
- const goals = ctx && typeof ctx.get === 'function' ? ctx.get('goals') : undefined
255
- if (goals === undefined) return
256
- const goal = goals.get(agent)
257
- if (goal !== undefined && goal.phase === 'active') {
258
- goals.pause(agent, { id: goal.id, revision: goal.revision })
259
- }
260
- } catch (e) {
261
- ctx?.logger?.warn?.('[session-guard] goal pause failed: ' + String(e))
262
- }
263
- }
264
-
265
- // ── 立即落地暂停 ────────────────────────────────────────────────────
266
-
267
- /** 立即停止运行回合(agent.cancel 保 inbox)+ 停 goal + 持久化快照。 */
268
- function applyPauseNow(id, resumeContent, forcedContext, deferredTools) {
269
- state.pendingPause.delete(id)
270
- const current = currentPause(id)
271
- if (current.paused) return { kind: 'success', text: 'task is already paused' }
272
- const agent = getAgent(id)
273
- if (agent !== undefined && agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
274
- clearInflight(id)
275
- if (agent !== undefined) pauseSessionGoal(agent)
276
- markPaused(id, resumeContent, forcedContext, deferredTools)
277
- return { kind: 'success', text: agent !== undefined && agent.status === 'running' ? 'task paused — the running turn was stopped' : 'task paused' }
278
- }
279
-
280
- // ── 暂停主逻辑(三粒度) ─────────────────────────────────────────────
281
-
282
- /**
283
- * 暂停一会话。opts: { mode:'safe'|'force', reason:'stop'|'wait' }。
284
- * 默认 safe+wait(对齐 DEFAULT_SETTINGS)。返回 { kind, text, needConfirmation? }。
285
- */
286
- function pauseTask(sessionId, opts = {}) {
287
- // F6:先解开 step 门,否则 safe 模式的 pendingPause 永远等不到安全边界事件。
288
- releaseStepGate(sessionId, 'pause')
289
- const agent = getAgent(sessionId)
290
- if (agent === undefined) return { kind: 'error', text: 'no live agent for this session — nothing to pause' }
291
- const current = currentPause(sessionId)
292
- if (current.paused) return { kind: 'success', text: 'task is already paused' }
293
- const mode = opts.mode ?? 'safe'
294
- const reason = opts.reason ?? 'wait'
295
- const resumeContent = agent.status === 'running' ? lastUserPrompt(agent) : null
296
-
297
- if (mode === 'force') {
298
- const interruptedTool = latestInflight(sessionId)
299
- if (agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
300
- clearInflight(sessionId)
301
- pauseSessionGoal(agent)
302
- markPaused(sessionId, resumeContent, {
303
- forced: true,
304
- interruptedTool: interruptedTool ? { name: interruptedTool.name, arguments: interruptedTool.arguments, callId: interruptedTool.callId } : null,
305
- })
306
- return {
307
- kind: 'success',
308
- text: interruptedTool !== null
309
- ? `task force-paused — interrupted tool ${interruptedTool.name}(预期目的:${describeToolPurpose(interruptedTool)}),可能已部分执行`
310
- : 'task force-paused — interrupted the running turn',
311
- }
312
- }
313
-
314
- // safe mode —— 延迟到安全边界
315
- if (agent.status === 'running' && inflightOf(sessionId).size > 0) {
316
- state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason })
317
- return { kind: 'success', text: 'task pausing — waiting for the running tool to finish (safe boundary), trace keeps recording until then' }
318
- }
319
- if (agent.status === 'running' && reason === 'wait') {
320
- state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason: 'wait' })
321
- return { kind: 'success', text: 'task pausing — waiting for the current reasoning to complete before pausing' }
322
- }
323
- return applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null })
324
- }
325
-
326
- // ── 恢复主逻辑 ───────────────────────────────────────────────────────
327
-
328
- /**
329
- * 恢复一会话(从暂停点继续,session log 即 trace,不整体重发)。
330
- * opts: { confirm:boolean, choice:'rerun'|'skip' }。
331
- */
332
- function resumeTask(sessionId, opts = {}) {
333
- // F6:step 门挂起时「恢复」等价于放行该 step(回合本来就还开着)。
334
- releaseStepGate(sessionId, 'resume')
335
- const agent = getAgent(sessionId)
336
- if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
337
- state.pendingPause.delete(sessionId)
338
- const current = currentPause(sessionId)
339
- if (!current.paused) return { kind: 'success', text: 'no paused task to resume' }
340
- // 0.1.5 compat:source 补 form: 'instructions'(0.1.5 ContextFormed 契约;旧版本忽略未知字段)
341
- const followup = (blocks) => enqueueFollowup(agent, makeFollowupMessage({ content: blocks, source: { kind: 'plugin', plugin: pluginId, form: 'instructions' } }), ctx)
342
-
343
- if (current.forced) {
344
- const tool = current.interruptedTool
345
- if (tool !== null) {
346
- if (opts?.confirm !== true) {
347
- return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停时工具 ${describeTool(tool)} 没有执行完成,将重新执行。请选择:重新执行该工具 / 跳过该工具 / 保持暂停。` }
348
- }
349
- const outcome = findToolOutcome(agent, tool.callId)
350
- clearPaused(sessionId)
351
- if (outcome !== null && !outcome.isError) {
352
- followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 实际已执行完成(结果见上方上下文)。请基于该结果继续执行,不要重复执行该工具。` }])
353
- return { kind: 'success', text: `task resumed — tool ${tool.name} had actually completed, continuing` }
354
- }
355
- if (outcome !== null && outcome.abortedBeforeDispatch) {
356
- if (opts?.choice === 'skip') {
357
- followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用),你选择跳过。请直接继续后续工作。` }])
358
- return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
359
- }
360
- followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用)。请执行该工具调用,然后继续任务。` }])
361
- return { kind: 'success', text: `task resumed — re-executing tool ${tool.name}` }
362
- }
363
- if (opts?.choice === 'skip') {
364
- followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行,状态未知)。你选择跳过该工具:请基于已有上下文继续任务,不再执行该工具。` }])
365
- return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
366
- }
367
- followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行并产生副作用,状态未知)。你选择重新执行:请先评估/清理该工具可能产生的部分副作用,再重新执行该工具调用,然后继续任务。` }])
368
- return { kind: 'success', text: `task resumed — re-executing interrupted tool ${tool.name}` }
369
- }
370
- clearPaused(sessionId)
371
- followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)继续执行任务。' }])
372
- return { kind: 'success', text: 'task resumed — continuing from the latest trace' }
373
- }
374
-
375
- // safe pause
376
- const deferred = Array.isArray(current.deferredTools) ? current.deferredTools : []
377
- if (deferred.length > 0) {
378
- if (opts?.confirm !== true) {
379
- return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停发生在推理完成后、工具执行前,以下工具未及执行(无副作用):${deferred.map(describeTool).join('、')}。请选择:重新执行 / 跳过 / 保持暂停。` }
380
- }
381
- clearPaused(sessionId)
382
- const rerun = deferred.filter((tool) => {
383
- const outcome = findToolOutcome(agent, tool.callId)
384
- return outcome === null || outcome.abortedBeforeDispatch === true
385
- })
386
- if (rerun.length === 0) {
387
- followup([{ type: 'text', text: '任务已恢复。上次暂停时待执行的工具均已实际执行完成,请基于以上结果继续执行,不要重复执行。' }])
388
- return { kind: 'success', text: 'task resumed — deferred tools had actually completed' }
389
- }
390
- if (opts?.choice === 'skip') {
391
- followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),你选择跳过:${rerun.map(describeTool).join('、')}。请直接继续后续工作。` }])
392
- return { kind: 'success', text: 'task resumed — skipped deferred tools' }
393
- }
394
- followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),请执行这些工具调用,然后继续任务:${rerun.map(describeTool).join('、')}。` }])
395
- return { kind: 'success', text: 'task resumed — re-executing deferred tools' }
396
- }
397
-
398
- clearPaused(sessionId)
399
- if (agent.status === 'running') {
400
- return { kind: 'success', text: 'task resumed — the task was still actually running; execution results keep flowing' }
401
- }
402
- followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)从暂停点继续执行,不要重复已完成的工作。' }])
403
- return { kind: 'success', text: 'task resumed — continuing from the pause point' }
404
- }
405
-
406
- // ── 取消 ─────────────────────────────────────────────────────────────
407
-
408
- /** 立即终止当前回合,回报被中断工具的目的/副作用风险。 */
409
- function cancelTask(sessionId) {
410
- // F6:取消 = 释放 step 门 + 中断回合(顺序无所谓,release 幂等)。
411
- releaseStepGate(sessionId, 'cancel')
412
- const agent = getAgent(sessionId)
413
- if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
414
- const current = currentPause(sessionId)
415
- if (current.paused) clearPaused(sessionId)
416
- state.pendingPause.delete(sessionId)
417
- let interrupted = null
418
- if (agent.status === 'running') {
419
- interrupted = latestInflight(sessionId)
420
- agent.cancel({ kind: 'user' }, { keepInbox: true })
421
- clearInflight(sessionId)
422
- }
423
- if (interrupted !== null) {
424
- return { kind: 'success', text: `task cancelled — 已立即终止正在执行的工具 ${interrupted.name}。其预期目的:${describeToolPurpose(interrupted)}。请检查该操作是否产生了副作用(文件修改、进程、网络等)。` }
425
- }
426
- return { kind: 'success', text: 'task cancelled' }
427
- }
428
-
429
- // ── 安全边界:session/event 监听落点 ─────────────────────────────────
430
-
431
- function scheduleDeferredPause(sessionId, resumeContent, deferredTools) {
432
- queueMicrotask(() => {
433
- try {
434
- const agent = getAgent(sessionId)
435
- if (agent === undefined) {
436
- state.pendingPause.delete(sessionId)
437
- return
438
- }
439
- applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null }, deferredTools)
440
- } catch (e) {
441
- ctx?.logger?.warn?.('[session-guard] deferred pause failed: ' + String(e))
442
- state.pendingPause.delete(sessionId)
443
- }
444
- })
445
- }
446
-
447
- function tryApplyPending(sessionId) {
448
- const pending = state.pendingPause.get(sessionId)
449
- if (pending === undefined) return
450
- if (pending.mode !== 'safe') return
451
- if (inflightOf(sessionId).size > 0) return
452
- scheduleDeferredPause(sessionId, pending.resumeContent ?? null, pending.deferredTools ?? null)
453
- }
454
-
455
- /** 会话事件监听:跟踪在途工具 + 在安全边界落地延迟暂停。 */
456
- function handleEvent(session, event) {
457
- const sessionId = session?.id
458
- if (typeof sessionId !== 'string') return
459
- if (event.type === 'tool/call') {
460
- const info = { name: event.data?.name ?? 'tool', arguments: event.data?.arguments ?? null, callId: event.data?.callId ?? 'unknown' }
461
- if (typeof event.data?.callId === 'string') inflightOf(sessionId).set(event.data.callId, info)
462
- return
463
- }
464
- if (event.type === 'tool/result') {
465
- // 双形态双读(见 readToolResultCallId):块上 toolCallId 优先,旧记录回退 source.callId。
466
- const callId = readToolResultCallId(event.data?.message)
467
- if (typeof callId === 'string') inflightOf(sessionId).delete(callId)
468
- tryApplyPending(sessionId)
469
- return
470
- }
471
- if (event.type === 'assistant/message') {
472
- const pending = state.pendingPause.get(sessionId)
473
- if (pending !== undefined && pending.mode === 'safe' && pending.reason === 'wait') {
474
- const content = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
475
- const calls = content.filter((block) => block?.type === 'tool-call').map((block) => ({
476
- name: block.name ?? 'tool',
477
- arguments: block.arguments ?? null,
478
- callId: block.id ?? 'unknown',
479
- }))
480
- if (calls.length > 0) pending.deferredTools = calls
481
- }
482
- tryApplyPending(sessionId)
483
- return
484
- }
485
- if (event.type === 'user/message') {
486
- const content = Array.isArray(event.data?.content) ? event.data.content : []
487
- for (const block of content) {
488
- const blockId = readToolResultBlockId(block)
489
- if (blockId !== undefined) inflightOf(sessionId).delete(blockId)
490
- }
491
- tryApplyPending(sessionId)
492
- return
493
- }
494
- }
495
-
496
- // ── 状态读取 ─────────────────────────────────────────────────────────
497
-
498
- function sessionState(sessionId) {
499
- const agent = getAgent(sessionId)
500
- const current = currentPause(sessionId)
501
- return {
502
- sessionId: String(sessionId),
503
- status: agent === undefined ? 'offline' : agent.status,
504
- paused: current.paused === true,
505
- forced: current.forced === true,
506
- interruptedTool: current.interruptedTool ?? null,
507
- deferredTools: current.deferredTools ?? null,
508
- resumeContent: current.resumeContent ?? null,
509
- }
510
- }
511
-
512
- return {
513
- pause: pauseTask,
514
- resume: resumeTask,
515
- cancel: cancelTask,
516
- state: sessionState,
517
- handleEvent,
518
- taskControlAvailable,
519
- /** 当前 in-flight 工具数(测试用)。 */
520
- _inflightCount: (id) => inflightOf(id).size,
521
- /** 是否挂起了延迟暂停(测试用)。 */
522
- _pendingCount: () => state.pendingPause.size,
523
- }
524
- }
1
+ /**
2
+ * dsh-session-guard — 自研会话门引擎(脱离 dsh-task-control,全量移植)。
3
+ *
4
+ * 真实锁定「session 推进」:复用 dsh runtime 自身的原语,不依赖任何外部会话门:
5
+ * - `agent.cancel({ kind:'user' }, { keepInbox:true })` —— 停住当前回合(中断推理/在途工具)。
6
+ * - `goals.pause(agent, { id, revision })` —— 停住同会话 goal,防 goal-round driver 再排。
7
+ * - `ctx.on('session/event')` —— 安全边界:tool/call 记 in-flight,tool/result 落地,
8
+ * assistant/message 记录 deferredTools,再落地延迟暂停(queueMicrotask)。
9
+ * - `agent.followup(<plugin user message>)` —— 恢复续跑指令(消息由本文件本地构造,见下)。
10
+ * - 暂停状态持久化(src/pause-store.js),不写 session log。
11
+ *
12
+ * 三粒度(对齐 task-control):
13
+ * force 立即中断推理+在途工具,记 interruptedTool,resume 需 confirm 选 rerun/skip
14
+ * safe + stop 在途工具跑完后再暂停(不中断推理则工具完成后落地)
15
+ * safe + wait 不中断推理,assistant/message 后记 deferredTools 再落地(默认)
16
+ *
17
+ * 设计为目标可单测:ctx / pauseStore / makeFollowupMessage 全部依赖注入,
18
+ * 停/续/取消判定不依赖真实 dsh runtime;`getAgent(sessionId)` 经 `ctx.agents.get` 懒取。
19
+ *
20
+ * VERSION-LINE(2026-09-27 订正):本仓 engines.dsh >=0.1.7-rc.1(会话格式 v4)。
21
+ * 下述扩展点的 0.1.1-rc.2/0.1.2-rc.1 双版本签名核对结论为历史依据,现役契约以
22
+ * v4 为准(署名 producer-owned kind、tool/result 为一等 role:'tool' 消息,
23
+ * 见 `tool-call-id.js` 头注的三形状代际表)。
24
+ * 唯一需要双读的是 `tool/result` 记录的调用 id 形态(见 readToolResultCallId):
25
+ * 0.1.1 与 0.1.2 的回放日志都同时保留 `content[].toolCallId` 与 `source.callId` 两种形态。
26
+ */
27
+ import { readToolResultBlockId, readToolResultCallId } from './tool-call-id.js'
28
+
29
+ /**
30
+ * 构造一条 plugin-source 的 user 消息(形状与 `dsh-llm` 的 `createUserMessage` 一致:
31
+ * `{ id, role:'user', content, source }`)。
32
+ *
33
+ * **为什么不用 `@deepseek-ai/dsh-llm` 的 `createUserMessage`**:那是平台包值导入,
34
+ * 既未在本插件 `dependencies` 中声明(属未声明运行时依赖),也违反本仓库
35
+ * 「host 端只 value-import `@deepseek-ai/schemastery`」的约定(findings 8.4)。
36
+ * `agent.followup` 只把消息放进 inbox(`send → inbox.splice`),不要求冻结或品牌 id,
37
+ * 因此本地构造等价且无依赖。retry.js / wiring.js 已用同一形状。
38
+ * @param {{content: unknown, source: object}} input
39
+ * @returns {{id: string, role: 'user', content: unknown, source: object}}
40
+ */
41
+ export function createPluginUserMessage({ content, source }) {
42
+ const cryptoObj = globalThis.crypto
43
+ const id =
44
+ cryptoObj && typeof cryptoObj.randomUUID === 'function'
45
+ ? cryptoObj.randomUUID()
46
+ : `session-guard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
47
+ return { id, role: 'user', content, source }
48
+ }
49
+
50
+ /**
51
+ * 恢复消息入队(双路径;双路径判定沿自 0.1.5 代的兼容实测,现役线为 v4)。
52
+ *
53
+ * 0.1.5 把 Inbox 从独立服务改为 agent-loop 只读投影(消息入队走 session 事件),
54
+ * `agent.followup` 是否保留无法静态确认,因此运行时检测:
55
+ * 1. `agent.followup(msg)` —— ≤0.1.4 与(若保留的)0.1.5 首选路径;
56
+ * 2. `agent.send(msg)` —— followup 被移除时的回退;
57
+ * 3. 两者皆无 —— 记 warn 并返回 false,绝不让 resume 因入队失败而抛错。
58
+ * @param {object} agent 目标 agent(live agent)
59
+ * @param {{id: string, role: 'user', content: unknown, source: object}} message 本地构造的 user 消息
60
+ * @param {object} [ctx] host context(仅用于 logger)
61
+ * @returns {boolean} 是否成功入队
62
+ */
63
+ export function enqueueFollowup(agent, message, ctx) {
64
+ if (typeof agent.followup === 'function') {
65
+ agent.followup(message)
66
+ return true
67
+ }
68
+ if (typeof agent.send === 'function') {
69
+ agent.send(message)
70
+ return true
71
+ }
72
+ try {
73
+ ctx?.logger?.warn?.('[session-guard] agent has no followup/send; resume message dropped')
74
+ } catch {
75
+ /* logger 不可用时静默 */
76
+ }
77
+ return false
78
+ }
79
+
80
+ /**
81
+ * @param {object} deps
82
+ * @param {object} deps.ctx host context(含 agents.get / get / logger,可选 goals)
83
+ * @param {ReturnType<import('./pause-store.js').createPauseStore>} deps.pauseStore 暂停状态存储
84
+ * @param {string} [deps.pluginId] followup 消息的 source.plugin(默认 'session-guard')
85
+ * @param {(input: {content: object, source: object}) => unknown} [deps.makeFollowupMessage] 恢复消息构造(默认本地构造;测试注入)
86
+ * @param {ReturnType<import('./step-gate.js').createStepGate>} [deps.stepGate] step 级门控(v0.2.0)
87
+ *
88
+ * **为什么 pause/resume/cancel 必须先释放 step gate(F6 死锁)**:step 门控挂起在
89
+ * `agent/pre-step`,此刻既不会产生 `assistant/message` 也不会产生 `tool/result`;
90
+ * 而 safe+wait / safe+stop 都靠这两个事件落地暂停 → 两边互等,会话永久卡死。
91
+ */
92
+ export function createPauseGate({ ctx, pauseStore, pluginId = 'session-guard', makeFollowupMessage = createPluginUserMessage, stepGate }) {
93
+ const state = { inFlight: new Map(), pendingPause: new Map() }
94
+ const getAgent = (sessionId) => (ctx && typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined)
95
+
96
+ /** 先解开 step 门(若存在),再走 turn 级逻辑;任何异常都不阻断暂停动作。 */
97
+ function releaseStepGate(sessionId, reason) {
98
+ if (!stepGate || typeof stepGate.release !== 'function') return
99
+ try {
100
+ stepGate.release(sessionId, reason)
101
+ } catch (e) {
102
+ ctx?.logger?.warn?.('[session-guard] step gate release failed: ' + String(e))
103
+ }
104
+ }
105
+
106
+ /** 会话门(dsh-task-control)当前是否可用(兼容探测;自研后始终视为可用)。 */
107
+ const taskControlAvailable = () => true
108
+
109
+ // ── in-flight 工具跟踪 ──────────────────────────────────────────────
110
+
111
+ function inflightOf(sessionId) {
112
+ let map = state.inFlight.get(sessionId)
113
+ if (map === undefined) {
114
+ map = new Map()
115
+ state.inFlight.set(sessionId, map)
116
+ }
117
+ return map
118
+ }
119
+
120
+ function latestInflight(sessionId) {
121
+ const map = inflightOf(sessionId)
122
+ const entries = [...map.values()]
123
+ return entries.length > 0 ? entries[entries.length - 1] : null
124
+ }
125
+
126
+ function clearInflight(sessionId) {
127
+ state.inFlight.delete(sessionId)
128
+ }
129
+
130
+ // ── 工具描述 / 结果查阅(resume 断点决策) ────────────────────────────
131
+
132
+ /** 描述工具目的:优先 model 写的 description,再 bash command,再 compact args。 */
133
+ function describeToolPurpose(info) {
134
+ if (info === undefined || info === null) return '未知工具'
135
+ const raw = info.arguments
136
+ const parseArgs = (text) => {
137
+ try {
138
+ return JSON.parse(text)
139
+ } catch {
140
+ return null
141
+ }
142
+ }
143
+ const args = typeof raw === 'string' ? parseArgs(raw) : raw
144
+ if (args !== null && typeof args.description === 'string' && args.description.length > 0) return args.description
145
+ if (info.name === 'bash' && args !== null && typeof args.command === 'string') return `运行命令:${args.command}`
146
+ if (args !== null) {
147
+ try {
148
+ return JSON.stringify(args).slice(0, 120)
149
+ } catch {
150
+ /* ignore */
151
+ }
152
+ }
153
+ return `工具 ${info.name}`
154
+ }
155
+
156
+ function describeTool(info) {
157
+ return `${info?.name ?? '未知工具'}(${describeToolPurpose(info)})`
158
+ }
159
+
160
+ /**
161
+ * `tool/result` 调用 id 的多形态读取已抽到 `./tool-call-id.js`(零依赖、可单测),
162
+ * 三形状代际(详见其头注):v4 native 顶层 `message.toolCallId` 优先 →
163
+ * v3 wrapper `content[].toolCallId` → 遗留 `source.callId`。勿改成单读——
164
+ * 任一单读都会在其它代际的记录上丢 id(in-flight 工具无法落地/暂停点判定失效)。
165
+ */
166
+
167
+ /**
168
+ * 读取会话事件数组。DSH 0.1.5 移除了 `session.events` 数组访问器,改为
169
+ * `snapshotEvents()`(compatibility-guide §20.3 实测表);本专线以
170
+ * snapshotEvents() 为主,events 数组仅作回退防御,两者皆缺时返回 null
171
+ * (fail-open,与既有防御语义一致)。
172
+ */
173
+ function readSessionEvents(session) {
174
+ if (!session) return null
175
+ if (typeof session.snapshotEvents === 'function') {
176
+ try {
177
+ const events = session.snapshotEvents()
178
+ return Array.isArray(events) ? events : null
179
+ } catch {
180
+ return null
181
+ }
182
+ }
183
+ return Array.isArray(session.events) ? session.events : null
184
+ }
185
+
186
+ /** 在 session log 查一个中断工具的实际结果(kernel 会 drain 已启动工具到 tool/result)。 */
187
+ function findToolOutcome(agent, callId) {
188
+ const events = readSessionEvents(agent?.session)
189
+ if (!events) return null
190
+ let outcome = null
191
+ for (const event of events) {
192
+ if (event.type === 'tool/result') {
193
+ const message = event.data?.message ?? {}
194
+ // v4 native(≥0.1.7-rc.1):一等 role:'tool' 消息,toolCallId/isError 在
195
+ // message 顶层(宿主 assertBlock 硬拒 `type:'tool-result'` 块)。native
196
+ // 分支是强制新增——漏掉它 isError 恒 false,恢复 followup 会把失败工具
197
+ // 误报为"已执行完成,不要重复执行"。
198
+ if (typeof message.toolCallId === 'string') {
199
+ if (message.toolCallId === callId) {
200
+ outcome = {
201
+ hasResult: true,
202
+ isError: message.isError === true,
203
+ abortedBeforeDispatch: event.data?.error?.code === 'ABORTED_BEFORE_DISPATCH',
204
+ content: Array.isArray(message.content) ? message.content : [],
205
+ }
206
+ }
207
+ continue
208
+ }
209
+ // v3 wrapper(未迁移日志的历史兼容)。
210
+ const block = (Array.isArray(message.content) ? message.content : []).find((b) => b?.type === 'tool-result')
211
+ const id = readToolResultCallId(message)
212
+ if (id === callId) {
213
+ outcome = {
214
+ hasResult: true,
215
+ isError: block?.isError === true,
216
+ abortedBeforeDispatch: event.data?.error?.code === 'ABORTED_BEFORE_DISPATCH',
217
+ content: block?.content ?? [],
218
+ }
219
+ }
220
+ } else if (event.type === 'user/message') {
221
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
222
+ for (const block of content) {
223
+ if (readToolResultBlockId(block) === callId) {
224
+ outcome = { hasResult: true, isError: block.isError === true, abortedBeforeDispatch: false, content: block.content ?? [] }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ return outcome
230
+ }
231
+
232
+ function lastUserPrompt(agent) {
233
+ const events = readSessionEvents(agent?.session)
234
+ if (!events) return null
235
+ for (let index = events.length - 1; index >= 0; index -= 1) {
236
+ const event = events[index]
237
+ if (event.type !== 'user/message') continue
238
+ if (event.data?.source?.kind !== 'user') continue
239
+ return event.data.content ?? null
240
+ }
241
+ return null
242
+ }
243
+
244
+ // ── 暂停状态读写 ────────────────────────────────────────────────────
245
+
246
+ function currentPause(id) {
247
+ return pauseStore.current(id)
248
+ }
249
+
250
+ function markPaused(id, resumeContent, forcedContext, deferredTools) {
251
+ const snapshot = {
252
+ sessionId: String(id),
253
+ paused: true,
254
+ resumeContent: resumeContent ?? null,
255
+ forced: forcedContext?.forced === true,
256
+ interruptedTool: forcedContext?.interruptedTool ?? null,
257
+ deferredTools: deferredTools ?? null,
258
+ updatedAt: Date.now(),
259
+ }
260
+ pauseStore.set(id, snapshot)
261
+ return snapshot
262
+ }
263
+
264
+ function clearPaused(id) {
265
+ pauseStore.clear(id)
266
+ }
267
+
268
+ // ── goal 暂停(防 goal-round driver 再排同会话轮次) ──────────────────
269
+
270
+ function pauseSessionGoal(agent) {
271
+ try {
272
+ const goals = ctx && typeof ctx.get === 'function' ? ctx.get('goals') : undefined
273
+ if (goals === undefined) return
274
+ const goal = goals.get(agent)
275
+ if (goal !== undefined && goal.phase === 'active') {
276
+ goals.pause(agent, { id: goal.id, revision: goal.revision })
277
+ }
278
+ } catch (e) {
279
+ ctx?.logger?.warn?.('[session-guard] goal pause failed: ' + String(e))
280
+ }
281
+ }
282
+
283
+ // ── 立即落地暂停 ────────────────────────────────────────────────────
284
+
285
+ /** 立即停止运行回合(agent.cancel 保 inbox)+ 停 goal + 持久化快照。 */
286
+ function applyPauseNow(id, resumeContent, forcedContext, deferredTools) {
287
+ state.pendingPause.delete(id)
288
+ const current = currentPause(id)
289
+ if (current.paused) return { kind: 'success', text: 'task is already paused' }
290
+ const agent = getAgent(id)
291
+ if (agent !== undefined && agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
292
+ clearInflight(id)
293
+ if (agent !== undefined) pauseSessionGoal(agent)
294
+ markPaused(id, resumeContent, forcedContext, deferredTools)
295
+ return { kind: 'success', text: agent !== undefined && agent.status === 'running' ? 'task paused — the running turn was stopped' : 'task paused' }
296
+ }
297
+
298
+ // ── 暂停主逻辑(三粒度) ─────────────────────────────────────────────
299
+
300
+ /**
301
+ * 暂停一会话。opts: { mode:'safe'|'force', reason:'stop'|'wait' }。
302
+ * 默认 safe+wait(对齐 DEFAULT_SETTINGS)。返回 { kind, text, needConfirmation? }。
303
+ */
304
+ function pauseTask(sessionId, opts = {}) {
305
+ // F6:先解开 step 门,否则 safe 模式的 pendingPause 永远等不到安全边界事件。
306
+ releaseStepGate(sessionId, 'pause')
307
+ const agent = getAgent(sessionId)
308
+ if (agent === undefined) return { kind: 'error', text: 'no live agent for this session — nothing to pause' }
309
+ const current = currentPause(sessionId)
310
+ if (current.paused) return { kind: 'success', text: 'task is already paused' }
311
+ const mode = opts.mode ?? 'safe'
312
+ const reason = opts.reason ?? 'wait'
313
+ const resumeContent = agent.status === 'running' ? lastUserPrompt(agent) : null
314
+
315
+ if (mode === 'force') {
316
+ const interruptedTool = latestInflight(sessionId)
317
+ if (agent.status === 'running') agent.cancel({ kind: 'user' }, { keepInbox: true })
318
+ clearInflight(sessionId)
319
+ pauseSessionGoal(agent)
320
+ markPaused(sessionId, resumeContent, {
321
+ forced: true,
322
+ interruptedTool: interruptedTool ? { name: interruptedTool.name, arguments: interruptedTool.arguments, callId: interruptedTool.callId } : null,
323
+ })
324
+ return {
325
+ kind: 'success',
326
+ text: interruptedTool !== null
327
+ ? `task force-paused — interrupted tool ${interruptedTool.name}(预期目的:${describeToolPurpose(interruptedTool)}),可能已部分执行`
328
+ : 'task force-paused — interrupted the running turn',
329
+ }
330
+ }
331
+
332
+ // safe mode —— 延迟到安全边界
333
+ if (agent.status === 'running' && inflightOf(sessionId).size > 0) {
334
+ state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason })
335
+ return { kind: 'success', text: 'task pausing — waiting for the running tool to finish (safe boundary), trace keeps recording until then' }
336
+ }
337
+ if (agent.status === 'running' && reason === 'wait') {
338
+ state.pendingPause.set(sessionId, { resumeContent, mode: 'safe', reason: 'wait' })
339
+ return { kind: 'success', text: 'task pausing — waiting for the current reasoning to complete before pausing' }
340
+ }
341
+ return applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null })
342
+ }
343
+
344
+ // ── 恢复主逻辑 ───────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * 恢复一会话(从暂停点继续,session log 即 trace,不整体重发)。
348
+ * opts: { confirm:boolean, choice:'rerun'|'skip' }。
349
+ */
350
+ function resumeTask(sessionId, opts = {}) {
351
+ // F6:step 门挂起时「恢复」等价于放行该 step(回合本来就还开着)。
352
+ releaseStepGate(sessionId, 'resume')
353
+ const agent = getAgent(sessionId)
354
+ if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
355
+ state.pendingPause.delete(sessionId)
356
+ const current = currentPause(sessionId)
357
+ if (!current.paused) return { kind: 'success', text: 'no paused task to resume' }
358
+ // form: 'instructions' —— 0.1.5 引入的 ContextFormed 语义字段(旧版本忽略未知字段);
359
+ // 署名 kind 已按 v4(≥0.1.7-rc.1)适配为 producer-owned `plugin:${pluginId}`
360
+ const followup = (blocks) => enqueueFollowup(agent, makeFollowupMessage({ content: blocks, source: { kind: `plugin:${pluginId}`, form: 'instructions' } }), ctx)
361
+
362
+ if (current.forced) {
363
+ const tool = current.interruptedTool
364
+ if (tool !== null) {
365
+ if (opts?.confirm !== true) {
366
+ return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停时工具 ${describeTool(tool)} 没有执行完成,将重新执行。请选择:重新执行该工具 / 跳过该工具 / 保持暂停。` }
367
+ }
368
+ const outcome = findToolOutcome(agent, tool.callId)
369
+ clearPaused(sessionId)
370
+ if (outcome !== null && !outcome.isError) {
371
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 实际已执行完成(结果见上方上下文)。请基于该结果继续执行,不要重复执行该工具。` }])
372
+ return { kind: 'success', text: `task resumed — tool ${tool.name} had actually completed, continuing` }
373
+ }
374
+ if (outcome !== null && outcome.abortedBeforeDispatch) {
375
+ if (opts?.choice === 'skip') {
376
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用),你选择跳过。请直接继续后续工作。` }])
377
+ return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
378
+ }
379
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 未及执行(无副作用)。请执行该工具调用,然后继续任务。` }])
380
+ return { kind: 'success', text: `task resumed — re-executing tool ${tool.name}` }
381
+ }
382
+ if (opts?.choice === 'skip') {
383
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行,状态未知)。你选择跳过该工具:请基于已有上下文继续任务,不再执行该工具。` }])
384
+ return { kind: 'success', text: `task resumed — skipped tool ${tool.name}` }
385
+ }
386
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时工具 ${describeTool(tool)} 没有执行完成(可能已部分执行并产生副作用,状态未知)。你选择重新执行:请先评估/清理该工具可能产生的部分副作用,再重新执行该工具调用,然后继续任务。` }])
387
+ return { kind: 'success', text: `task resumed — re-executing interrupted tool ${tool.name}` }
388
+ }
389
+ clearPaused(sessionId)
390
+ followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)继续执行任务。' }])
391
+ return { kind: 'success', text: 'task resumed — continuing from the latest trace' }
392
+ }
393
+
394
+ // safe pause
395
+ const deferred = Array.isArray(current.deferredTools) ? current.deferredTools : []
396
+ if (deferred.length > 0) {
397
+ if (opts?.confirm !== true) {
398
+ return { kind: 'error', needConfirmation: true, text: `需要确认:上次暂停发生在推理完成后、工具执行前,以下工具未及执行(无副作用):${deferred.map(describeTool).join('、')}。请选择:重新执行 / 跳过 / 保持暂停。` }
399
+ }
400
+ clearPaused(sessionId)
401
+ const rerun = deferred.filter((tool) => {
402
+ const outcome = findToolOutcome(agent, tool.callId)
403
+ return outcome === null || outcome.abortedBeforeDispatch === true
404
+ })
405
+ if (rerun.length === 0) {
406
+ followup([{ type: 'text', text: '任务已恢复。上次暂停时待执行的工具均已实际执行完成,请基于以上结果继续执行,不要重复执行。' }])
407
+ return { kind: 'success', text: 'task resumed — deferred tools had actually completed' }
408
+ }
409
+ if (opts?.choice === 'skip') {
410
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),你选择跳过:${rerun.map(describeTool).join('、')}。请直接继续后续工作。` }])
411
+ return { kind: 'success', text: 'task resumed — skipped deferred tools' }
412
+ }
413
+ followup([{ type: 'text', text: `任务已恢复。上次暂停时以下工具未及执行(无副作用),请执行这些工具调用,然后继续任务:${rerun.map(describeTool).join('、')}。` }])
414
+ return { kind: 'success', text: 'task resumed — re-executing deferred tools' }
415
+ }
416
+
417
+ clearPaused(sessionId)
418
+ if (agent.status === 'running') {
419
+ return { kind: 'success', text: 'task resumed — the task was still actually running; execution results keep flowing' }
420
+ }
421
+ followup([{ type: 'text', text: '任务已恢复。请基于以上上下文(暂停点之前的完整执行记录)从暂停点继续执行,不要重复已完成的工作。' }])
422
+ return { kind: 'success', text: 'task resumed — continuing from the pause point' }
423
+ }
424
+
425
+ // ── 取消 ─────────────────────────────────────────────────────────────
426
+
427
+ /** 立即终止当前回合,回报被中断工具的目的/副作用风险。 */
428
+ function cancelTask(sessionId) {
429
+ // F6:取消 = 释放 step 门 + 中断回合(顺序无所谓,release 幂等)。
430
+ releaseStepGate(sessionId, 'cancel')
431
+ const agent = getAgent(sessionId)
432
+ if (agent === undefined) return { kind: 'error', text: 'no live agent for this session' }
433
+ const current = currentPause(sessionId)
434
+ if (current.paused) clearPaused(sessionId)
435
+ state.pendingPause.delete(sessionId)
436
+ let interrupted = null
437
+ if (agent.status === 'running') {
438
+ interrupted = latestInflight(sessionId)
439
+ agent.cancel({ kind: 'user' }, { keepInbox: true })
440
+ clearInflight(sessionId)
441
+ }
442
+ if (interrupted !== null) {
443
+ return { kind: 'success', text: `task cancelled — 已立即终止正在执行的工具 ${interrupted.name}。其预期目的:${describeToolPurpose(interrupted)}。请检查该操作是否产生了副作用(文件修改、进程、网络等)。` }
444
+ }
445
+ return { kind: 'success', text: 'task cancelled' }
446
+ }
447
+
448
+ // ── 安全边界:session/event 监听落点 ─────────────────────────────────
449
+
450
+ function scheduleDeferredPause(sessionId, resumeContent, deferredTools) {
451
+ queueMicrotask(() => {
452
+ try {
453
+ const agent = getAgent(sessionId)
454
+ if (agent === undefined) {
455
+ state.pendingPause.delete(sessionId)
456
+ return
457
+ }
458
+ applyPauseNow(sessionId, resumeContent, { forced: false, interruptedTool: null }, deferredTools)
459
+ } catch (e) {
460
+ ctx?.logger?.warn?.('[session-guard] deferred pause failed: ' + String(e))
461
+ state.pendingPause.delete(sessionId)
462
+ }
463
+ })
464
+ }
465
+
466
+ function tryApplyPending(sessionId) {
467
+ const pending = state.pendingPause.get(sessionId)
468
+ if (pending === undefined) return
469
+ if (pending.mode !== 'safe') return
470
+ if (inflightOf(sessionId).size > 0) return
471
+ scheduleDeferredPause(sessionId, pending.resumeContent ?? null, pending.deferredTools ?? null)
472
+ }
473
+
474
+ /** 会话事件监听:跟踪在途工具 + 在安全边界落地延迟暂停。 */
475
+ function handleEvent(session, event) {
476
+ const sessionId = session?.id
477
+ if (typeof sessionId !== 'string') return
478
+ if (event.type === 'tool/call') {
479
+ const info = { name: event.data?.name ?? 'tool', arguments: event.data?.arguments ?? null, callId: event.data?.callId ?? 'unknown' }
480
+ if (typeof event.data?.callId === 'string') inflightOf(sessionId).set(event.data.callId, info)
481
+ return
482
+ }
483
+ if (event.type === 'tool/result') {
484
+ // 三形状双读以上(见 readToolResultCallId):v4 native 顶层 toolCallId 优先,
485
+ // v3 wrapper 块上次之,旧记录再回退 source.callId。
486
+ const callId = readToolResultCallId(event.data?.message)
487
+ if (typeof callId === 'string') inflightOf(sessionId).delete(callId)
488
+ tryApplyPending(sessionId)
489
+ return
490
+ }
491
+ if (event.type === 'assistant/message') {
492
+ const pending = state.pendingPause.get(sessionId)
493
+ if (pending !== undefined && pending.mode === 'safe' && pending.reason === 'wait') {
494
+ const content = Array.isArray(event.data?.message?.content) ? event.data.message.content : []
495
+ const calls = content.filter((block) => block?.type === 'tool-call').map((block) => ({
496
+ name: block.name ?? 'tool',
497
+ arguments: block.arguments ?? null,
498
+ callId: block.id ?? 'unknown',
499
+ }))
500
+ if (calls.length > 0) pending.deferredTools = calls
501
+ }
502
+ tryApplyPending(sessionId)
503
+ return
504
+ }
505
+ if (event.type === 'user/message') {
506
+ const content = Array.isArray(event.data?.content) ? event.data.content : []
507
+ for (const block of content) {
508
+ const blockId = readToolResultBlockId(block)
509
+ if (blockId !== undefined) inflightOf(sessionId).delete(blockId)
510
+ }
511
+ tryApplyPending(sessionId)
512
+ return
513
+ }
514
+ }
515
+
516
+ // ── 状态读取 ─────────────────────────────────────────────────────────
517
+
518
+ function sessionState(sessionId) {
519
+ const agent = getAgent(sessionId)
520
+ const current = currentPause(sessionId)
521
+ return {
522
+ sessionId: String(sessionId),
523
+ status: agent === undefined ? 'offline' : agent.status,
524
+ paused: current.paused === true,
525
+ forced: current.forced === true,
526
+ interruptedTool: current.interruptedTool ?? null,
527
+ deferredTools: current.deferredTools ?? null,
528
+ resumeContent: current.resumeContent ?? null,
529
+ }
530
+ }
531
+
532
+ return {
533
+ pause: pauseTask,
534
+ resume: resumeTask,
535
+ cancel: cancelTask,
536
+ state: sessionState,
537
+ handleEvent,
538
+ taskControlAvailable,
539
+ /** 当前 in-flight 工具数(测试用)。 */
540
+ _inflightCount: (id) => inflightOf(id).size,
541
+ /** 是否挂起了延迟暂停(测试用)。 */
542
+ _pendingCount: () => state.pendingPause.size,
543
+ }
544
+ }