@gavinc-cn/touchstone-dsh 0.1.0 → 0.2.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/LICENSE +0 -0
- package/README.md +22 -1
- package/README.zh.md +12 -1
- package/agents.py +0 -0
- package/auth.py +0 -0
- package/board.py +1119 -93
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +590 -17
- package/db.py +63 -12
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +8 -5
- package/dsh-plugin/lib/agent-driver.js +895 -126
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +399 -20
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +0 -0
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +100 -6
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +1226 -42
- package/feishu_conv.py +920 -0
- package/lib.py +0 -0
- package/lifecycle.py +0 -0
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +8 -3
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +2 -1
- package/runner.py +11 -0
- package/server.py +264 -30
- package/sessparse.py +244 -44
- package/touchstone.py +8 -4
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +484 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +1 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +100 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* 对外契约(全部挂在 webServer 的 `${DRIVER_PREFIX}` 前缀下,仅回环可达 + 令牌校验):
|
|
15
15
|
* GET /touchstone-agent/health 就绪探测(agents 服务是否可用)
|
|
16
16
|
* GET /touchstone-agent/live 当前可见会话表(含外部直跑会话,零 REST 探测)
|
|
17
|
+
* + `complete`:宿主已有会话是否已枚举(A 批)
|
|
17
18
|
* GET /touchstone-agent/status?session_id= 单会话状态 + last_seq(SSE 续传基准)
|
|
18
19
|
* GET /touchstone-agent/events?session_id=&since= SSE 事件流(首发 ring 内 seq>since 的帧)
|
|
19
20
|
* POST /touchstone-agent/session 建会话或恢复会话 {cwd, task, session_id?, model?, provider?}
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
* POST /touchstone-agent/dispose 释放常驻会话(可选连会话存储一起删)
|
|
24
25
|
* GET /touchstone-agent/archived 宿主归档集整表(看板「已完成」与 dsh 会话归档同步)
|
|
25
26
|
* POST /touchstone-agent/archive {session_id, archived} 归档/取消归档(幂等)
|
|
27
|
+
* POST /touchstone-agent/watch {session_id, on?} 看管声明(外部会话注入开关,C 批)
|
|
26
28
|
*
|
|
27
29
|
* 状态帧(scope=state)新增 `driver/archived`:归档集**整表快照**(不是增量),
|
|
28
30
|
* 变化时推一帧(`domain/changed` 事件为主 + keepalive 15s 兜底比对);平台侧
|
|
@@ -57,6 +59,31 @@ const QUESTION_CALL_MAX = 64;
|
|
|
57
59
|
/** SSE keepalive 注释帧间隔(毫秒):防中间层按空闲超时切断长连 */
|
|
58
60
|
const KEEPALIVE_MS = 15000;
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 宿主已有会话枚举的**兜底重试**间隔(毫秒;A 批 2026-10-08)。
|
|
64
|
+
*
|
|
65
|
+
* apply 瞬间宿主可能仍在恢复工作区(`sessions` 服务尚未挂上),首轮枚举会失败;
|
|
66
|
+
* 1s 后再枚举一次即可补上(枚举幂等,重复调用只做覆盖写)。真机实测:热重载后
|
|
67
|
+
* 驱动实例重建 ⇒ `observed` 全空而 `/live` 恒空,平台可见性整体丢失(见
|
|
68
|
+
* bug_report/20261008_1935),本兜底是「首轮没赶上」的第二道网。
|
|
69
|
+
*/
|
|
70
|
+
const ENUMERATE_RETRY_MS = 1000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 归档集兜底扫描间隔(毫秒;纯内存读,不产生任何 HTTP/磁盘 IO)。
|
|
74
|
+
*
|
|
75
|
+
* 2026-10-07 实测定因:宿主 `WorkspaceRegistry.setState()` 的顺序是
|
|
76
|
+
* `await global.set(state)` → `this.state = state`,而 `domain/changed` 正是在
|
|
77
|
+
* `global.set` 内部**同步**发出的 ⇒ 监听器执行时 registry 的
|
|
78
|
+
* `archivedSessionIds` 读到的仍是**旧快照**(官方 registry 的内存缓存尚未刷新)。
|
|
79
|
+
* 旧实现据此比对 key 判定「没变化」而早退,一次帧都不推——dsh 侧归档只能等
|
|
80
|
+
* 15s 的 `_keepalive` 兜底(真机四组实测:归档 RPC 13~19ms 返回,帧延迟
|
|
81
|
+
* 1.1/6.1/6.1/13.8s)。事件路径已改为**直接取事件载荷**里的权威新值
|
|
82
|
+
* (见 `_onDomainChanged`),本定时器只作二道兜底:事件再丢/形态再变时,
|
|
83
|
+
* 变化也能在 3s 内推出去(原兜底是 15s)。
|
|
84
|
+
*/
|
|
85
|
+
const ARCHIVE_SWEEP_MS = 3000;
|
|
86
|
+
|
|
60
87
|
/** 请求体上限(字节):提示词可能很长,给足余量 */
|
|
61
88
|
const MAX_BODY = 8 * 1024 * 1024;
|
|
62
89
|
|
|
@@ -182,21 +209,45 @@ export class AgentDriver {
|
|
|
182
209
|
this.sessions = new Map();
|
|
183
210
|
/** 全局可见会话(含用户自己直跑的):外部会话发现通道,替代逐会话 REST 探测 */
|
|
184
211
|
this.observed = new Map();
|
|
212
|
+
/**
|
|
213
|
+
* 平台**显式声明**看管的会话(C 批,2026-10-10):`POST /watch` 写入,`/health.watched` 报数。
|
|
214
|
+
*
|
|
215
|
+
* 为什么必须由平台显式声明,而不是「宿主里凡非池内会话一律纳管」:宿主进程里
|
|
216
|
+
* 有大量与 Touchstone 无关的会话(用户自己开的、其它插件建的、子代理派生的),
|
|
217
|
+
* 按推断纳管等于给平台开一个「向任意会话注入消息与作答」的口子。只有声明过的
|
|
218
|
+
* sid 才是平台可回落投递(T2)、认领提问(T3)、接管审批(T4)的对象;平台撤销
|
|
219
|
+
* (`on:false`)或宿主会话销毁即收口,看管表不会只涨不消。
|
|
220
|
+
*/
|
|
221
|
+
this.watched = new Set();
|
|
185
222
|
/** agents 服务就绪标记(未就绪时路由返回 503,Python 侧据此降级为 CLI 形态) */
|
|
186
223
|
this.ready = false;
|
|
187
224
|
this.agentCtx = null;
|
|
188
225
|
this.fiber = null;
|
|
189
226
|
this._routes = [];
|
|
190
227
|
this._timer = null;
|
|
228
|
+
/** 归档集快速兜底扫描定时器(见 ARCHIVE_SWEEP_MS) */
|
|
229
|
+
this._sweepTimer = null;
|
|
191
230
|
this._userMessageFactory = null;
|
|
192
231
|
/** 平台侧审批 id 序号(`ap-<n>`;ApprovalRequestEvent 无 id,见 _onApprovalRequest) */
|
|
193
232
|
this._approvalSeq = 0;
|
|
194
233
|
/**
|
|
195
|
-
* 插件**认领中**的提问:callId → {sid, mark, resolve, reject, finish, ...}
|
|
196
|
-
* (2026-10-04 修 #791
|
|
197
|
-
*
|
|
234
|
+
* 插件**认领中**的提问:callId → {sid, mark, resolve, reject, finish, lane, ...}
|
|
235
|
+
* (2026-10-04 修 #791 认领;2026-10-06 修 #837 起改为**双通道**:平台经
|
|
236
|
+
* `/answer` 兑现,dsh GUI 的原生作答同样兑现——先到者胜,见 `_holdQuestion`)。
|
|
237
|
+
* 只装平台自持会话的提问;兑现或中止即从表里摘除;外部会话的提问一律让位
|
|
238
|
+
* 原生作答者,不入本表。
|
|
198
239
|
*/
|
|
199
240
|
this.questions = new Map();
|
|
241
|
+
/**
|
|
242
|
+
* 插件**认领中**的审批(C 批 T4,2026-10-10):sid → {id, mark, resolve, reject, finish, lane}
|
|
243
|
+
* ——与 `questions` 平行的一套,承载**看管的外部会话**的审批双通道(`_holdApproval`)。
|
|
244
|
+
*
|
|
245
|
+
* 为什么不并进 `entry.pendingApproval`:池内会话(平台自持)的审批认领走的是池条目上的
|
|
246
|
+
* 既有单槽 `pendingApproval`(`/status` 的 `pending_approval`、`_approval` 的池内分支都
|
|
247
|
+
* 读它),而看管的外部会话**没有池条目**(不接管),故另立一张按 sid 索引的表。
|
|
248
|
+
* 池内路径一字未动,两套并存、互不影响;兑现或收口即从表里摘除。
|
|
249
|
+
*/
|
|
250
|
+
this.approvals = new Map();
|
|
200
251
|
/**
|
|
201
252
|
* `ask_user_question` 的 tool/call 登记:callId → {sid, at}(bounded)。
|
|
202
253
|
* 两个用途:① legacy 模式提问不带 `wait.callId`,用真实 tool callId 作平台侧
|
|
@@ -223,6 +274,15 @@ export class AgentDriver {
|
|
|
223
274
|
* 的最近一次已发布值(换行拼接的 key,便于整表比对)。null=尚未取到。
|
|
224
275
|
*/
|
|
225
276
|
this._archivedKey = null;
|
|
277
|
+
/**
|
|
278
|
+
* 「宿主已有会话已枚举」标志(A 批 2026-10-08):只有在成功读完宿主 `sessions`
|
|
279
|
+
* 服务的 store 之后才为 true,`/live` 据此声明 `complete`——Python 侧只有见到
|
|
280
|
+
* `complete:true` 才敢把**空快照**当成「宿主里确实没有会话」(否则一律按未知
|
|
281
|
+
* 处理:不写列、不推断,见 dshevents._align)。枚举失败时保持 false。
|
|
282
|
+
*/
|
|
283
|
+
this._enumerated = false;
|
|
284
|
+
/** 枚举兜底重试定时器(见 ENUMERATE_RETRY_MS) */
|
|
285
|
+
this._enumerateTimer = null;
|
|
226
286
|
}
|
|
227
287
|
|
|
228
288
|
/**
|
|
@@ -238,7 +298,11 @@ export class AgentDriver {
|
|
|
238
298
|
agentCtx.on('session/created', (session) => this._observe(session));
|
|
239
299
|
agentCtx.on('session/disposed', (session) => {
|
|
240
300
|
const sid = String(session.id);
|
|
301
|
+
// 在途认领(提问/审批)先收口:会话已销毁,waterfall 再也不可能被兑现,
|
|
302
|
+
// 留着就是「宿主 `ask()` 永远悬挂」+ 挂起标记残留(C 批 T4 的 `_dropClaims`)
|
|
303
|
+
this._dropClaims(sid);
|
|
241
304
|
this.observed.delete(sid);
|
|
305
|
+
this.watched.delete(sid); // 宿主会话已销毁:看管声明随之收口(C 批)
|
|
242
306
|
this._publishState('session/disposed', { sid });
|
|
243
307
|
});
|
|
244
308
|
// agent 状态变更(P0 实测跨插件可见):payload 形状是 **单个对象**
|
|
@@ -259,15 +323,25 @@ export class AgentDriver {
|
|
|
259
323
|
agentCtx.on('user-questions/request',
|
|
260
324
|
(request, next) => this._onQuestionRequest(request, next),
|
|
261
325
|
{ prepend: true });
|
|
262
|
-
// 审批同为 waterfall
|
|
263
|
-
//
|
|
264
|
-
|
|
326
|
+
// 审批同为 waterfall,**同样必须 prepend**(与提问同源的理由见 `_onQuestionRequest`:
|
|
327
|
+
// dsh Web 客户端问答桥先注册且认领后不再 next(),append 注册的 listener 在真机上
|
|
328
|
+
// 永远收不到)。prepend 只抢「先看」位置,**是否让位由分支决定**——池内未接管、
|
|
329
|
+
// 看管外无活 agent、(未看管的)外部会话三类一律 `next()` 让位,dsh GUI 的审批弹窗
|
|
330
|
+
// 照常;只有「池内已接管」与「看管外 + 宿主有活 agent」才认领(双通道:原生框照旧弹)
|
|
331
|
+
agentCtx.on('approval/request', (request, next) => this._onApprovalRequest(request, next),
|
|
332
|
+
{ prepend: true });
|
|
265
333
|
// 宿主 inbox(P6 #21):排队消息的增/领/弃三个事件都可见 ⇒ 平台会话窗能
|
|
266
334
|
// 展示「宿主排队行」(kimi 侧等价物是 /prompts 的 queued 段)。
|
|
267
335
|
agentCtx.on('agent/inbox/inserted', (payload) => this._onInbox('inserted', payload));
|
|
268
336
|
agentCtx.on('agent/inbox/claimed', (payload) => this._onInbox('claimed', payload));
|
|
269
337
|
agentCtx.on('agent/inbox/discarded', (payload) => this._onInbox('discarded', payload));
|
|
270
338
|
this.ready = true;
|
|
339
|
+
// 已有会话枚举(A 批 2026-10-08):`session/created` 对**已有**会话不会再发,
|
|
340
|
+
// 插件重新 apply(热重载)后必须主动补齐,否则 `/live` 恒空、平台可见性丢失。
|
|
341
|
+
this._enumerateHostSessions();
|
|
342
|
+
this._enumerateTimer = setTimeout(() => this._enumerateHostSessions(),
|
|
343
|
+
ENUMERATE_RETRY_MS);
|
|
344
|
+
if (this._enumerateTimer.unref) this._enumerateTimer.unref();
|
|
271
345
|
this.logger.info(`touchstone: agent 驱动就绪(会话池 + SSE 事件流,前缀 ${DRIVER_PREFIX})`);
|
|
272
346
|
});
|
|
273
347
|
// 业务服务延迟注入(2026-10-03 P3):缺失不阻塞插件加载,调用时按需 get;
|
|
@@ -290,22 +364,28 @@ export class AgentDriver {
|
|
|
290
364
|
// 事件挂在注入作用域上(契约自检的桩 ctx 只有 agentCtx 有 on;真机两者同一总线)
|
|
291
365
|
const bus = (wsCtx && typeof wsCtx.on === 'function') ? wsCtx : this.ctx;
|
|
292
366
|
if (bus && typeof bus.on === 'function') {
|
|
293
|
-
bus.on('domain/changed', (change) =>
|
|
294
|
-
if (!change || change.domain !== 'workspace') return;
|
|
295
|
-
this._publishArchiveState(false);
|
|
296
|
-
});
|
|
367
|
+
bus.on('domain/changed', (change) => this._onDomainChanged(change));
|
|
297
368
|
}
|
|
298
369
|
this._publishArchiveState(true);
|
|
299
370
|
this.logger.info('touchstone: workspaceRegistry 可用(归档集事件 + /archived//archive)');
|
|
300
371
|
});
|
|
301
372
|
this._timer = setInterval(() => this._keepalive(), KEEPALIVE_MS);
|
|
302
373
|
if (this._timer.unref) this._timer.unref();
|
|
374
|
+
// 归档集快速兜底(2026-10-07):事件为主路径已毫秒级,这里只是二道防线
|
|
375
|
+
this._sweepTimer = setInterval(() => this._publishArchiveState(false),
|
|
376
|
+
ARCHIVE_SWEEP_MS);
|
|
377
|
+
if (this._sweepTimer.unref) this._sweepTimer.unref();
|
|
303
378
|
}
|
|
304
379
|
|
|
305
380
|
/** 停用:注销路由、断开 SSE、释放常驻会话与观察表。 */
|
|
306
381
|
async dispose() {
|
|
307
382
|
if (this._timer) clearInterval(this._timer);
|
|
308
383
|
this._timer = null;
|
|
384
|
+
if (this._sweepTimer) clearInterval(this._sweepTimer);
|
|
385
|
+
this._sweepTimer = null;
|
|
386
|
+
// 枚举兜底定时器:停用后不能再写 `observed`(否则清表之后又被补回,成了幽灵会话)
|
|
387
|
+
if (this._enumerateTimer) clearTimeout(this._enumerateTimer);
|
|
388
|
+
this._enumerateTimer = null;
|
|
309
389
|
for (const disposeRoute of this._routes) {
|
|
310
390
|
try {
|
|
311
391
|
disposeRoute();
|
|
@@ -328,18 +408,31 @@ export class AgentDriver {
|
|
|
328
408
|
await this._disposeHandle(entry);
|
|
329
409
|
}
|
|
330
410
|
// 认领中的提问:插件要走了,兑现不了——reject 收口(宿主 `ask()` 见信号中止/
|
|
331
|
-
// 异常会归一,tool result
|
|
411
|
+
// 异常会归一,tool result 记中断),并清挂起标记、收起 dsh GUI 的提问框,
|
|
412
|
+
// 避免平台侧与浏览器侧各残留一个 pending
|
|
332
413
|
for (const [callId, pending] of [...this.questions]) {
|
|
333
414
|
this.questions.delete(callId);
|
|
334
415
|
try {
|
|
335
416
|
pending.finish();
|
|
417
|
+
pending.cancelLane();
|
|
336
418
|
pending.reject(new Error('touchstone driver disposed before the question settled'));
|
|
337
419
|
} catch {
|
|
338
420
|
/* 已收口/已兑现:忽略 */
|
|
339
421
|
}
|
|
340
422
|
}
|
|
341
423
|
this.questionCalls.clear();
|
|
424
|
+
// 在途**审批**认领(C 批 T4):同属「插件要走了、兑现不了」,按看管 sid 逐个走
|
|
425
|
+
// `_dropClaims` 统一收口(提问/审批 + 挂起标记一把清);上面那个全量循环已兜住
|
|
426
|
+
// 提问,故这里实际补的是 `approvals` 与仍残留的挂起标记。`watched` 已并入 sid 集合,
|
|
427
|
+
// 是因为「撤销看管后仍有在途审批」的窗口里 approvals 有值而 watched 已空。
|
|
428
|
+
// 调用点落在 `dispose()`(本驱动**没有** `stop()`:真实失活口只有这里的 dispose,
|
|
429
|
+
// 与 T1 的 `watched.clear()` 同处,brief 里写的 `stop()` 系笔误)
|
|
430
|
+
for (const sid of new Set([...this.watched, ...this.approvals.keys()])) {
|
|
431
|
+
this._dropClaims(sid);
|
|
432
|
+
}
|
|
342
433
|
this.observed.clear();
|
|
434
|
+
this.watched.clear(); // 看管声明同属易失的进程内状态,停用时一并收口
|
|
435
|
+
this._enumerated = false; // 观察表已清空:快照不再完整,重启后须重新枚举
|
|
343
436
|
this.ready = false;
|
|
344
437
|
}
|
|
345
438
|
|
|
@@ -357,6 +450,8 @@ export class AgentDriver {
|
|
|
357
450
|
usage: entry.usage || null, // 上下文用量(assistant/message 累积)
|
|
358
451
|
permission: this._permissionState(entry), // 会话级权限({mode, preset};进程内回读)
|
|
359
452
|
started_at: entry.createdAt, // 平台侧「运行中会话精确探测」的排序依据
|
|
453
|
+
// 会话头行 origin(子代理='subagent',主会话=空串):平台据此不建卡/不占位
|
|
454
|
+
origin: this._sessionOrigin(entry.agent && entry.agent.session),
|
|
360
455
|
}));
|
|
361
456
|
const external = [];
|
|
362
457
|
for (const [sid, info] of this.observed) {
|
|
@@ -368,6 +463,7 @@ export class AgentDriver {
|
|
|
368
463
|
cwd: info.cwd,
|
|
369
464
|
status: live ? live.status : 'unknown',
|
|
370
465
|
owned: false,
|
|
466
|
+
origin: info.origin || '', // 子代理会话标记(见 _sessionOrigin)
|
|
371
467
|
interaction: info.interaction || null,
|
|
372
468
|
last_turn_reason: info.lastTurnReason || null,
|
|
373
469
|
});
|
|
@@ -375,6 +471,79 @@ export class AgentDriver {
|
|
|
375
471
|
return [...owned, ...external];
|
|
376
472
|
}
|
|
377
473
|
|
|
474
|
+
/**
|
|
475
|
+
* 该会话是否被平台声明为「看管」(C 批,2026-10-10)。
|
|
476
|
+
*
|
|
477
|
+
* 外部会话(用户在 dsh GUI 直跑/接管的)的投递回落、提问认领、审批接管三条通道
|
|
478
|
+
* 全部以本判据为闸:**只有平台显式 `POST /watch` 过的 sid 才为 true**。绝不按
|
|
479
|
+
* 「不在池内」「能枚举到」之类推断——宿主里与 Touchstone 无关的会话多得是,
|
|
480
|
+
* 推断就等于允许平台向任意会话注入消息与答案(见 `this.watched` 的说明)。
|
|
481
|
+
*
|
|
482
|
+
* @param {string} sid - dsh 会话 id(`session-<uuid>`)
|
|
483
|
+
* @returns {boolean} 看管中即 true;空值/未声明/已撤销/已销毁一律 false
|
|
484
|
+
*/
|
|
485
|
+
_isWatched(sid) {
|
|
486
|
+
return this.watched.has(String(sid || ''));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 宿主**活 agent**读口(C 批,2026-10-10):`agents.get(sid)` 给出该会话在宿主进程内
|
|
491
|
+
* 的 agent 句柄;没有(会话已结束,或本进程从没起过它)返回 undefined。
|
|
492
|
+
*
|
|
493
|
+
* 与 `this.sessions` 的分界(T2 回落投递的依据):`sessions` 是**平台持有**的池
|
|
494
|
+
* (handle 由本插件建/恢复并持有),本读口只问「宿主里它还活着吗」——句柄归谁与
|
|
495
|
+
* 所有权无关(用户直跑会话的 agent 归 GUI 作用域,见 C 方案 §二 T2)。于是
|
|
496
|
+
* 「`_isWatched` 且 `_liveAgent` 有值」即可直接 followup/steer 投递,无需接管会话。
|
|
497
|
+
* 守卫 `typeof … 'function'`:极简桩 ctx 可能没有 agents 服务(与 `_fork` 同款防御)。
|
|
498
|
+
*
|
|
499
|
+
* @param {string} sid - dsh 会话 id
|
|
500
|
+
* @returns {object|undefined} 宿主活 agent(带 followup/steer/status);无则 undefined
|
|
501
|
+
*/
|
|
502
|
+
_liveAgent(sid) {
|
|
503
|
+
const agents = this.agentCtx && this.agentCtx.agents;
|
|
504
|
+
if (!agents || typeof agents.get !== 'function') return undefined;
|
|
505
|
+
return agents.get(String(sid || ''));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 外部会话投递的**唯一闸门**(C 批 T2 重构,2026-10-10):`/prompt`、`/steer`、`/status`
|
|
510
|
+
* 三个端点的「池外回落」判定全部收在这里;未看管一律回到池内/404 老路径(见设计 §3.1
|
|
511
|
+
* 与 §3.2)。三档语义如下,调用方按返回值分流:
|
|
512
|
+
*
|
|
513
|
+
* ① 池内(`this.sessions.has(sid)`)**或**未看管:返回 `undefined` 且**不写任何响应**
|
|
514
|
+
* —— 调用方交回既有路径:`/prompt`·`/steer` 继续走 `_lookup`(唯一 404 出口,文案
|
|
515
|
+
* `会话不在驱动池中: <sid>` 一字不变),`/status` 走自己的池内 200 / 池外 404;
|
|
516
|
+
* ② 已看管但 `_liveAgent(sid)` 不存在:**自己写 404** `会话已结束(宿主无活动 agent): <sid>`
|
|
517
|
+
* 并返回 `null` —— 「声明过看管、会话却已结束」的独立分档,平台据此报因,不再误报
|
|
518
|
+
* 「不在驱动池中」;
|
|
519
|
+
* ③ 已看管且宿主有活 agent:返回该 live agent(`/prompt` 用它 `followup`、`/steer` 用它
|
|
520
|
+
* `steer`、`/status` 取它的 `status`),**不接管**会话。
|
|
521
|
+
*
|
|
522
|
+
* `res` 传 `null` = 干跑:只判定、不写响应(`/status` 用)。`/status` 对「watched 但无活
|
|
523
|
+
* agent」保留**既有** 404 原文案 `会话不在驱动池中: <sid>`(控制者裁定维持「三端点文案
|
|
524
|
+
* 不完全统一」,见进度记录「T2 顾虑不修」),故它把 ②③ 一并当「非外部」处理、合流到
|
|
525
|
+
* 自己那个 404 出口;判定逻辑仍只有这一份实现,不会再长出第三个副本。
|
|
526
|
+
*
|
|
527
|
+
* 为什么必须收敛成一份实现:T3(`/answer`)与 T4(`/approval`)还要在同一闸门上加分支,
|
|
528
|
+
* 四处副本改一漏一将是**静默**的(评审 Important 1)。
|
|
529
|
+
*
|
|
530
|
+
* @param {object|null} res - HTTP 响应对象;`null` = 干跑不写响应
|
|
531
|
+
* @param {string} sid - dsh 会话 id(调用方已 `String(...)` 归一)
|
|
532
|
+
* @returns {object|undefined|null} live agent;`undefined`=池内或未看管;`null`=已看管但无活 agent
|
|
533
|
+
*/
|
|
534
|
+
_externalTarget(res, sid) {
|
|
535
|
+
// ① 池内/未看管:不自作主张,交回调用方的既有路径(看管声明是唯一闸门)
|
|
536
|
+
if (this.sessions.has(sid) || !this._isWatched(sid)) return undefined;
|
|
537
|
+
// ② 已看管但宿主已无活 agent:会话结束/被销毁,单独一档文案
|
|
538
|
+
const live = this._liveAgent(sid);
|
|
539
|
+
if (!live) {
|
|
540
|
+
if (res) this._json(res, 404, { error: `会话已结束(宿主无活动 agent): ${sid}` });
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
// ③ 已看管且宿主有活 agent:直投目标
|
|
544
|
+
return live;
|
|
545
|
+
}
|
|
546
|
+
|
|
378
547
|
// ---------- 会话事件 ----------
|
|
379
548
|
|
|
380
549
|
/**
|
|
@@ -432,22 +601,34 @@ export class AgentDriver {
|
|
|
432
601
|
this._publishState('transcript', { sid, data: { event_seq: event.seq,
|
|
433
602
|
kind: event.type } });
|
|
434
603
|
}
|
|
435
|
-
// 审批是会话事件(log-only audit
|
|
604
|
+
// 审批是会话事件(log-only audit),与提问共同构成「等人工输入」实况。
|
|
605
|
+
// C 批 T4:看管外部会话的**认领中**审批以平台标记为准,审计帧不得覆盖/收口它——
|
|
606
|
+
// 审计 mark 既没有 `ap-<n>` 也没有 `answerable`(只是宿主自己落的审计记录),盖上
|
|
607
|
+
// 去平台侧的作答按钮就消失了。真机主顺序是审计帧在前(读宿主
|
|
608
|
+
// `dsh-user-approval/lib/index.js` 的 `request()`:先 append `approval/asked`、再跑
|
|
609
|
+
// `approval/request` waterfall),故这两个分支只在「观察者提交后回调、帧落到认领之后」
|
|
610
|
+
// 时才命中;池内/未看管会话的 `approvals` 恒空,行为一个字节不变。
|
|
436
611
|
if (event.type === 'approval/asked') {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
612
|
+
// 认领中(`approvals` 命中)只跳过「写标记 + 发状态帧」这一段,函数尾部的
|
|
613
|
+
// `observed.updatedAt` 与会话流转发照旧执行(不能用 return 提前退出)
|
|
614
|
+
if (!this.approvals.has(sid)) {
|
|
615
|
+
const mark = { kind: 'approval', id: event.data && event.data.id,
|
|
616
|
+
tool: (event.data && event.data.toolName) || '',
|
|
617
|
+
reason: (event.data && event.data.reason) || '' };
|
|
618
|
+
if (entry) entry.interaction = mark;
|
|
619
|
+
const seen = this.observed.get(sid);
|
|
620
|
+
if (seen) seen.interaction = mark;
|
|
621
|
+
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
622
|
+
}
|
|
444
623
|
}
|
|
445
624
|
if (event.type === 'approval/decided') {
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
625
|
+
if (!this.approvals.has(sid)) {
|
|
626
|
+
if (entry) entry.interaction = null;
|
|
627
|
+
const seen = this.observed.get(sid);
|
|
628
|
+
if (seen) seen.interaction = null;
|
|
629
|
+
this._publishState('driver/interaction', {
|
|
630
|
+
sid, data: { state: 'decided', outcome: (event.data && event.data.outcome) || '' } });
|
|
631
|
+
}
|
|
451
632
|
}
|
|
452
633
|
// 提问的工具调用/结果(2026-10-04 修 #791):
|
|
453
634
|
// · tool/call:`ask_user_question` 的真实 callId 只在这里出现——legacy 模式下
|
|
@@ -486,17 +667,79 @@ export class AgentDriver {
|
|
|
486
667
|
entry.publish({ sid, ...trimEvent(relay) });
|
|
487
668
|
}
|
|
488
669
|
|
|
670
|
+
/** 会话头行的 origin(dsh 子代理会话为 `'subagent'`,主会话/未上报为空串)。
|
|
671
|
+
*
|
|
672
|
+
* 为什么平台需要它(2026-10-07):子代理会话与主会话同 bucket 同格式,平台侧
|
|
673
|
+
* 原本把每个会话都建成看板卡并在其运行时落 `ext:` 占用行——子代理是主会话的
|
|
674
|
+
* 实现细节,于是「一次派 8 个子代理」=多 8 张卡 + 项目被占满不补位(实障:
|
|
675
|
+
* 卡 870 排队 5 小时)。平台据此把子代理排除在建卡与占用之外,判定源就是这里。
|
|
676
|
+
* 空串=未知(绝不能推断成「非子代理」)。
|
|
677
|
+
*/
|
|
678
|
+
_sessionOrigin(session) {
|
|
679
|
+
return String((session && session.header && session.header.origin) || '');
|
|
680
|
+
}
|
|
681
|
+
|
|
489
682
|
/** `session/created`:登记全局可见会话(外部直跑会话的发现入口)。 */
|
|
490
683
|
_observe(session) {
|
|
491
684
|
const sid = String(session.id);
|
|
492
685
|
const cwd = (session.header && session.header.cwd) || '';
|
|
493
686
|
this.observed.set(sid, {
|
|
494
687
|
cwd,
|
|
688
|
+
origin: this._sessionOrigin(session),
|
|
495
689
|
lastTurnReason: null,
|
|
496
690
|
interaction: null,
|
|
497
691
|
updatedAt: Date.now(),
|
|
498
692
|
});
|
|
499
|
-
this._publishState('session/created', {
|
|
693
|
+
this._publishState('session/created', {
|
|
694
|
+
sid, data: { cwd, owned: this.sessions.has(sid), origin: this._sessionOrigin(session) } });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 枚举**宿主已有会话**,补进观察表(A 批 2026-10-08)。
|
|
699
|
+
*
|
|
700
|
+
* 为什么必须有它:`observed` 的写入路径只有 `session/created` 事件(L257 订阅)、
|
|
701
|
+
* 平台自建会话(`_session`)、fork 接管(`_fork`),而宿主对**已有**会话不会再发
|
|
702
|
+
* `session/created`——插件重新 apply(`dispose()` + 新实例,热重载/面板行开关往返)
|
|
703
|
+
* 后两表全空,`/live` 于是恒空(真机实测 23 条 → 0 条,3 分钟不自愈),Python 侧
|
|
704
|
+
* `dshevents` 对齐拿到空表 ⇒ 对所有外部会话失去可见性(运行中的看板卡被搬去
|
|
705
|
+
* 待审核,见 bug_report/20261008_1935)。
|
|
706
|
+
*
|
|
707
|
+
* 做法:读宿主 `sessions` 服务(`SessionStore.store`,`Map(sid → entry)`,
|
|
708
|
+
* `entry.session` = 活会话)逐个走既有的 `_observe()`,**已在观察表里的 sid 跳过**
|
|
709
|
+
* ——枚举只负责「发现没见过的」,不会把事件学到的 turn 结果/挂起问答覆盖掉,故本方法
|
|
710
|
+
* 可安全重复调用(apply 时一次 + 1s 兜底一次)。`store` 是宿主内部字段、非正式契约:
|
|
711
|
+
* 形状不符或抛错时只 warn,`_enumerated` 保持 false ⇒ `/live` 报 `complete:false`,
|
|
712
|
+
* 平台按「未对齐」处理(宁可多等不可误放行),插件其余功能不受影响。
|
|
713
|
+
*
|
|
714
|
+
* @returns {boolean} 是否成功枚举(true = `/live` 可声明快照完整)
|
|
715
|
+
*/
|
|
716
|
+
_enumerateHostSessions() {
|
|
717
|
+
try {
|
|
718
|
+
const service = this.ctx.get('sessions');
|
|
719
|
+
const table = service && service.store;
|
|
720
|
+
if (!(table instanceof Map)) {
|
|
721
|
+
this.logger.warn('touchstone: 宿主 sessions.store 不可读,'
|
|
722
|
+
+ '/live 声明 complete=false(平台按未对齐处理)');
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
let n = 0;
|
|
726
|
+
for (const entry of table.values()) {
|
|
727
|
+
const session = entry && entry.session;
|
|
728
|
+
if (!session || !session.id) continue; // 脏 entry(无活会话):跳过
|
|
729
|
+
// 已在观察表里的(事件先到 / 平台自建已登记)不再覆盖:`_observe` 是整条覆盖写,
|
|
730
|
+
// 会把事件学到的 turn 结果、挂起问答清成 null——枚举只负责「发现没见过的」。
|
|
731
|
+
if (this.observed.has(String(session.id))) continue;
|
|
732
|
+
this._observe(session);
|
|
733
|
+
n += 1;
|
|
734
|
+
}
|
|
735
|
+
this._enumerated = true;
|
|
736
|
+
this.logger.info(`touchstone: 枚举宿主已有会话 ${n} 条(/live complete=true)`);
|
|
737
|
+
return true;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
this.logger.warn(`touchstone: 枚举宿主会话失败(/live 声明 complete=false): ${
|
|
740
|
+
(err && err.message) || err}`);
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
500
743
|
}
|
|
501
744
|
|
|
502
745
|
/**
|
|
@@ -547,7 +790,7 @@ export class AgentDriver {
|
|
|
547
790
|
}
|
|
548
791
|
|
|
549
792
|
/**
|
|
550
|
-
* `user-questions/request`(waterfall,**prepend 注册**):提问的观测 +
|
|
793
|
+
* `user-questions/request`(waterfall,**prepend 注册**):提问的观测 + 双通道作答。
|
|
551
794
|
*
|
|
552
795
|
* 为什么必须 prepend(2026-10-04 隔离实例探针实测,修 #791):
|
|
553
796
|
* dsh Web 的客户端问答桥(`@deepseek-ai/dsh-api-remotes` 把该 waterfall 转发给
|
|
@@ -556,27 +799,42 @@ export class AgentDriver {
|
|
|
556
799
|
* listener 均不触发,prepend 的才触发)。prepend 只抢「先看」的位置,是否让位
|
|
557
800
|
* 由下面的分支决定,故 Web GUI 对**外部会话**的行为一个字节都没变。
|
|
558
801
|
*
|
|
559
|
-
*
|
|
560
|
-
* 1) 平台自持会话(`/session`
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
802
|
+
* 三条分支:
|
|
803
|
+
* 1) 平台自持会话(`/session` 建的池内会话,看板卡与任务轮都走它):**双通道**——
|
|
804
|
+
* 插件认领(平台 `/answer` 兑现,Touchstone 会话窗渲染选择框可作答)**并且**
|
|
805
|
+
* 把请求交回下游原生链路(dsh Web GUI 同样弹提问框、同样可作答),两侧先答者胜
|
|
806
|
+
* (竞速口径见 `_holdQuestion`)。
|
|
807
|
+
* · 为什么必须认领:dsh 阻塞式 `ask_user_question`(tool-ask-user 默认 legacy
|
|
808
|
+
* 模式)**不带 `request.wait.callId`**,而宿主
|
|
809
|
+
* `userQuestions.answer(agent, callId, …)` 只受理「continued」(前台等待超时
|
|
810
|
+
* 后)的提问——**在途提问只有 waterfall 链内返回答案这一条路**。只让位不认领,
|
|
811
|
+
* 平台的 answerable/作答通道全程空转(#791:Touchstone 会话窗不出现选择框)。
|
|
812
|
+
* · 为什么要让位(2026-10-06 修 #837):纯认领时该 waterfall 到插件为止,dsh Web
|
|
813
|
+
* GUI 收不到提问 —— 提问框只在 Touchstone 侧出现。预期是**两侧都显示**。
|
|
814
|
+
* 2) **池外但平台已看管**的外部会话(C 批 T3,2026-10-10;用户在 dsh GUI 直跑、平台
|
|
815
|
+
* 只 `POST /watch` 声明看管的):与 ①**同一套双通道**——认领(挂起标记带 call_id
|
|
816
|
+
* ⇒ 平台 answerable=true、`/answer` 兑现 waterfall)**并且**原生链路照旧打开
|
|
817
|
+
* (dsh GUI 照旧弹框),两侧先答者胜;**不接管**该会话(不建池条目)。这正是 T2
|
|
818
|
+
* 投递回落(`/prompt`·`/steer`)在作答侧的对称面:看得见就要答得上。
|
|
819
|
+
* 3) 其它会话(用户自己直跑且平台**未**看管的):只旁听(发状态帧,平台据此把卡置
|
|
820
|
+
* 阻塞、把 ext 行按挂起处置),`next()` 让原生作答者(dsh Web GUI)照旧作答;
|
|
821
|
+
* `_isWatched` 是唯一闸门,未看管会话的行为与 C 批之前一个字节不差。
|
|
569
822
|
*/
|
|
570
823
|
_onQuestionRequest(request, next) {
|
|
571
824
|
const agent = request && request.agent;
|
|
572
825
|
const sid = agent ? String(agent.id) : '';
|
|
573
826
|
const waitCallId = String((request && request.wait && request.wait.callId) || '');
|
|
574
827
|
const entry = sid ? this.sessions.get(sid) : undefined;
|
|
575
|
-
|
|
576
|
-
|
|
828
|
+
// ① 池内 / ② 池外+看管:同一套认领(`_isWatched` 只在这里开口,未看管照旧走 ③)
|
|
829
|
+
if (entry !== undefined || (sid && this._isWatched(sid))) {
|
|
830
|
+
// 双通道。标识优先取宿主给的 wait.callId(timed 模式),
|
|
577
831
|
// 否则取 `ask_user_question` 的真实 tool callId(legacy 模式),再否则自造
|
|
578
832
|
const callId = waitCallId || this._takeQuestionCallId(sid) || this._nextQuestionId();
|
|
579
|
-
|
|
833
|
+
// 先留原宿主取消信号(轮次中止/停卡):打开原生通道会替换请求对象上的
|
|
834
|
+
// `signal`(见 `_openNativeLane`),平台侧的收口仍要认**原信号**
|
|
835
|
+
const hostSignal = request && request.signal;
|
|
836
|
+
const lane = this._openNativeLane(request, next);
|
|
837
|
+
return this._holdQuestion(sid, callId, request, hostSignal, lane);
|
|
580
838
|
}
|
|
581
839
|
if (!sid) return next();
|
|
582
840
|
this._publishQuestionMark(sid, waitCallId, request);
|
|
@@ -584,52 +842,123 @@ export class AgentDriver {
|
|
|
584
842
|
}
|
|
585
843
|
|
|
586
844
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* `ask_user_question
|
|
845
|
+
* 打开「原生作答通道」:把提问交回 waterfall 下游(dsh Web 客户端问答桥 → 浏览器
|
|
846
|
+
* 提问框),返回 `{promise, cancel}`。dsh GUI 的作答经该 promise 回到宿主
|
|
847
|
+
* `ask_user_question`,与平台 `/answer` 是同一条 waterfall 语义。
|
|
848
|
+
*
|
|
849
|
+
* 为什么转发前要替换 `request.signal`(修 #837 的关键;依据是读
|
|
850
|
+
* `@deepseek-ai/dsh-api-gateway` 两个半壳代码,不是猜测):
|
|
851
|
+
* · Host 半(`lib/index.js` `startRemoteEvent`):请求对象上的 `signal` 被登记为该
|
|
852
|
+
* pending 事件的**宿主取消信号**,一旦 abort 就 `finishRemoteEvent` 并向浏览器补发
|
|
853
|
+
* `{type:'cancel', eventId}`;
|
|
854
|
+
* · Client 半(`lib/client.js` 的 `frame.type === 'cancel'`):收到 cancel 即 abort 该
|
|
855
|
+
* 次投递的 signal ⇒ 客户端问答桥的 `claimSignal` 中止 ⇒ 提问框自行收起、
|
|
856
|
+
* 定时等待(timed 模式)一并释放。
|
|
857
|
+
* 平台先在 Touchstone 侧作答时,这是插件**唯一**能让 dsh GUI 那张框消失的手段
|
|
858
|
+
* (否则框会留在输入区,用户再答也无人接收)。
|
|
859
|
+
* `request` 是宿主 `ask()` 里 `{...request, agent}` 造出来的**浅拷贝**,只被本链下游
|
|
860
|
+
* 看到;宿主 `ask()` 自身的取消判断读原对象,故替换不影响它。替换后仍把原信号接进来
|
|
861
|
+
* (停卡/轮次中止时两端一起收口,行为与替换前一致)。
|
|
862
|
+
*
|
|
863
|
+
* 下游失败(无 Web 客户端问答桥的 profile 如 headless、客户端内部异常)只当该通道
|
|
864
|
+
* 不存在:这里先吃掉 rejection(平台通道仍等 `/answer`),调用方只竞速**成功**值。
|
|
865
|
+
*
|
|
866
|
+
* @returns {{promise: Promise<any>, cancel: (reason?: Error) => void}}
|
|
867
|
+
*/
|
|
868
|
+
_openNativeLane(request, next) {
|
|
869
|
+
const upstream = request && request.signal;
|
|
870
|
+
const controller = new AbortController();
|
|
871
|
+
if (upstream) {
|
|
872
|
+
if (upstream.aborted) controller.abort(upstream.reason);
|
|
873
|
+
else if (typeof upstream.addEventListener === 'function') {
|
|
874
|
+
upstream.addEventListener('abort', () => controller.abort(upstream.reason), { once: true });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
request.signal = controller.signal; // 网关据此登记取消/收框
|
|
879
|
+
} catch {
|
|
880
|
+
/* 请求对象被冻结(异常形态):退化为「平台先答时 GUI 的框不自收」,不影响作答 */
|
|
881
|
+
}
|
|
882
|
+
let promise;
|
|
883
|
+
try {
|
|
884
|
+
promise = Promise.resolve(next());
|
|
885
|
+
} catch (err) {
|
|
886
|
+
promise = Promise.reject(err);
|
|
887
|
+
}
|
|
888
|
+
promise.catch(() => {}); // 见 docstring:下游失败由平台通道兜底
|
|
889
|
+
return { promise, cancel: (reason) => { try { controller.abort(reason); } catch { /* 已中止 */ } } };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* 认领一个会话的提问(池内自持会话、看管的外部会话共用,C 批 T3 起形参由池条目
|
|
894
|
+
* 改为 `sid`):登记进 `this.questions`、发挂起状态帧,返回的 Promise
|
|
895
|
+
* 由**两个作答通道先到者**兑现——① 平台 `/answer`;② 原生链路(dsh GUI 提问框,
|
|
896
|
+
* `_openNativeLane`)。返回值直接交回宿主 `ask_user_question`。
|
|
897
|
+
*
|
|
898
|
+
* 竞速口径(修 #837,两侧都显示、都可答):
|
|
899
|
+
* · 原生链路**成功**即胜出:清平台挂起标记(Touchstone 会话窗的框随之收起),
|
|
900
|
+
* 平台侧迟到的 `/answer` 落到 `svc.answer` 的 continued 通道(accepted=false,
|
|
901
|
+
* 平台按 40405 收口,不空转)。
|
|
902
|
+
* · 原生链路**失败**不算数(GUI 点「取消」、客户端异常、无客户端的 profile…):
|
|
903
|
+
* 平台通道继续等 `/answer`。真正的失败只由宿主取消信号(停卡/cancel)给出。
|
|
904
|
+
* · 平台先答:`_settleQuestion` 主动 abort 原生通道的取消信号 → dsh GUI 的提问框
|
|
905
|
+
* 自行收起(见 `_openNativeLane`),不留「答了也没人收」的死框。
|
|
590
906
|
*
|
|
591
907
|
* 中止(用户停卡/cancel):宿主 abort 信号触发 → reject 普通 Error,宿主 `ask()`
|
|
592
908
|
* 见「信号已中止」会归一为 ASK_ABORTED(tool result 记中断,与旧行为一致)。
|
|
593
909
|
*/
|
|
594
|
-
_holdQuestion(
|
|
595
|
-
const sid = entry.sessionId;
|
|
910
|
+
_holdQuestion(sid, callId, request, hostSignal, lane) {
|
|
596
911
|
return new Promise((resolve, reject) => {
|
|
597
|
-
const pending = { sid, callId, mark: null, done: false, finish: null, resolve, reject
|
|
912
|
+
const pending = { sid, callId, mark: null, done: false, finish: null, resolve, reject,
|
|
913
|
+
lane: lane || null,
|
|
914
|
+
cancelLane: () => { if (lane) lane.cancel(new Error('the platform answered the question first')); } };
|
|
598
915
|
this.questions.set(callId, pending);
|
|
599
916
|
/** 收口(幂等):摘表 + 解绑中止监听 + 清挂起标记 */
|
|
600
917
|
pending.finish = () => {
|
|
601
918
|
if (pending.done) return;
|
|
602
919
|
pending.done = true;
|
|
603
920
|
if (this.questions.get(callId) === pending) this.questions.delete(callId);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
signal.removeEventListener('abort', pending.onAbort);
|
|
921
|
+
if (pending.onAbort && hostSignal
|
|
922
|
+
&& typeof hostSignal.removeEventListener === 'function') {
|
|
923
|
+
hostSignal.removeEventListener('abort', pending.onAbort);
|
|
608
924
|
}
|
|
609
925
|
this._clearQuestionMark(sid);
|
|
610
926
|
};
|
|
611
|
-
|
|
612
|
-
if (
|
|
613
|
-
|
|
927
|
+
// 原生通道(dsh GUI):成功即胜出并清平台挂起标记;失败一律忽略(平台通道仍在)
|
|
928
|
+
if (lane) {
|
|
929
|
+
lane.promise.then((value) => {
|
|
930
|
+
if (pending.done) return;
|
|
931
|
+
pending.finish();
|
|
932
|
+
resolve(value);
|
|
933
|
+
}, () => { /* 见 docstring:旁路故障不判死提问 */ });
|
|
934
|
+
}
|
|
935
|
+
if (hostSignal) {
|
|
936
|
+
if (hostSignal.aborted) {
|
|
614
937
|
pending.finish();
|
|
938
|
+
pending.cancelLane();
|
|
615
939
|
reject(new Error('ask_user_question aborted before the platform answered'));
|
|
616
940
|
return;
|
|
617
941
|
}
|
|
618
942
|
pending.onAbort = () => {
|
|
619
943
|
pending.finish();
|
|
944
|
+
pending.cancelLane();
|
|
620
945
|
reject(new Error('ask_user_question aborted before the platform answered'));
|
|
621
946
|
};
|
|
622
|
-
|
|
947
|
+
hostSignal.addEventListener('abort', pending.onAbort);
|
|
623
948
|
}
|
|
624
949
|
pending.mark = this._publishQuestionMark(sid, callId, request);
|
|
625
950
|
});
|
|
626
951
|
}
|
|
627
952
|
|
|
628
|
-
/**
|
|
953
|
+
/**
|
|
954
|
+
* 兑现认领中的提问(平台 `/answer` 送达):true=命中并已交付宿主。
|
|
955
|
+
* 平台先答时顺带 abort 原生通道的取消信号——dsh GUI 的提问框随之收起(修 #837)。
|
|
956
|
+
*/
|
|
629
957
|
_settleQuestion(callId, answer) {
|
|
630
958
|
const pending = this.questions.get(callId);
|
|
631
959
|
if (pending === undefined) return false;
|
|
632
960
|
pending.finish();
|
|
961
|
+
pending.cancelLane();
|
|
633
962
|
pending.resolve(answer);
|
|
634
963
|
return true;
|
|
635
964
|
}
|
|
@@ -715,7 +1044,11 @@ export class AgentDriver {
|
|
|
715
1044
|
return mark;
|
|
716
1045
|
}
|
|
717
1046
|
|
|
718
|
-
/**
|
|
1047
|
+
/**
|
|
1048
|
+
* 清挂起标记(作答送达/提问收口/审批收口):状态流发一帧非 asked 态即清。
|
|
1049
|
+
* 提问与审批**共用**同一个 interaction 槽(`entry.interaction` / `observed.interaction`),
|
|
1050
|
+
* 故两者走同一个清口(审批的认领收口见 `_holdApproval` / `_dropClaims`)。
|
|
1051
|
+
*/
|
|
719
1052
|
_clearQuestionMark(sid) {
|
|
720
1053
|
if (!sid) return;
|
|
721
1054
|
const entry = this.sessions.get(sid);
|
|
@@ -758,19 +1091,44 @@ export class AgentDriver {
|
|
|
758
1091
|
}
|
|
759
1092
|
}
|
|
760
1093
|
|
|
1094
|
+
/**
|
|
1095
|
+
* `domain/changed` → 归档集推帧(事件为主路径)。
|
|
1096
|
+
*
|
|
1097
|
+
* **为什么取事件载荷而不是回读 registry(2026-10-07 真机实测定因)**:官方
|
|
1098
|
+
* `WorkspaceRegistry.setState()` 的写法是 `await this.global.set(state)` **之后**
|
|
1099
|
+
* 才更新自己的内存缓存 `this.state`,而 `domain/changed` 正是在 `global.set`
|
|
1100
|
+
* 内部**同步**发出的——监听器执行时 `registry.archivedSessionIds` 读到的还是
|
|
1101
|
+
* **旧快照**,旧实现据此算出「key 没变」直接早退、一帧都不推;真机表现为
|
|
1102
|
+
* 「dsh GUI 归档后要等 15s keepalive 才把卡片搬进『已完成』」(四组实测:
|
|
1103
|
+
* RPC 13~19ms,帧延迟 1.1/6.1/6.1/13.8s,全部落在 keepalive 节拍上)。
|
|
1104
|
+
* 事件载荷 `change.value` 就是刚落盘的整表状态(与 registry 随后缓存的是同一个
|
|
1105
|
+
* 对象引用),取 `archivedSessionIds` 即权威新值;非全局态写入(`table` 非空,
|
|
1106
|
+
* 如 workspaces 表记录)不携带该字段 → 回退读 registry(那类写入本就不改归档集)。
|
|
1107
|
+
* @param {object} change - `{domain, table, key, operation, value}`
|
|
1108
|
+
*/
|
|
1109
|
+
_onDomainChanged(change) {
|
|
1110
|
+
if (!change || change.domain !== 'workspace') return;
|
|
1111
|
+
const state = change.table ? null : change.value;
|
|
1112
|
+
const ids = (state && Array.isArray(state.archivedSessionIds))
|
|
1113
|
+
? state.archivedSessionIds : undefined;
|
|
1114
|
+
this._publishArchiveState(false, ids);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
761
1117
|
/**
|
|
762
1118
|
* 归档集变化 → 推一帧 `driver/archived` **整表快照**(进 stateRing + 广播)。
|
|
763
1119
|
* 整表而非增量:平台卡片绑定的 sid 可能不在会话池/观察表里(历史会话),
|
|
764
1120
|
* 整表帧覆盖任意 sid,且平台侧一次覆盖、无需逐会话记账。
|
|
765
1121
|
* @param {boolean} force - true=无条件发(启动基线);false=与上次发布值比对
|
|
1122
|
+
* @param {string[]|undefined} ids - 权威归档集(`domain/changed` 事件载荷优先);
|
|
1123
|
+
* 缺省=回读 registry(keepalive / 快速兜底扫描路径)
|
|
766
1124
|
*/
|
|
767
|
-
_publishArchiveState(force) {
|
|
768
|
-
const
|
|
769
|
-
if (
|
|
770
|
-
const key =
|
|
1125
|
+
_publishArchiveState(force, ids) {
|
|
1126
|
+
const list = Array.isArray(ids) ? ids.map(String) : this._archivedIds();
|
|
1127
|
+
if (list === null) return; // 服务缺失:不发帧(平台侧维持「未知」)
|
|
1128
|
+
const key = list.join('\n');
|
|
771
1129
|
if (!force && key === this._archivedKey) return;
|
|
772
1130
|
this._archivedKey = key;
|
|
773
|
-
this._publishState('driver/archived', { data: { archived:
|
|
1131
|
+
this._publishState('driver/archived', { data: { archived: list } });
|
|
774
1132
|
}
|
|
775
1133
|
|
|
776
1134
|
/** `GET /archived`:归档集整表(平台 EventHub 在(重)连对齐时取一次)。 */
|
|
@@ -855,7 +1213,12 @@ export class AgentDriver {
|
|
|
855
1213
|
const path = url.pathname.slice(DRIVER_PREFIX.length) || '/';
|
|
856
1214
|
try {
|
|
857
1215
|
if (path === '/health' && req.method === 'GET') return this._json(res, 200, this._health());
|
|
858
|
-
if (path === '/live' && req.method === 'GET')
|
|
1216
|
+
if (path === '/live' && req.method === 'GET') {
|
|
1217
|
+
// `complete` = 宿主已有会话是否已枚举(A 批):只有它为 true,Python 侧才敢把
|
|
1218
|
+
// 「空表」当成「宿主里确实没有会话」;否则空表一律按未知处理(不写列、不推断)。
|
|
1219
|
+
return this._json(res, 200, { sessions: this.live(),
|
|
1220
|
+
complete: this._enumerated === true });
|
|
1221
|
+
}
|
|
859
1222
|
if (path === '/status' && req.method === 'GET') return this._status(res, url);
|
|
860
1223
|
// `?scope=state` = 全局状态流(P4,Python EventHub 唯一连接);缺省 = 单会话事件流
|
|
861
1224
|
if (path === '/events' && req.method === 'GET') {
|
|
@@ -882,6 +1245,8 @@ export class AgentDriver {
|
|
|
882
1245
|
// 会话归档(看板「已完成」双向同步,2026-10-05)
|
|
883
1246
|
if (path === '/archived' && req.method === 'GET') return this._archived(res);
|
|
884
1247
|
if (path === '/archive' && req.method === 'POST') return await this._archive(res, req);
|
|
1248
|
+
// 看管声明(C 批,2026-10-10):外部会话注入开关——T2 投递回落/T3 提问认领/T4 审批的门
|
|
1249
|
+
if (path === '/watch' && req.method === 'POST') return await this._watch(res, req);
|
|
885
1250
|
this._json(res, 404, { error: `未知驱动端点 ${path}` });
|
|
886
1251
|
} catch (err) {
|
|
887
1252
|
this.logger.warn(`touchstone: 驱动 ${path} 失败: ${err && err.message}`);
|
|
@@ -897,14 +1262,63 @@ export class AgentDriver {
|
|
|
897
1262
|
prefix: DRIVER_PREFIX,
|
|
898
1263
|
live: this.sessions.size,
|
|
899
1264
|
observed: this.observed.size,
|
|
1265
|
+
watched: this.watched.size, // 平台声明的看管会话数(C 批;T2~T4 的作用域)
|
|
1266
|
+
// 宿主已有会话是否枚举成功(A 批):热重载后可见性恢复与否,看这一位
|
|
1267
|
+
enumerated: this._enumerated === true,
|
|
900
1268
|
};
|
|
901
1269
|
}
|
|
902
1270
|
|
|
903
|
-
/**
|
|
1271
|
+
/**
|
|
1272
|
+
* `POST /watch` {session_id, on?}:声明/撤销「平台看管该会话」(C 批,2026-10-10)。
|
|
1273
|
+
*
|
|
1274
|
+
* 语义:`on !== false`(缺省 true)= 纳入看管,`on:false` = 撤销;两者都幂等
|
|
1275
|
+
* (重复声明不报错,回执以 `watched` 给出**当前实际状态**,平台据此核对)。
|
|
1276
|
+
* 只写内存表、不碰宿主会话,故对未知 sid 也返回 200——`/watch` 是「声明意图」,
|
|
1277
|
+
* 不是「建立会话」;sid 是否真实存在由 T2 投递时的 404 兜底暴露。
|
|
1278
|
+
* 状态码约定:200=已记录(含幂等重复、含未知 sid);400=session_id 为空。
|
|
1279
|
+
*/
|
|
1280
|
+
async _watch(res, req) {
|
|
1281
|
+
const body = await this._body(req);
|
|
1282
|
+
const sid = String(body.session_id || '');
|
|
1283
|
+
if (!sid) {
|
|
1284
|
+
this._json(res, 400, { error: 'session_id 不能为空' });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const on = body.on !== false;
|
|
1288
|
+
if (on) this.watched.add(sid); else this.watched.delete(sid);
|
|
1289
|
+
this._json(res, 200, { ok: true, session_id: sid, watched: this.watched.has(sid) });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 单会话状态:含 last_seq(Python 订阅 SSE 时的续传基准)。
|
|
1294
|
+
*
|
|
1295
|
+
* C 批(T2,2026-10-10)加**并列**回落:池外但平台看管(`/watch`)且宿主仍有活 agent
|
|
1296
|
+
* 的会话,回最小实时态 `{session_id, status, external, interaction}`。**不含 `last_seq`**:
|
|
1297
|
+
* 外部会话的会话事件 seq 不进本池(`_onSessionEvent` 对非池内会话早退),平台侧基线
|
|
1298
|
+
* 口径由 `dshevents` 统一处理(设计 §4.3)。看管过但宿主已无活 agent 的会话仍走原文案
|
|
1299
|
+
* 404(控制者裁定:只规定「watched + 活 agent」这一档的回落形状,不动 /status 的既有
|
|
1300
|
+
* 404 文案),未看管会话行为一字不变。
|
|
1301
|
+
*
|
|
1302
|
+
* 重构(评审 Important 1):池外判定改调 `_externalTarget`(与 `/prompt`·`/steer` 共用
|
|
1303
|
+
* 的**唯一**闸门)。此处传 `res=null` 干跑——闸门只判定不写响应,于是「watched 但无活
|
|
1304
|
+
* agent」与本端点的既有 404 原文案自然合流到下面同一个出口,判定逻辑不再有第三份副本。
|
|
1305
|
+
*/
|
|
904
1306
|
_status(res, url) {
|
|
905
1307
|
const sid = url.searchParams.get('session_id') || '';
|
|
906
1308
|
const entry = this.sessions.get(sid);
|
|
907
1309
|
if (!entry) {
|
|
1310
|
+
// 池外:共用闸门(干跑,不写响应)判定「已看管 + 宿主有活 agent」这一档
|
|
1311
|
+
const live = this._externalTarget(null, sid);
|
|
1312
|
+
if (live) {
|
|
1313
|
+
this._json(res, 200, {
|
|
1314
|
+
session_id: sid,
|
|
1315
|
+
status: live.status,
|
|
1316
|
+
external: true,
|
|
1317
|
+
// 挂起实况取旁听表(`_observe` 写入):未旁听到即 null(平台既有口径,不推断)
|
|
1318
|
+
interaction: (this.observed.get(sid) || {}).interaction || null,
|
|
1319
|
+
});
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
908
1322
|
this._json(res, 404, { error: `会话不在驱动池中: ${sid}` });
|
|
909
1323
|
return;
|
|
910
1324
|
}
|
|
@@ -923,6 +1337,8 @@ export class AgentDriver {
|
|
|
923
1337
|
interaction: entry.interaction,
|
|
924
1338
|
cwd: entry.cwd,
|
|
925
1339
|
task: entry.task,
|
|
1340
|
+
// 会话头行 origin(子代理='subagent',主会话=空串)——与 /live 同口径
|
|
1341
|
+
origin: this._sessionOrigin(entry.agent && entry.agent.session),
|
|
926
1342
|
});
|
|
927
1343
|
}
|
|
928
1344
|
|
|
@@ -1107,12 +1523,35 @@ export class AgentDriver {
|
|
|
1107
1523
|
}
|
|
1108
1524
|
}
|
|
1109
1525
|
|
|
1110
|
-
/**
|
|
1526
|
+
/**
|
|
1527
|
+
* 投递一轮提示词:平台的「一轮」= 一次 followup + 等 turn/end(Python 侧等 SSE 帧)。
|
|
1528
|
+
*
|
|
1529
|
+
* C 批(T2,2026-10-10)加**并列**回落分支:池外但平台看管(`/watch`)且宿主仍有活
|
|
1530
|
+
* agent 的会话(用户在 dsh GUI 直跑的),直接对宿主 agent `followup` 投递、**不接管**,
|
|
1531
|
+
* 回执带 `external:true`。判定顺序(设计 §3.2):池内原路 → watched + 活 agent 回落
|
|
1532
|
+
* → 404 分档(已看管无活 agent / 未看管)。未看管会话的 404 文案与行为一字不变——
|
|
1533
|
+
* 仍走 `_lookup` 这个既有唯一出口。
|
|
1534
|
+
*
|
|
1535
|
+
* 重构(评审 Important 1):池外三档判定收敛到 `_externalTarget`(唯一闸门),本端点只留
|
|
1536
|
+
* 自己独有的三行——空 prompt→400、`live.followup` 投递、200 `external:true` 回执。
|
|
1537
|
+
*/
|
|
1111
1538
|
async _prompt(res, req) {
|
|
1112
1539
|
const body = await this._body(req);
|
|
1113
|
-
const
|
|
1114
|
-
if (!entry) return;
|
|
1540
|
+
const sid = String(body.session_id || '');
|
|
1115
1541
|
const text = String(body.prompt == null ? '' : body.prompt);
|
|
1542
|
+
const live = this._externalTarget(res, sid);
|
|
1543
|
+
if (live === null) return; // ② 已看管但宿主无活 agent:闸门已回 404 分档文案
|
|
1544
|
+
if (live !== undefined) { // ③ 已看管且宿主有活 agent:直投,不接管
|
|
1545
|
+
if (!text) {
|
|
1546
|
+
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
live.followup(this._userMessage(text));
|
|
1550
|
+
this._json(res, 200, { ok: true, session_id: sid, external: true });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const entry = this._lookup(res, body); // ① 池内/未看管:既有路径
|
|
1554
|
+
if (!entry) return;
|
|
1116
1555
|
if (!text) {
|
|
1117
1556
|
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1118
1557
|
return;
|
|
@@ -1122,20 +1561,33 @@ export class AgentDriver {
|
|
|
1122
1561
|
}
|
|
1123
1562
|
|
|
1124
1563
|
/**
|
|
1125
|
-
* 回答挂起的提问(2026-10-04 修 #791
|
|
1564
|
+
* 回答挂起的提问(2026-10-04 修 #791 起分两路;2026-10-06 修 #837 起 dsh GUI 也可答):
|
|
1126
1565
|
*
|
|
1127
|
-
* ①
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1566
|
+
* ① 插件**认领中**的提问(平台自持会话、以及 C 批 T3 起**看管的外部会话**的在途提问,
|
|
1567
|
+
* `this.questions` 命中):兑现认领 Promise——返回值由 waterfall 直接交回宿主
|
|
1568
|
+
* `ask_user_question`。这是平台答**在途**提问的通道(见 `_onQuestionRequest` 注释)。
|
|
1130
1569
|
* ② 认领表未命中:退回宿主 `ctx.userQuestions.answer(agent, callId, …)`——
|
|
1131
1570
|
* 只对「continued」(前台等待超时后)的提问有效,覆盖 timed 模式超时后
|
|
1132
|
-
* 平台迟到作答的场景;accepted=false
|
|
1133
|
-
*
|
|
1571
|
+
* 平台迟到作答的场景;accepted=false 表示问题已不存在/已应答(含**用户在
|
|
1572
|
+
* dsh GUI 先答**的情形:#837 起两侧同题,先答者胜),平台侧按
|
|
1573
|
+
* 40405「问题已不存在」放弃重试,不空转。
|
|
1574
|
+
*
|
|
1575
|
+
* 池外分支(C 批 T3):池内/未看管仍走 `_lookup` 这个既有唯一出口(404 文案一字不变);
|
|
1576
|
+
* **看管的外部会话**放行——认领表命中即兑现,否则对宿主活 agent(`_liveAgent`)走 ②;
|
|
1577
|
+
* 「看管但宿主已无活 agent」与未看管同走 404 原文案。这里**不套用** `_externalTarget`:
|
|
1578
|
+
* 该闸门对「看管但无活 agent」自写的是 `/prompt`·`/steer` 的分档文案,而本端点要维持
|
|
1579
|
+
* 既有单串;且认领表命中时与「宿主是否还有活 agent」无关(在途提问必须能兑现)。
|
|
1580
|
+
* 判定必须发生在写响应**之前**,不能先 `_lookup` 写了 404 再补第二个响应。
|
|
1134
1581
|
*/
|
|
1135
1582
|
async _answer(res, req) {
|
|
1136
1583
|
const body = await this._body(req);
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1584
|
+
const sid = String(body.session_id || '');
|
|
1585
|
+
// 池内 / 未看管的池外:既有路径一字不动(`_lookup` 是唯一 404 出口)
|
|
1586
|
+
const entry = this.sessions.get(sid);
|
|
1587
|
+
if (entry === undefined && !(sid && this._isWatched(sid))) {
|
|
1588
|
+
this._lookup(res, body);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1139
1591
|
const callId = String(body.call_id || '');
|
|
1140
1592
|
if (!callId) {
|
|
1141
1593
|
this._json(res, 400, { error: 'call_id 不能为空' });
|
|
@@ -1144,14 +1596,26 @@ export class AgentDriver {
|
|
|
1144
1596
|
const answers = Array.isArray(body.answers) ? body.answers : [];
|
|
1145
1597
|
const held = this.questions.get(callId);
|
|
1146
1598
|
if (held !== undefined) {
|
|
1147
|
-
if (held.sid !==
|
|
1599
|
+
if (held.sid !== sid) {
|
|
1148
1600
|
this._json(res, 409, { error: '提问不属于该会话' });
|
|
1149
1601
|
return;
|
|
1150
1602
|
}
|
|
1151
1603
|
this._settleQuestion(callId, { answers });
|
|
1152
|
-
this._json(res, 200, { ok: true, accepted: true, session_id:
|
|
1604
|
+
this._json(res, 200, { ok: true, accepted: true, session_id: sid });
|
|
1153
1605
|
return;
|
|
1154
1606
|
}
|
|
1607
|
+
// 认领表未命中:池内用池条目上的 agent(含接管条目的 agent=null,原样交给宿主);
|
|
1608
|
+
// 看管的外部会话用宿主活 agent,无活 agent 即与未看管同走 404 原文案。
|
|
1609
|
+
let agent;
|
|
1610
|
+
if (entry !== undefined) {
|
|
1611
|
+
agent = entry.agent;
|
|
1612
|
+
} else {
|
|
1613
|
+
agent = this._liveAgent(sid);
|
|
1614
|
+
if (!agent) {
|
|
1615
|
+
this._lookup(res, body);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1155
1619
|
const svc = this.agentCtx ? this.agentCtx.get('userQuestions') : null;
|
|
1156
1620
|
if (!svc || typeof svc.answer !== 'function') {
|
|
1157
1621
|
this._json(res, 503, { error: 'userQuestions 服务不可用' });
|
|
@@ -1159,20 +1623,41 @@ export class AgentDriver {
|
|
|
1159
1623
|
}
|
|
1160
1624
|
let accepted = false;
|
|
1161
1625
|
try {
|
|
1162
|
-
accepted = svc.answer(
|
|
1626
|
+
accepted = svc.answer(agent, callId, { answers });
|
|
1163
1627
|
} catch (err) {
|
|
1164
1628
|
this._json(res, 409, { error: `作答被拒: ${(err && err.message) || err}` });
|
|
1165
1629
|
return;
|
|
1166
1630
|
}
|
|
1167
|
-
if (accepted) this._clearQuestionMark(
|
|
1168
|
-
this._json(res, 200, { ok: true, accepted, session_id:
|
|
1631
|
+
if (accepted) this._clearQuestionMark(sid); // 受理即清挂起标记
|
|
1632
|
+
this._json(res, 200, { ok: true, accepted, session_id: sid });
|
|
1169
1633
|
}
|
|
1170
1634
|
|
|
1171
|
-
/**
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1635
|
+
/**
|
|
1636
|
+
* 插话:运行中 steer 到最近 step 边界(空闲则直接起一个 turn)。
|
|
1637
|
+
*
|
|
1638
|
+
* C 批(T2)回落与 `/prompt` 同款(设计 §3.2):池外 + 看管 + 宿主有活 agent ⇒ 对宿主
|
|
1639
|
+
* agent `steer` 直投并回 `external:true`;未看管会话行为一字不变。
|
|
1640
|
+
*
|
|
1641
|
+
* 重构(评审 Important 1):池外三档判定收敛到 `_externalTarget`(唯一闸门),本端点只留
|
|
1642
|
+
* 自己独有的三行——空 prompt→400、`live.steer` 插话、200 `external:true` 回执。
|
|
1643
|
+
*/
|
|
1644
|
+
async _steer(res, req) {
|
|
1645
|
+
const body = await this._body(req);
|
|
1646
|
+
const sid = String(body.session_id || '');
|
|
1175
1647
|
const text = String(body.prompt == null ? '' : body.prompt);
|
|
1648
|
+
const live = this._externalTarget(res, sid);
|
|
1649
|
+
if (live === null) return; // ② 已看管但宿主无活 agent:闸门已回 404 分档文案
|
|
1650
|
+
if (live !== undefined) { // ③ 已看管且宿主有活 agent:直投,不接管
|
|
1651
|
+
if (!text) {
|
|
1652
|
+
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
live.steer(this._userMessage(text));
|
|
1656
|
+
this._json(res, 200, { ok: true, session_id: sid, external: true });
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
const entry = this._lookup(res, body); // ① 池内/未看管:既有路径
|
|
1660
|
+
if (!entry) return;
|
|
1176
1661
|
if (!text) {
|
|
1177
1662
|
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1178
1663
|
return;
|
|
@@ -1221,11 +1706,21 @@ export class AgentDriver {
|
|
|
1221
1706
|
/**
|
|
1222
1707
|
* `approval/request` waterfall:返回 outcome 即「认领」,调用 next() 则让位。
|
|
1223
1708
|
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1709
|
+
* 注册选项是 `{prepend: true}`(C 批 T4):与提问同源,dsh Web 客户端问答桥先注册且
|
|
1710
|
+
* 认领后不再 next(),append 注册的 listener 在真机上永远收不到。prepend **只抢「先看」
|
|
1711
|
+
* 位置**,是否让位由下面的分支决定——池内未接管 / 看管外无活 agent / 未看管三类一律
|
|
1712
|
+
* `next()`,故 C 批之前的外部会话与未看管会话行为一个字节都没变。三条分支:
|
|
1713
|
+
*
|
|
1714
|
+
* 1) 池内(平台自持会话)且平台经 `/permission` 接管过(`holdApprovals`):**认领**,
|
|
1715
|
+
* 把请求挂起等 `/approval` 送达 `allowed-once|rejected|cancelled`;请求 signal abort
|
|
1716
|
+
* (撤回/取消)时落 `'cancelled'` 收口,不悬挂。**纯让位**(未接管/已有在途认领)
|
|
1717
|
+
* 时走 `next()`,GUI 的审批弹窗照常作答。
|
|
1718
|
+
* 2) **池外但平台已看管**(C 批 T4:用户 dsh GUI 直跑、平台只 `POST /watch` 声明看管的)
|
|
1719
|
+
* 且宿主仍有活 agent:与提问侧同款**双通道**——认领(挂起标记 `answerable:true` 带
|
|
1720
|
+
* `ap-<n>` id,平台 `/approval` 兑现 waterfall)**并且**原生链路照旧打开(dsh GUI 的
|
|
1721
|
+
* 审批框照常弹、照常可答),两侧先答者胜、另一侧收口(竞速口径见 `_holdApproval`)。
|
|
1722
|
+
* **不接管**该会话(不建池条目)。「看管但宿主已无活 agent」落回 `next()` 让位。
|
|
1723
|
+
* 3) 其它(用户直跑且**未**看管):原样 `next()` 让位,与 C 批之前逐字一致。
|
|
1229
1724
|
*
|
|
1230
1725
|
* 注意 `ApprovalRequestEvent` **没有 id**(id 由服务内部生成、只出现在
|
|
1231
1726
|
* `approval/asked|decided` 审计事件里),故这里自生成 `ap-<n>` 作为平台侧
|
|
@@ -1236,43 +1731,251 @@ export class AgentDriver {
|
|
|
1236
1731
|
const agent = request && request.agent;
|
|
1237
1732
|
const sid = String((agent && agent.session && agent.session.id) || '');
|
|
1238
1733
|
const entry = this.sessions.get(sid);
|
|
1239
|
-
|
|
1734
|
+
// ① 池内:既有认领逻辑逐字不动(未接管 / 已有在途认领 ⇒ 让位)
|
|
1735
|
+
if (entry !== undefined) {
|
|
1736
|
+
if (!entry.holdApprovals || entry.pendingApproval) return next();
|
|
1737
|
+
const id = `ap-${++this._approvalSeq}`;
|
|
1738
|
+
const mark = { kind: 'approval', id,
|
|
1739
|
+
tool: (request && request.toolName) || '',
|
|
1740
|
+
call_id: String((request && request.callId) || ''),
|
|
1741
|
+
reason: (request && request.reason) || '',
|
|
1742
|
+
answerable: true };
|
|
1743
|
+
entry.interaction = mark;
|
|
1744
|
+
entry.publish({ sid, seq: null, time: Date.now(), type: 'driver/interaction',
|
|
1745
|
+
data: { state: 'asked', interaction: mark } });
|
|
1746
|
+
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
1747
|
+
return new Promise((resolve) => {
|
|
1748
|
+
const settle = (outcome) => {
|
|
1749
|
+
if (!entry.pendingApproval || entry.pendingApproval.id !== id) return;
|
|
1750
|
+
entry.pendingApproval = null;
|
|
1751
|
+
entry.interaction = null;
|
|
1752
|
+
resolve(outcome);
|
|
1753
|
+
};
|
|
1754
|
+
entry.pendingApproval = { id, settle };
|
|
1755
|
+
const signal = request && request.signal;
|
|
1756
|
+
if (signal) {
|
|
1757
|
+
if (signal.aborted) return settle('cancelled');
|
|
1758
|
+
try {
|
|
1759
|
+
signal.addEventListener('abort', () => settle('cancelled'), { once: true });
|
|
1760
|
+
} catch { /* 无 addEventListener 的实现:忽略,靠 /approval 收口 */ }
|
|
1761
|
+
}
|
|
1762
|
+
return undefined;
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
// ② 池外 + 看管 + 宿主有活 agent:双通道(认领 + 原生框照旧弹),不接管会话
|
|
1766
|
+
if (sid && this._isWatched(sid) && this._liveAgent(sid)) {
|
|
1767
|
+
// 同会话已有在途认领:让位(与池内 `entry.pendingApproval` 同款)。若在这里覆盖
|
|
1768
|
+
// `approvals` 槽,前一条认领的 Promise 就再无兑现可能(正是 `_dropClaims` 要消灭的
|
|
1769
|
+
// 悬挂态);让位后它仍可经 dsh GUI 作答,不丢审批。
|
|
1770
|
+
if (this.approvals.has(sid)) return next();
|
|
1771
|
+
// 先留原宿主取消信号(轮次中止/会话销毁):打开原生通道会替换请求对象上的
|
|
1772
|
+
// `signal`(见 `_openNativeLane`),平台侧的收口仍要认**原信号**
|
|
1773
|
+
const hostSignal = request && request.signal;
|
|
1774
|
+
const lane = this._openNativeLane(request, next);
|
|
1775
|
+
return this._holdApproval(sid, request, hostSignal, lane);
|
|
1776
|
+
}
|
|
1777
|
+
// ③ 未看管 / 看管但宿主无活 agent:原样让位(GUI 照常可答)
|
|
1778
|
+
return next();
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
this.logger.warn(`touchstone: approval/request 处理异常: ${(err && err.message) || err}`);
|
|
1781
|
+
return next();
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* 认领一个**看管外部会话**的审批(C 批 T4;池内会话仍走 `entry.pendingApproval` 既有
|
|
1787
|
+
* 单槽,本方法只管池外):登记进 `this.approvals`、发挂起标记,返回的 Promise 由
|
|
1788
|
+
* **两个作答通道先到者**兑现——① 平台 `/approval`(`_settleApproval`);
|
|
1789
|
+
* ② 原生链路(dsh GUI 审批框,`_openNativeLane`)。返回值直接交回宿主 `approval/request`
|
|
1790
|
+
* waterfall(outcome 字符串)。
|
|
1791
|
+
*
|
|
1792
|
+
* 与 `_holdQuestion` 同构(两侧都显示、都可答,先答者胜):
|
|
1793
|
+
* · 原生链路**成功**即胜出:清平台挂起标记(Touchstone 侧的审批框随之收起),平台侧
|
|
1794
|
+
* 迟到的 `/approval` 落到「认领表已空」⇒ 409 收口,不重复兑现。
|
|
1795
|
+
* · 原生链路**失败**不算数(GUI 关掉框、客户端异常、无客户端的 profile…):平台通道
|
|
1796
|
+
* 继续等 `/approval`。真正的失败只由宿主取消信号(轮次 abort / 会话销毁)给出。
|
|
1797
|
+
* · 平台先答:`_settleApproval` 主动 abort 原生通道的取消信号 → dsh GUI 的审批框自行
|
|
1798
|
+
* 收起(见 `_openNativeLane`),不留「答了也没人收」的死框。
|
|
1799
|
+
*
|
|
1800
|
+
* 中止(停卡/cancel,宿主 signal abort):清标记 + 收起 GUI 框 + reject,宿主按
|
|
1801
|
+
* 「审批请求已中止」归一,不悬挂(与 `_holdQuestion` 的中止口径一致)。
|
|
1802
|
+
*
|
|
1803
|
+
* 单槽约束:`approvals` 按 sid 索引(与池内 `entry.pendingApproval` 同款),同一会话
|
|
1804
|
+
* 同时只有一条在途认领;若宿主并发抛来第二条,`_onApprovalRequest` 让位给它走原生通道,
|
|
1805
|
+
* 不覆盖本槽(否则前一条的 Promise 永远兑现不了)。
|
|
1806
|
+
*
|
|
1807
|
+
* @param {string} sid - 看管中的外部会话 id(**没有**池条目,故标记只能走 observed)
|
|
1808
|
+
* @param {object} request - 宿主 `approval/request` 请求对象(toolName/callId/reason)
|
|
1809
|
+
* @param {AbortSignal|undefined} hostSignal - 原宿主取消信号(替换前的)
|
|
1810
|
+
* @param {{promise: Promise<any>, cancel: Function}} lane - 原生作答通道
|
|
1811
|
+
* @returns {Promise<string>} 平台的 outcome(`allowed-once|rejected|cancelled`)
|
|
1812
|
+
*/
|
|
1813
|
+
_holdApproval(sid, request, hostSignal, lane) {
|
|
1814
|
+
return new Promise((resolve, reject) => {
|
|
1240
1815
|
const id = `ap-${++this._approvalSeq}`;
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1816
|
+
const pending = { sid, id, mark: null, done: false, finish: null, resolve, reject,
|
|
1817
|
+
lane: lane || null,
|
|
1818
|
+
cancelLane: () => { if (lane) lane.cancel(new Error('the platform answered the approval first')); } };
|
|
1819
|
+
this.approvals.set(sid, pending);
|
|
1820
|
+
/** 收口(幂等):摘表 + 解绑中止监听 + 清挂起标记 */
|
|
1821
|
+
pending.finish = () => {
|
|
1822
|
+
if (pending.done) return;
|
|
1823
|
+
pending.done = true;
|
|
1824
|
+
if (this.approvals.get(sid) === pending) this.approvals.delete(sid);
|
|
1825
|
+
if (pending.onAbort && hostSignal
|
|
1826
|
+
&& typeof hostSignal.removeEventListener === 'function') {
|
|
1827
|
+
hostSignal.removeEventListener('abort', pending.onAbort);
|
|
1828
|
+
}
|
|
1829
|
+
// 挂起标记与提问共用同一个 interaction 槽(`_clearQuestionMark` 即该槽的收口口)
|
|
1830
|
+
this._clearQuestionMark(sid);
|
|
1831
|
+
};
|
|
1832
|
+
// 原生通道(dsh GUI):成功即胜出并清平台挂起标记;失败一律忽略(平台通道仍在)
|
|
1833
|
+
if (lane) {
|
|
1834
|
+
lane.promise.then((value) => {
|
|
1835
|
+
if (pending.done) return;
|
|
1836
|
+
pending.finish();
|
|
1837
|
+
resolve(value);
|
|
1838
|
+
}, () => { /* 见 docstring:旁路故障不判死审批 */ });
|
|
1839
|
+
}
|
|
1840
|
+
if (hostSignal) {
|
|
1841
|
+
if (hostSignal.aborted) {
|
|
1842
|
+
pending.finish();
|
|
1843
|
+
pending.cancelLane();
|
|
1844
|
+
reject(new Error('approval request aborted before the platform answered'));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
pending.onAbort = () => {
|
|
1848
|
+
pending.finish();
|
|
1849
|
+
pending.cancelLane();
|
|
1850
|
+
reject(new Error('approval request aborted before the platform answered'));
|
|
1851
|
+
};
|
|
1852
|
+
hostSignal.addEventListener('abort', pending.onAbort);
|
|
1853
|
+
}
|
|
1854
|
+
pending.mark = this._publishApprovalMark(sid, id, request);
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* 兑现认领中的**外部会话**审批(平台 `/approval` 送达):true=命中并已交付宿主。
|
|
1860
|
+
* 平台先答时顺带 abort 原生通道的取消信号——dsh GUI 的审批框随之收起。
|
|
1861
|
+
*
|
|
1862
|
+
* `approval_id` 空串按池内分支同款语义处理(不指定 = 兑现当前在途那条);
|
|
1863
|
+
* 显式给了 id 但不符 ⇒ false(不误兑现别的审批)。
|
|
1864
|
+
*
|
|
1865
|
+
* @param {string} sid - 看管中的外部会话 id
|
|
1866
|
+
* @param {string} approvalId - 平台回传的 `ap-<n>`(可空)
|
|
1867
|
+
* @param {string} outcome - `allowed-once|rejected|cancelled`(调用方已校验)
|
|
1868
|
+
* @returns {boolean} 命中并兑现即 true;无在途认领或 id 不符即 false
|
|
1869
|
+
*/
|
|
1870
|
+
_settleApproval(sid, approvalId, outcome) {
|
|
1871
|
+
const pending = this.approvals.get(sid);
|
|
1872
|
+
if (pending === undefined) return false;
|
|
1873
|
+
const wanted = String(approvalId == null ? '' : approvalId);
|
|
1874
|
+
if (wanted && wanted !== pending.id) return false;
|
|
1875
|
+
pending.finish();
|
|
1876
|
+
pending.cancelLane();
|
|
1877
|
+
pending.resolve(outcome);
|
|
1878
|
+
return true;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* 发布「审批挂起」标记(与 `_publishQuestionMark` 同款,仅用于**池外**认领):
|
|
1883
|
+
* 外部会话没有池条目,故写 `observed`(平台 `/status`、`/live` 的挂起实况来源)
|
|
1884
|
+
* 并推全局状态流;池内分支保持既有的 inline 写法(`entry.publish` + 状态流)逐字不动。
|
|
1885
|
+
*
|
|
1886
|
+
* @returns {object} 标记本身(形状:`{kind:'approval', id, tool, call_id, reason, answerable}`)
|
|
1887
|
+
*/
|
|
1888
|
+
_publishApprovalMark(sid, id, request) {
|
|
1889
|
+
const mark = { kind: 'approval', id,
|
|
1890
|
+
tool: (request && request.toolName) || '',
|
|
1891
|
+
call_id: String((request && request.callId) || ''),
|
|
1892
|
+
reason: (request && request.reason) || '',
|
|
1893
|
+
answerable: true };
|
|
1894
|
+
if (!sid) return mark;
|
|
1895
|
+
const entry = this.sessions.get(sid);
|
|
1896
|
+
const seen = this.observed.get(sid);
|
|
1897
|
+
if (entry) {
|
|
1246
1898
|
entry.interaction = mark;
|
|
1247
1899
|
entry.publish({ sid, seq: null, time: Date.now(), type: 'driver/interaction',
|
|
1248
1900
|
data: { state: 'asked', interaction: mark } });
|
|
1249
|
-
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
1250
|
-
return new Promise((resolve) => {
|
|
1251
|
-
const settle = (outcome) => {
|
|
1252
|
-
if (!entry.pendingApproval || entry.pendingApproval.id !== id) return;
|
|
1253
|
-
entry.pendingApproval = null;
|
|
1254
|
-
entry.interaction = null;
|
|
1255
|
-
resolve(outcome);
|
|
1256
|
-
};
|
|
1257
|
-
entry.pendingApproval = { id, settle };
|
|
1258
|
-
const signal = request && request.signal;
|
|
1259
|
-
if (signal) {
|
|
1260
|
-
if (signal.aborted) return settle('cancelled');
|
|
1261
|
-
try {
|
|
1262
|
-
signal.addEventListener('abort', () => settle('cancelled'), { once: true });
|
|
1263
|
-
} catch { /* 无 addEventListener 的实现:忽略,靠 /approval 收口 */ }
|
|
1264
|
-
}
|
|
1265
|
-
return undefined;
|
|
1266
|
-
});
|
|
1267
|
-
} catch (err) {
|
|
1268
|
-
this.logger.warn(`touchstone: approval/request 处理异常: ${(err && err.message) || err}`);
|
|
1269
|
-
return next();
|
|
1270
1901
|
}
|
|
1902
|
+
if (seen) seen.interaction = mark;
|
|
1903
|
+
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
1904
|
+
return mark;
|
|
1271
1905
|
}
|
|
1272
1906
|
|
|
1273
|
-
/**
|
|
1907
|
+
/**
|
|
1908
|
+
* 收口某会话的**全部在途认领**(C 批 T4):提问认领 reject、审批认领 reject、挂起标记
|
|
1909
|
+
* 一并清掉。调用点两处——`session/disposed`(宿主会话被销毁)与 `dispose()`(插件停用)。
|
|
1910
|
+
*
|
|
1911
|
+
* 为什么必须有:认领的 Promise 是宿主 `ask()`/审批 waterfall 的返回值,会话没了就**再无
|
|
1912
|
+
* 兑现可能**;只把它留在表里 = 宿主侧永久悬挂 + 平台侧挂起标记残留(卡永远显示「等作答」)。
|
|
1913
|
+
* T3 有意不写 disposed 清理,明文把这一态留给本批(计划分工)。
|
|
1914
|
+
*
|
|
1915
|
+
* 收口口径:一律 **reject**(与 `dispose()` 里提问的全量收口同一口径),宿主见异常/信号
|
|
1916
|
+
* 中止自会归一为中断;`finish()` 幂等,重复调用/已兑现的条目是纯 no-op。
|
|
1917
|
+
*
|
|
1918
|
+
* @param {string} sid - 会话 id(池内/池外皆可;按 `pending.sid` 匹配)
|
|
1919
|
+
*/
|
|
1920
|
+
_dropClaims(sid) {
|
|
1921
|
+
const key = String(sid || '');
|
|
1922
|
+
if (!key) return;
|
|
1923
|
+
// 只在**确实摘掉了认领**时才补发挂起标记收口帧:会话被销毁时本方法对每个 sid 都会跑,
|
|
1924
|
+
// 无认领的会话(尤其**未看管**会话)不能因此多出一帧状态变更——那是可观测的行为漂移。
|
|
1925
|
+
let dropped = false;
|
|
1926
|
+
for (const [callId, pending] of [...this.questions]) {
|
|
1927
|
+
if (pending.sid !== key) continue;
|
|
1928
|
+
try {
|
|
1929
|
+
pending.finish();
|
|
1930
|
+
pending.cancelLane();
|
|
1931
|
+
pending.reject(new Error('host session disposed before the question settled'));
|
|
1932
|
+
dropped = true;
|
|
1933
|
+
} catch {
|
|
1934
|
+
/* 已收口/已兑现:忽略 */
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const approval = this.approvals.get(key);
|
|
1938
|
+
if (approval !== undefined) {
|
|
1939
|
+
try {
|
|
1940
|
+
approval.finish();
|
|
1941
|
+
approval.cancelLane();
|
|
1942
|
+
approval.reject(new Error('host session disposed before the approval settled'));
|
|
1943
|
+
dropped = true;
|
|
1944
|
+
} catch {
|
|
1945
|
+
/* 已收口/已兑现:忽略 */
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// 两条链都没命中就不发帧(`finish()` 本身已清标记,这里只兜「认领在别处摘了表、
|
|
1949
|
+
// 标记却还在」的残留态)
|
|
1950
|
+
if (dropped) this._clearQuestionMark(key);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* `POST /approval`:{session_id, approval_id?, decision} → 兑现挂起的审批。
|
|
1955
|
+
*
|
|
1956
|
+
* 池外分支(C 批 T4):**看管中**的外部会话放行——认领表(`approvals`)命中即兑现
|
|
1957
|
+
* (宿主 waterfall 收到 outcome),否则回 409「当前没有等待中的审批(或已由 GUI 作答)」
|
|
1958
|
+
* (GUI 先答/已收口时的迟到作答就落这里,与池内分支同串)。decision 先在外部支校验,
|
|
1959
|
+
* 非法一律 400、**绝不**把非法 outcome 递宿主。池内与未看管仍走 `_lookup` 这个既有唯一
|
|
1960
|
+
* 404 出口(文案一字不变):判定必须发生在写响应**之前**,不能先 `_lookup` 写了 404
|
|
1961
|
+
* 再补第二个响应(与 `_answer` 同款理由)。
|
|
1962
|
+
*/
|
|
1274
1963
|
async _approval(res, req) {
|
|
1275
1964
|
const body = await this._body(req);
|
|
1965
|
+
const sid = String(body.session_id || '');
|
|
1966
|
+
if (this.sessions.get(sid) === undefined && sid && this._isWatched(sid)) {
|
|
1967
|
+
const decision = String(body.decision == null ? '' : body.decision);
|
|
1968
|
+
if (!['allowed-once', 'rejected', 'cancelled'].includes(decision)) {
|
|
1969
|
+
this._json(res, 400, { error: 'decision 非法(allowed-once/rejected/cancelled)' });
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (this._settleApproval(sid, body.approval_id, decision)) {
|
|
1973
|
+
this._json(res, 200, { ok: true, accepted: true, session_id: sid, outcome: decision });
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
this._json(res, 409, { error: '当前没有等待中的审批(或已由 GUI 作答)' });
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1276
1979
|
const entry = this._lookup(res, body);
|
|
1277
1980
|
if (!entry) return;
|
|
1278
1981
|
const pending = entry.pendingApproval;
|
|
@@ -1528,22 +2231,64 @@ export class AgentDriver {
|
|
|
1528
2231
|
* 而 `commands` 可注入且 `command-compact`(`/compact`)在本 profile 已装载
|
|
1529
2232
|
* (`--dump-config` 可见)。命令是异步长任务(要过一次模型),故**触发即回**
|
|
1530
2233
|
* `{started:true}`;真正的失败只落宿主日志(平台侧 compact 语义本来就是"发起")。
|
|
2234
|
+
*
|
|
2235
|
+
* 池外回落(2026-10-10,用户报障「卡片 945 无法 compact」):平台自建卡会话在宿主侧
|
|
2236
|
+
* 重载/重启后只剩观察(`/live` 的 `owned:false`)而**宿主里仍活着**——此时 `/compact`
|
|
2237
|
+
* 若只认驱动池(旧行为)就必 404「会话不在驱动池中」,前端把这条内部文案原样 toast。
|
|
2238
|
+
* 语义与 `/rename` 的池外回落同款,判定复用**唯一闸门** `_externalTarget`:
|
|
2239
|
+
* ① 池内 ⇒ 既有路径(`entry.agent` 触发,回执不带 `external`);
|
|
2240
|
+
* ② 已看管但宿主无活 agent ⇒ 闸门自写 404 分档文案(会话已结束);
|
|
2241
|
+
* ③ 未看管 ⇒ 走 `_lookup` 这个既有唯一 404 出口(原文案一字不变);
|
|
2242
|
+
* ④ 已看管且宿主有活 agent ⇒ 对**宿主活 agent** 触发压缩(回执带 `external:true`),
|
|
2243
|
+
* **不接管**(不入池、不建 handle、不动 `owned`)。
|
|
2244
|
+
*
|
|
2245
|
+
* 为什么池外只能走「直投活 agent」而不是「先接管再压缩」:外部会话的 agent 已在宿主
|
|
2246
|
+
* `agents` store 里注册,`/session` resume(`agents.enter()`)对已注册 id 直接抛
|
|
2247
|
+
* `agent "…" is already registered` ⇒ 接管必 500(C 批 T1 的实测结论)。
|
|
1531
2248
|
*/
|
|
1532
2249
|
async _compact(res, req) {
|
|
1533
2250
|
const body = await this._body(req);
|
|
1534
|
-
const
|
|
1535
|
-
|
|
2251
|
+
const sid = String((body && body.session_id) || '');
|
|
2252
|
+
const entry = this.sessions.get(sid);
|
|
2253
|
+
let agent = entry && entry.agent;
|
|
2254
|
+
let external = false;
|
|
2255
|
+
if (!entry) {
|
|
2256
|
+
const live = this._externalTarget(res, sid);
|
|
2257
|
+
if (live === null) return; // ② 已看管无活 agent:闸门已回 404 分档
|
|
2258
|
+
if (live === undefined) { // ③ 未看管:既有唯一 404 出口(原文案)
|
|
2259
|
+
this._lookup(res, body);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
agent = live; // ④ 已看管 + 宿主有活 agent:直投,不接管
|
|
2263
|
+
external = true;
|
|
2264
|
+
}
|
|
1536
2265
|
const commands = this.ctx.get('commands');
|
|
1537
2266
|
if (!commands || typeof commands.execute !== 'function') {
|
|
1538
2267
|
this._json(res, 503, { error: 'commands 服务不可用,无法触发 /compact' });
|
|
1539
2268
|
return;
|
|
1540
2269
|
}
|
|
1541
2270
|
const controller = new AbortController();
|
|
1542
|
-
Promise.resolve(commands.execute(
|
|
1543
|
-
.then(() =>
|
|
2271
|
+
Promise.resolve(commands.execute(agent, '/compact', [], controller.signal))
|
|
2272
|
+
.then((out) => {
|
|
2273
|
+
// 回执形状(`@deepseek-ai/dsh-commands` 的 `execute`):`{commandId, result:{kind,text}}`;
|
|
2274
|
+
// 语法/命令名不解析时 resolve `undefined`(该 profile 没挂 command-compact)。关键是
|
|
2275
|
+
// **`kind:'error'` 也是正常 resolve**(真实失败之一:会话非 idle ⇒ busy,见
|
|
2276
|
+
// `dsh-command-compact` 的 expectedFailure 文案)——旧实现无条件记「完成」,会把
|
|
2277
|
+
// 「压根没压」写成成功。池外会话多正被用户驱动,撞 busy 的概率高于池内,故按实况记账
|
|
2278
|
+
// (HTTP 语义不变:仍是「触发即回」,这里只让宿主日志说真话)。
|
|
2279
|
+
const result = (out && out.result) || null;
|
|
2280
|
+
if (!result) {
|
|
2281
|
+
this.logger.warn(`touchstone: /compact 未执行 sid=${sid}: 该 profile 未装载 /compact 命令`);
|
|
2282
|
+
} else if (result.kind === 'error') {
|
|
2283
|
+
this.logger.warn(`touchstone: /compact 未生效 sid=${sid}: ${result.text || '未知原因'}`);
|
|
2284
|
+
} else {
|
|
2285
|
+
this.logger.info(`touchstone: /compact 完成 sid=${sid}${result.text ? `: ${result.text}` : ''}`);
|
|
2286
|
+
}
|
|
2287
|
+
})
|
|
1544
2288
|
.catch((err) => this.logger.warn(
|
|
1545
|
-
`touchstone: /compact 失败 sid=${
|
|
1546
|
-
this._json(res, 200, { ok: true, session_id:
|
|
2289
|
+
`touchstone: /compact 失败 sid=${sid}: ${(err && err.message) || err}`));
|
|
2290
|
+
this._json(res, 200, { ok: true, session_id: sid, started: true,
|
|
2291
|
+
...(external ? { external: true } : {}) });
|
|
1547
2292
|
}
|
|
1548
2293
|
|
|
1549
2294
|
/**
|
|
@@ -1616,11 +2361,35 @@ export class AgentDriver {
|
|
|
1616
2361
|
this._json(res, 200, { ok: true, session_id: entry.sessionId, new_session_id: newSid });
|
|
1617
2362
|
}
|
|
1618
2363
|
|
|
1619
|
-
/**
|
|
2364
|
+
/**
|
|
2365
|
+
* `POST /rename`:会话标题(平台侧「会话命名」;与 dsh 侧栏同一份标题)。
|
|
2366
|
+
*
|
|
2367
|
+
* 池外回落(2026-10-10 用户需求「TS 看板卡改名 → DSH 会话名同步」):平台**看管**
|
|
2368
|
+
* (`/watch`,只声明不接管)且宿主仍有活 agent 的池外会话(用户在 dsh GUI 直跑的)
|
|
2369
|
+
* ⇒ 直接改宿主会话标题,回执带 `external:true`。判定复用 `/prompt`·`/steer` 的
|
|
2370
|
+
* **唯一闸门** `_externalTarget`,三档语义一字不差:
|
|
2371
|
+
* ① 池内 ⇒ 原路径(本函数开头已单独处理);
|
|
2372
|
+
* ② 已看管但宿主无活 agent ⇒ 闸门自写 404 分档文案(会话已结束);
|
|
2373
|
+
* ③ 未看管 ⇒ 走 `_lookup` 这个既有唯一 404 出口(原文案不变);**判定顺序同
|
|
2374
|
+
* `/prompt`**:先定档再校验空标题——池外未看管的空标题仍先撞 404,不被 400 截胡。
|
|
2375
|
+
* 为什么**不接管**(不入池、不建 handle、不动 `owned`):改名只需要宿主
|
|
2376
|
+
* `sessionController.rename`(内部 `resolveAgent(sessionId)` 对任何宿主可解析的会话
|
|
2377
|
+
* 都可用),一旦收养就会改 `owned` 与项目占用/`ext:` 行口径,代价远大于改名本身。
|
|
2378
|
+
*/
|
|
1620
2379
|
async _rename(res, req) {
|
|
1621
2380
|
const body = await this._body(req);
|
|
1622
|
-
const
|
|
1623
|
-
|
|
2381
|
+
const sid = String((body && body.session_id) || '');
|
|
2382
|
+
const entry = this.sessions.get(sid);
|
|
2383
|
+
let external = false;
|
|
2384
|
+
if (!entry) {
|
|
2385
|
+
const live = this._externalTarget(res, sid);
|
|
2386
|
+
if (live === null) return; // ② 已看管无活 agent:闸门已回 404 分档
|
|
2387
|
+
if (live === undefined) { // ③ 未看管:既有唯一 404 出口(原文案)
|
|
2388
|
+
this._lookup(res, body);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
external = true; // 已被看管且宿主有活 agent:改名回落
|
|
2392
|
+
}
|
|
1624
2393
|
const title = String(body.title == null ? '' : body.title).trim();
|
|
1625
2394
|
if (!title) {
|
|
1626
2395
|
this._json(res, 400, { error: 'title 不能为空' });
|
|
@@ -1631,9 +2400,9 @@ export class AgentDriver {
|
|
|
1631
2400
|
this._json(res, 503, { error: 'sessionController 服务不可用,无法改名' });
|
|
1632
2401
|
return;
|
|
1633
2402
|
}
|
|
1634
|
-
const value = await controller.rename({ sessionId:
|
|
2403
|
+
const value = await controller.rename({ sessionId: sid, title });
|
|
1635
2404
|
this._json(res, 200, {
|
|
1636
|
-
ok: true, session_id:
|
|
2405
|
+
ok: true, session_id: sid, ...(external ? { external: true } : {}),
|
|
1637
2406
|
title: String((value && value.title) || title),
|
|
1638
2407
|
});
|
|
1639
2408
|
}
|