@ynhcj/xiaoyi-channel 0.0.231-next → 0.0.232-next

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.
Files changed (88) hide show
  1. package/dist/index.js +171 -6
  2. package/dist/src/channel.js +5 -2
  3. package/dist/src/conversation/conversation-manager.d.ts +12 -3
  4. package/dist/src/conversation/conversation-manager.js +89 -8
  5. package/dist/src/conversation/conversation-session.d.ts +16 -0
  6. package/dist/src/conversation/conversation-session.js +5 -0
  7. package/dist/src/conversation/cron-buffer.d.ts +36 -11
  8. package/dist/src/conversation/cron-buffer.js +157 -56
  9. package/dist/src/conversation/outbound-gateway.d.ts +4 -0
  10. package/dist/src/conversation/outbound-gateway.js +2 -2
  11. package/dist/src/conversation/outbound-queue.d.ts +35 -0
  12. package/dist/src/conversation/outbound-queue.js +96 -0
  13. package/dist/src/conversation/stream-assembler.d.ts +52 -0
  14. package/dist/src/conversation/stream-assembler.js +122 -0
  15. package/dist/src/cron-command.js +2 -24
  16. package/dist/src/cspl/call_api.js +8 -2
  17. package/dist/src/cspl/constants.d.ts +1 -0
  18. package/dist/src/cspl/constants.js +2 -0
  19. package/dist/src/cspl/sentinel_hook.js +6 -9
  20. package/dist/src/cspl/utils.d.ts +2 -2
  21. package/dist/src/cspl/utils.js +8 -8
  22. package/dist/src/dispatch/bot.js +43 -3
  23. package/dist/src/dispatch/outbound.js +14 -5
  24. package/dist/src/dispatch/reply-dispatcher.js +283 -155
  25. package/dist/src/dynamic-skill-trigger.d.ts +8 -0
  26. package/dist/src/dynamic-skill-trigger.js +62 -0
  27. package/dist/src/formatter.d.ts +7 -0
  28. package/dist/src/formatter.js +71 -94
  29. package/dist/src/provider.js +87 -4
  30. package/dist/src/reply-dispatcher.js +16 -12
  31. package/dist/src/self-evolution-handler.d.ts +1 -1
  32. package/dist/src/skill-retriever/config.js +2 -2
  33. package/dist/src/skill-retriever/hooks.js +34 -13
  34. package/dist/src/skill-retriever/skill-status.d.ts +22 -0
  35. package/dist/src/skill-retriever/skill-status.js +125 -0
  36. package/dist/src/skill-retriever/tool-search.d.ts +4 -2
  37. package/dist/src/skill-retriever/tool-search.js +75 -15
  38. package/dist/src/skill-retriever/types.d.ts +1 -0
  39. package/dist/src/tools/agent-as-skill-tool.js +1 -1
  40. package/dist/src/tools/calendar-tool.js +1 -1
  41. package/dist/src/tools/call-device-tool.js +24 -13
  42. package/dist/src/tools/call-phone-tool.js +4 -2
  43. package/dist/src/tools/check-plugin-privilege-tool.js +1 -1
  44. package/dist/src/tools/create-alarm-tool.js +1 -1
  45. package/dist/src/tools/delete-alarm-tool.js +1 -1
  46. package/dist/src/tools/discover-cross-devices-tool.js +1 -1
  47. package/dist/src/tools/display-a2ui-card-bypath-tool.js +43 -10
  48. package/dist/src/tools/hmos-cli.js +2 -2
  49. package/dist/src/tools/invoke.js +4 -4
  50. package/dist/src/tools/location-tool.js +1 -1
  51. package/dist/src/tools/login-token-tool.js +1 -1
  52. package/dist/src/tools/modify-alarm-tool.js +1 -1
  53. package/dist/src/tools/modify-note-tool.js +1 -1
  54. package/dist/src/tools/note-tool.js +1 -1
  55. package/dist/src/tools/query-app-message-tool.js +1 -1
  56. package/dist/src/tools/query-memory-data-tool.js +1 -1
  57. package/dist/src/tools/query-todo-task-tool.js +1 -1
  58. package/dist/src/tools/save-file-to-phone-tool.js +1 -1
  59. package/dist/src/tools/save-media-to-gallery-tool.js +1 -1
  60. package/dist/src/tools/search-alarm-tool.js +1 -1
  61. package/dist/src/tools/search-calendar-tool.js +1 -1
  62. package/dist/src/tools/search-contact-tool.js +1 -1
  63. package/dist/src/tools/search-email-tool.js +1 -1
  64. package/dist/src/tools/search-file-tool.js +1 -1
  65. package/dist/src/tools/search-message-tool.js +1 -1
  66. package/dist/src/tools/search-note-tool.js +1 -1
  67. package/dist/src/tools/search-photo-gallery-tool.js +1 -1
  68. package/dist/src/tools/send-cross-device-task-tool.js +1 -1
  69. package/dist/src/tools/send-email-tool.js +1 -2
  70. package/dist/src/tools/send-file-to-user-tool.js +1 -1
  71. package/dist/src/tools/send-message-tool.js +4 -2
  72. package/dist/src/tools/session-manager.d.ts +4 -4
  73. package/dist/src/tools/session-manager.js +23 -12
  74. package/dist/src/tools/upload-file-tool.js +1 -1
  75. package/dist/src/tools/upload-photo-tool.js +1 -1
  76. package/dist/src/tools/xiaoyi-add-collection-tool.js +1 -1
  77. package/dist/src/tools/xiaoyi-collection-tool.js +1 -1
  78. package/dist/src/tools/xiaoyi-delete-collection-tool.js +1 -1
  79. package/dist/src/tools/xiaoyi-gui-tool.js +3 -3
  80. package/dist/src/transport/push.d.ts +3 -1
  81. package/dist/src/transport/push.js +9 -1
  82. package/dist/src/trigger-handler.js +1 -1
  83. package/dist/src/utils/async-mutex.d.ts +5 -0
  84. package/dist/src/utils/async-mutex.js +14 -0
  85. package/dist/src/utils/cron-push-map.js +29 -21
  86. package/dist/src/utils/pushdata-manager.d.ts +9 -1
  87. package/dist/src/utils/pushdata-manager.js +28 -19
  88. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -15,6 +15,8 @@ import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
15
15
  import { registerCLIHook } from "./src/tools/hmos-cli.js";
16
16
  import { recoverCronState } from "./src/cron-recovery.js";
17
17
  import { writeSkillUsage } from "./src/utils/skills-logger.js";
18
+ import { markSubagentSpawned, markSubagentEnded, deliverSubagentFinalResult, } from "./src/conversation/conversation-manager.js";
19
+ import { notifyCronAgentEnd } from "./src/conversation/cron-buffer.js";
18
20
  import { logger } from "./src/utils/logger.js";
19
21
  /**
20
22
  * Parse a file path string to detect if it refers to a SKILL.md file within
@@ -88,19 +90,32 @@ function registerSkillsDiagnosticHook(api) {
88
90
  }
89
91
  });
90
92
  }
93
+ /**
94
+ * 从 openclaw sessionKey 解析 cron jobId。
95
+ * 格式形如 `agent:<agentId>:cron:<jobId>:run:<startedAtTs>`(隔离 run 为
96
+ * `cron:<jobId>`),jobId 位于 "cron:" 之后到下一个冒号/空白之前。
97
+ */
98
+ function parseCronJobIdFromSessionKey(sessionKey) {
99
+ const match = sessionKey.match(/cron:([^:\s]+)/);
100
+ return match ? match[1] : undefined;
101
+ }
91
102
  /**
92
103
  * Register the cron detection hook.
93
104
  *
94
105
  * When openclaw's cron runner triggers a tool call, the sessionKey has the
95
- * format "cron:<jobId>". We use this to mark the toolCallId in a global Map
106
+ * format "cron:<jobId>"(或 agent 展开后的 `agent:<id>:cron:<jobId>:run:<ts>`)。
107
+ * We use this to mark the toolCallId in a global Map
96
108
  * so that sendCommand() can route the command through the push channel
97
109
  * instead of the (non-existent) WebSocket session.
98
110
  */
99
111
  function registerCronDetectionHook(api) {
100
112
  api.on("before_tool_call", async (event, ctx) => {
101
- // sessionKey 前缀依赖 openclaw 版本行为,不一定可靠;
102
- // isCronActive() 由 provider.ts 根据消息内容 [cron:...] 设置,更可靠。
103
- if ((ctx.sessionKey?.startsWith("cron:") || isCronActive()) && event.toolCallId) {
113
+ // sessionKey 前缀依赖 openclaw 版本行为,不一定可靠;isCronActive(jobId)
114
+ // 按 jobId 精确判断(provider 已从消息 [cron:...] 标记过该 job),替代
115
+ // 原先的全局 120s 时间戳,避免把普通对话/其它 job 的工具调用误判为 cron。
116
+ const sessionKey = ctx.sessionKey ?? "";
117
+ const sessionJobId = parseCronJobIdFromSessionKey(sessionKey);
118
+ if ((sessionKey.includes("cron:") || isCronActive(sessionJobId)) && event.toolCallId) {
104
119
  markCronToolCall(event.toolCallId);
105
120
  // 存储 runId,供 call_device_tool 在 ALS 缺失时构造合成 SessionContext
106
121
  if (event.runId) {
@@ -318,15 +333,165 @@ function registerCronRecoveryHook(api) {
318
333
  logger.log(`${logTag} ═══════════════════════════════════════════`);
319
334
  });
320
335
  }
336
+ /**
337
+ * 从 sessions_spawn 工具结果中提取 ACP child sessionKey。
338
+ * 兼容三种 result 形态:{status, childSessionKey} 直返、JSON 字符串、
339
+ * {content:[{text: "<json>"}], details:{...}} 工具信封。
340
+ * 仅接受 status="accepted" 且 key 形如 agent:<id>:acp:<uuid> 的结果。
341
+ */
342
+ function extractAcceptedAcpChildSessionKey(result) {
343
+ const pick = (obj) => {
344
+ if (!obj || typeof obj !== "object")
345
+ return null;
346
+ const record = obj;
347
+ if (record.status !== "accepted")
348
+ return null;
349
+ const key = record.childSessionKey;
350
+ return typeof key === "string" && key.includes(":acp:") ? key : null;
351
+ };
352
+ const direct = pick(result);
353
+ if (direct)
354
+ return direct;
355
+ const envelope = result;
356
+ const fromDetails = pick(envelope?.details);
357
+ if (fromDetails)
358
+ return fromDetails;
359
+ if (Array.isArray(envelope?.content)) {
360
+ for (const item of envelope.content) {
361
+ if (typeof item?.text !== "string")
362
+ continue;
363
+ try {
364
+ const fromText = pick(JSON.parse(item.text));
365
+ if (fromText)
366
+ return fromText;
367
+ }
368
+ catch {
369
+ // 非 JSON 文本,忽略
370
+ }
371
+ }
372
+ }
373
+ if (typeof result === "string") {
374
+ try {
375
+ return pick(JSON.parse(result));
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ function registerSubagentHooks(api) {
384
+ // subagent_spawned: fires after a subagent run is successfully registered.
385
+ // We increment the expected completion count so that onIdle knows to wait.
386
+ api.on("subagent_spawned", async (_event, ctx) => {
387
+ const requesterSessionKey = ctx?.requesterSessionKey;
388
+ if (!requesterSessionKey)
389
+ return;
390
+ const count = markSubagentSpawned(requesterSessionKey);
391
+ if (count > 0) {
392
+ logger.log(`[XY-SUBAGENT] spawned, requesterSessionKey=${requesterSessionKey.slice(0, 30)}, expected=${count}`);
393
+ }
394
+ });
395
+ // ACP SPAWN TRACKING: openclaw 的 subagent_spawned hook 仅由内嵌 subagent
396
+ // 路径(subagent-spawn.ts)发射;acp-spawn.ts(runtime="acp",如 Claude Code)
397
+ // 不发射 —— 实测 ACP run 仅在结束时经 run registry 触发 subagent_ended,
398
+ // 导致等待态从未建立、父 turn onIdle 直接发 final 结束对话。
399
+ // 因此在 after_tool_call 补记:sessions_spawn 成功且 childSessionKey 为 ACP
400
+ // 形态时递增等待计数。内嵌 subagent 的 childKey 形如 agent:<id>:subagent:<id>,
401
+ // 不命中此分支,仍由 subagent_spawned hook 负责,不会重复计数。
402
+ api.on("after_tool_call", async (event, ctx) => {
403
+ try {
404
+ if (event?.toolName !== "sessions_spawn" || event?.error)
405
+ return;
406
+ const childKey = extractAcceptedAcpChildSessionKey(event?.result);
407
+ if (!childKey)
408
+ return;
409
+ const sessionKey = ctx?.sessionKey;
410
+ if (!sessionKey)
411
+ return;
412
+ const count = markSubagentSpawned(sessionKey);
413
+ if (count > 0) {
414
+ logger.log(`[XY-SUBAGENT] ACP spawned, childSessionKey=${childKey.slice(0, 40)}, expected=${count}`);
415
+ }
416
+ }
417
+ catch (err) {
418
+ logger.error(`[XY-SUBAGENT] Error tracking ACP spawn:`, err);
419
+ }
420
+ });
421
+ // subagent_ended: fires when a subagent run terminates (complete/error/killed).
422
+ // This is the PRIMARY delivery tracking mechanism. When all expected
423
+ // subagents have ended and parent has settled, we finalize the A2A session.
424
+ api.on("subagent_ended", async (event, ctx) => {
425
+ try {
426
+ const requesterSessionKey = ctx?.requesterSessionKey;
427
+ if (!requesterSessionKey) {
428
+ logger.log(`[XY-SUBAGENT-END] no requesterSessionKey in ctx`);
429
+ return;
430
+ }
431
+ const transition = markSubagentEnded(requesterSessionKey);
432
+ logger.log(`[XY-SUBAGENT-END] ended, targetSessionKey=${event?.targetSessionKey?.slice(0, 30)}, outcome=${event?.outcome}, complete=${transition?.isComplete ?? false}, shouldFinalize=${transition?.shouldFinalize ?? false}, transition=${!!transition}`);
433
+ if (transition?.shouldFinalize) {
434
+ logger.log(`[XY-SUBAGENT-END] Starting finalization...`);
435
+ // Grace: announce 投递的 sendText 与 subagent_ended hook 并发到达
436
+ // (实测 sendText 可能先于 hook 约 1s,也可能晚于 hook;长文本还会
437
+ // 被 textChunkLimit 分片成多次 sendText)。捕获窗口直到
438
+ // finalDeliveryStarted 才关闭,此处的等待直接扩大迟到来片的入窗机会。
439
+ await new Promise((r) => setTimeout(r, 3000));
440
+ // 最终交付由对话管理层统一负责(含 config 解析、final 帧、清理)
441
+ await deliverSubagentFinalResult({
442
+ state: transition.state,
443
+ reason: "all-subagents-ended-after-parent-settled",
444
+ });
445
+ logger.log(`[XY-SUBAGENT-END] Finalized A2A session after all subagents ended`);
446
+ }
447
+ }
448
+ catch (err) {
449
+ logger.error(`[XY-SUBAGENT-END] Error in subagent_ended hook:`, err);
450
+ }
451
+ });
452
+ }
321
453
  function registerFullHooks(api) {
454
+ // SUBAGENT HOOKS: track subagent spawn/end lifecycle for session keep-alive
455
+ registerSubagentHooks(api);
456
+ // CRON AGGREGATION: 通过 agent 事件总线感知 cron isolated run 结束。
457
+ // 注意:agent_end 插件 hook 只在 dispatch-from-config 路径触发,cron
458
+ // isolated run 不会触发它;embedded run 的 lifecycle 事件才是可靠信号。
459
+ // phase=end/error 后到达的 sendText 即为 announce 最终结果(放行)。
460
+ api.agent.events.registerAgentEventSubscription({
461
+ id: "xy-cron-turn-tracker",
462
+ description: "Track cron isolated run completion for sendText aggregation",
463
+ streams: ["lifecycle"],
464
+ handle: (event) => {
465
+ try {
466
+ const phase = event?.data?.phase;
467
+ // 仅 phase=end 是 run 级终态信号;phase=error 可能是单次模型调用
468
+ // 失败(后续还有重试/start),不能据此放行 announce。
469
+ // 真正失败的 run(无 end)由 120s 安全超时兜底 flush 缓冲文本。
470
+ if (phase !== "end")
471
+ return;
472
+ const sessionKey = event?.sessionKey ?? "";
473
+ // 从 sessionKey(agent:<id>:cron:<jobId>:run:<ts>)解析 jobId,只结束
474
+ // 该 job 的 turn;isCronActive(jobId) 作为无 cron 前缀版本的兜底(按
475
+ // jobId 精确判断,普通对话的 lifecycle end 不会命中)。
476
+ const cronJobId = parseCronJobIdFromSessionKey(sessionKey);
477
+ if (sessionKey.includes("cron:") || sessionKey.startsWith("cron:") || isCronActive(cronJobId)) {
478
+ logger.log(`[XY-CRON] Cron run lifecycle end, sessionKey=${sessionKey || "unknown"}, jobId=${cronJobId ?? "-"}`);
479
+ notifyCronAgentEnd(cronJobId);
480
+ }
481
+ }
482
+ catch (err) {
483
+ logger.error(`[XY-CRON] Error in cron lifecycle subscription:`, err);
484
+ }
485
+ },
486
+ });
322
487
  // SKILL RETRIEVER HOOK: before_prompt_build hook
323
488
  const pluginConfig = api.pluginConfig || {};
324
489
  const skillRetrieverConfig = normalizeToolRetrieverConfig({
325
490
  enabled: pluginConfig.skillRetrieverEnabled ?? true,
326
- maxTools: pluginConfig.skillRetrieverMaxTools ?? 2,
491
+ maxTools: pluginConfig.skillRetrieverMaxTools ?? 6,
327
492
  includeUninstalledOnly: true,
328
493
  envFilePath: "~/.openclaw/.xiaoyienv",
329
- timeoutMs: pluginConfig.skillRetrieverTimeoutMs ?? 1000,
494
+ timeoutMs: pluginConfig.skillRetrieverTimeoutMs ?? 10000,
330
495
  });
331
496
  const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
332
497
  api.on("before_prompt_build", async (event, ctx) => {
@@ -1,6 +1,6 @@
1
1
  import { resolveXYConfig, listXYAccountIds, getDefaultXYAccountId } from "./config.js";
2
2
  import { xyConfigSchema } from "./config-schema.js";
3
- import { xyOutbound } from "./outbound.js";
3
+ import { xyOutbound } from "./dispatch/outbound.js";
4
4
  import { filterToolsByDevice } from "./tools/device-tool-map.js";
5
5
  import { getCurrentSessionContext } from "./tools/session-manager.js";
6
6
  import { logger } from "./utils/logger.js";
@@ -23,7 +23,10 @@ import { getAlarmToolSchemaTool } from "./tools/get-alarm-tool-schema.js";
23
23
  import { getCollectionToolSchemaTool } from "./tools/get-collection-tool-schema.js";
24
24
  import { loginTokenTool } from "./tools/login-token-tool.js";
25
25
  import { agentAsSkillTool } from "./tools/agent-as-skill-tool.js";
26
+ // import { discoverCrossDevicesTool } from "./tools/discover-cross-devices-tool.js";
27
+ // import { sendCrossDeviceTaskTool } from "./tools/send-cross-device-task-tool.js";
26
28
  import { displayA2UICardByPathTool } from "./tools/display-a2ui-card-bypath-tool.js";
29
+ // import { checkPluginPrivilegeTool } from "./tools/check-plugin-privilege-tool.js";
27
30
  import { invokeTool } from "./tools/invoke.js";
28
31
  import { xiaoyiAppendReferenceTool } from "./tools/xiaoyi-append-reference.js";
29
32
  const ALL_TOOLS = [
@@ -152,7 +155,7 @@ export const xyPlugin = {
152
155
  },
153
156
  gateway: {
154
157
  async startAccount(context) {
155
- const { monitorXYProvider } = await import("./monitor.js");
158
+ const { monitorXYProvider } = await import("./dispatch/monitor.js");
156
159
  const { createXyAcpBindingManager } = await import("./acp-session-binding.js");
157
160
  const account = resolveXYConfig(context.cfg);
158
161
  context.setStatus?.({
@@ -29,6 +29,13 @@ export declare function listActiveTaskBindings(): Array<{
29
29
  currentMessageId: string;
30
30
  updatedAt: number;
31
31
  }>;
32
+ /**
33
+ * dispatch 时登记在途 turn(bot.ts 创建 dispatcher 后调用)。
34
+ * 注意:steer 直接注入成功的路径不 dispatch,不得登记。
35
+ */
36
+ export declare function markTurnInflight(sessionId: string, taskId: string): void;
37
+ /** turn 不再产生流式帧时清除在途标记(onIdle 入口 / onSettled 兜底)。 */
38
+ export declare function clearTurnInflight(sessionId: string, taskId: string): void;
32
39
  /**
33
40
  * 绑定 openclaw sessionKey 到首条消息的 A2A 身份。
34
41
  * 仅在首条消息时调用(steer 消息不覆盖,保持 subagent 等待态跟踪稳定)。
@@ -74,9 +81,11 @@ export declare function getWaitState(sessionId: string, taskId?: string): Subage
74
81
  export declare function hasWaitState(sessionId: string, taskId?: string): boolean;
75
82
  export declare function clearWaitState(sessionId: string, reason: string, taskId?: string): SubagentWaitState | null;
76
83
  /**
77
- * 当所有 subagent 完成且父 turn 已 settle 时,由 manager 发送唯一的
78
- * final 帧并真正结束当前 A2A session:
79
- * 1. 合并 completionTexts 发送 final:true 帧
84
+ * 当所有 subagent 完成且父 turn 已 settle 时,由 manager 发送终态帧并真正
85
+ * 结束当前 A2A session:
86
+ * 1. completed 状态帧("任务处理已完成~")→ final:true 全文本帧(合并
87
+ * completionTexts)——与 reply-dispatcher 正常终态同序,xy_tester
88
+ * 与客户端依赖该顺序
80
89
  * 2. 清理等待态、停止心跳、移除任务链
81
90
  * 3. 会话状态迁移到 completed
82
91
  */
@@ -43,6 +43,8 @@ export function getSession(sessionId) {
43
43
  return sessions.get(sessionId) ?? null;
44
44
  }
45
45
  export function deleteSession(sessionId) {
46
+ const session = sessions.get(sessionId);
47
+ session?.outboundQueue.destroy();
46
48
  sessions.delete(sessionId);
47
49
  }
48
50
  export function listSessions() {
@@ -136,6 +138,28 @@ export function listActiveTaskBindings() {
136
138
  }
137
139
  return result;
138
140
  }
141
+ // ─── 在途 turn 跟踪(subagent 终态帧时机门控) ───────────────
142
+ /**
143
+ * dispatch 时登记在途 turn(bot.ts 创建 dispatcher 后调用)。
144
+ * 注意:steer 直接注入成功的路径不 dispatch,不得登记。
145
+ */
146
+ export function markTurnInflight(sessionId, taskId) {
147
+ const session = getOrCreateSession(sessionId);
148
+ if (!session.inflightTurns) {
149
+ session.inflightTurns = new Set();
150
+ }
151
+ session.inflightTurns.add(taskId);
152
+ }
153
+ /** turn 不再产生流式帧时清除在途标记(onIdle 入口 / onSettled 兜底)。 */
154
+ export function clearTurnInflight(sessionId, taskId) {
155
+ const session = sessions.get(sessionId);
156
+ session?.inflightTurns?.delete(taskId);
157
+ }
158
+ /** session 内是否仍有 turn 在流式输出(未 idle)。 */
159
+ function hasInflightTurn(sessionId) {
160
+ const session = sessions.get(sessionId);
161
+ return (session?.inflightTurns?.size ?? 0) > 0;
162
+ }
139
163
  // ─── SessionKey ↔ SessionId 索引 ──────────────────────────────
140
164
  /**
141
165
  * 绑定 openclaw sessionKey 到首条消息的 A2A 身份。
@@ -354,15 +378,33 @@ export function clearWaitState(sessionId, reason, taskId) {
354
378
  }
355
379
  // ─── Subagent 最终交付(收拢自原 outbound.deliverSubagentFinalResult) ──
356
380
  /**
357
- * 当所有 subagent 完成且父 turn 已 settle 时,由 manager 发送唯一的
358
- * final 帧并真正结束当前 A2A session:
359
- * 1. 合并 completionTexts 发送 final:true 帧
381
+ * 当所有 subagent 完成且父 turn 已 settle 时,由 manager 发送终态帧并真正
382
+ * 结束当前 A2A session:
383
+ * 1. completed 状态帧("任务处理已完成~")→ final:true 全文本帧(合并
384
+ * completionTexts)——与 reply-dispatcher 正常终态同序,xy_tester
385
+ * 与客户端依赖该顺序
360
386
  * 2. 清理等待态、停止心跳、移除任务链
361
387
  * 3. 会话状态迁移到 completed
362
388
  */
363
389
  export async function deliverSubagentFinalResult(params) {
364
390
  const { state, reason } = params;
365
391
  const log = logger.withContext(state.sessionId, state.taskId);
392
+ // 🔑 幂等 guard:终态交付只执行一次。onIdle defer 分支 / onSettled 安全网 /
393
+ // subagent_ended hook 可能相继触发交付,重复调用为 no-op。
394
+ // (检查与下方 finalDeliveryStarted 置位之间无 await,不会并发穿透。)
395
+ if (state.finalDeliveryStarted) {
396
+ log.log(`[SUBAGENT-FINAL] Final delivery already started, skipping duplicate trigger, reason=${reason}`);
397
+ return;
398
+ }
399
+ // 🔑 时机门控:session 内仍有 turn 在流式输出(steer 重分发的新 turn 未
400
+ // idle)时推迟交付 —— 此时以最新 taskId 发 final:true 会提前截断在途
401
+ // 回复,且新 turn 之后还会再发一次 final(双重终态帧)。推迟期间
402
+ // waitState 完整保留(parentSettled、完成计数等就绪态不丢失),由在途
403
+ // turn 的 onIdle defer 分支或 onSettled 安全网就地交付,身份仍取任务链末尾。
404
+ if (hasInflightTurn(state.sessionId)) {
405
+ log.log(`[SUBAGENT-FINAL] Deferring final delivery — turn still in flight, reason=${reason}`);
406
+ return;
407
+ }
366
408
  const config = getCachedXYConfig();
367
409
  if (!config) {
368
410
  log.error(`[SUBAGENT-FINAL] No cached XY config, cannot deliver final result`);
@@ -375,17 +417,56 @@ export async function deliverSubagentFinalResult(params) {
375
417
  ? state.completionTexts.join("\n\n")
376
418
  : (params.text || undefined) ?? "子任务已完成";
377
419
  setSessionState(state.sessionId, "completing");
378
- await sendA2AResponse({
420
+ // final 帧走会话出站队列:排在任何在途 partial 帧之后,保证客户端
421
+ // 先收齐流式正文再收到 final(与 reply-dispatcher 的终态帧同一收口点)。
422
+ // 终态帧延迟同 reply-dispatcher 的 terminalFrameDelayMs 治理:让已发出的
423
+ // 长正文先穿过下游服务端慢速管道,保证终态帧最后到达。
424
+ // 解析下发帧所用的 A2A 身份:跟随任务链末尾的最新 taskId/messageId。
425
+ // steer 重分发场景下 state.taskId 为旧身份,取链尾可保证终态帧与
426
+ // 心跳/保活帧的 taskId 一致,客户端不会看到 taskId 跳跃。
427
+ const effectiveTaskId = getCurrentTaskId(state.sessionId) ?? state.taskId;
428
+ const effectiveMessageId = getCurrentMessageId(state.sessionId) ?? state.messageId;
429
+ const terminalFrameDelayMs = config.terminalFrameDelayMs ?? 0;
430
+ const session = sessions.get(state.sessionId);
431
+ const sendCompletedStatus = () => sendStatusUpdate({
432
+ config,
433
+ sessionId: state.sessionId,
434
+ taskId: effectiveTaskId,
435
+ messageId: effectiveMessageId,
436
+ text: "任务处理已完成~",
437
+ state: "completed",
438
+ });
439
+ const sendFinal = () => sendA2AResponse({
379
440
  config,
380
441
  sessionId: state.sessionId,
381
- taskId: state.taskId,
382
- messageId: state.messageId,
442
+ taskId: effectiveTaskId,
443
+ messageId: effectiveMessageId,
383
444
  text: finalText,
384
445
  append: false,
385
446
  final: true,
386
447
  });
448
+ if (session) {
449
+ session.outboundQueue.enqueue({
450
+ taskId: effectiveTaskId,
451
+ label: "subagent-terminal-status",
452
+ delayMs: terminalFrameDelayMs,
453
+ send: sendCompletedStatus,
454
+ });
455
+ session.outboundQueue.enqueue({
456
+ taskId: effectiveTaskId,
457
+ label: "subagent-final",
458
+ delayMs: terminalFrameDelayMs,
459
+ send: sendFinal,
460
+ });
461
+ await session.outboundQueue.whenIdle();
462
+ }
463
+ else {
464
+ await sendCompletedStatus();
465
+ await sendFinal();
466
+ }
387
467
  clearWaitState(state.sessionId, reason ?? "all-subagent-results-delivered", state.taskId);
388
- completeTask(state.sessionId, state.taskId);
468
+ // 清理整个任务链(无 expectedTaskId),避免 steer 引入的额外 taskId 残留。
469
+ completeTask(state.sessionId);
389
470
  setSessionState(state.sessionId, "completed");
390
- log.log(`[SUBAGENT-FINAL] Subagent final delivered to original A2A task, reason=${reason}`);
471
+ log.log(`[SUBAGENT-FINAL] Subagent final delivered, reason=${reason}, effectiveTaskId=${effectiveTaskId}`);
391
472
  }
@@ -1,3 +1,5 @@
1
+ import { StreamAssembler } from "./stream-assembler.js";
2
+ import { OutboundQueue } from "./outbound-queue.js";
1
3
  export interface TaskEntry {
2
4
  taskId: string;
3
5
  messageId: string;
@@ -39,6 +41,20 @@ export interface ConversationSession {
39
41
  subagentWaits: SubagentWaitState[];
40
42
  /** manager 拥有的 30s 状态心跳定时器。 */
41
43
  statusTimer?: NodeJS.Timeout;
44
+ /**
45
+ * 当前 dispatcher 的流式文本装配器(model/injected 段拼接 + 权威文本修正)。
46
+ * 生命周期随 dispatcher:dispatcher 创建时挂载,终态清理时卸载;
47
+ * 挂在这里是为了让工具(display-a2ui-card 等)经 getSession 注入文本段。
48
+ */
49
+ assembler?: StreamAssembler;
50
+ /** 会话级出站队列:所有出站帧的时序收口点,生命周期随 session。 */
51
+ outboundQueue: OutboundQueue;
52
+ /**
53
+ * 在途 turn 的 taskId 集合(dispatch 时登记,onIdle/onSettled 时清除)。
54
+ * subagent 终态帧的时机门控据此判断"session 内仍有 turn 在流式输出",
55
+ * 防止 final:true 提前截断在途回复(steer 重分发场景)。
56
+ */
57
+ inflightTurns: Set<string>;
42
58
  lastActivityAt: number;
43
59
  }
44
60
  export declare function createConversationSession(sessionId: string): ConversationSession;
@@ -6,12 +6,17 @@
6
6
  // - sessionKey: openclaw 路由 sessionKey(吸收自 sessionKeyMap)
7
7
  // - subagentWaits: subagent 等待态(吸收自 subagent-wait-state)
8
8
  // - statusTimer: 30s 状态心跳定时器(由 manager 拥有)
9
+ // - assembler: 流式文本装配器(model/injected 段拼接 + 权威文本修正)
10
+ // - outboundQueue: 会话级出站 FIFO(帧时序唯一收口,coalescing/delayMs)
11
+ import { OutboundQueue } from "./outbound-queue.js";
9
12
  export function createConversationSession(sessionId) {
10
13
  return {
11
14
  sessionId,
12
15
  state: "working",
13
16
  tasks: [],
14
17
  subagentWaits: [],
18
+ outboundQueue: new OutboundQueue(sessionId),
19
+ inflightTurns: new Set(),
15
20
  lastActivityAt: Date.now(),
16
21
  };
17
22
  }
@@ -1,25 +1,50 @@
1
1
  import type { XYChannelConfig } from "../types.js";
2
- /** 关闭当前 cron turn(清理全部定时器)。 */
3
- export declare function closeCronTurn(reason: string): void;
2
+ /** 随 push 下发的 cron 元数据(jobId + title),客户端据此识别 push 来源。 */
3
+ export interface CronTurnMeta {
4
+ jobId?: string;
5
+ jobTitle?: string;
6
+ }
7
+ /** 关闭指定 job 的 cron turn(清理全部定时器)。 */
8
+ export declare function closeCronTurn(jobId: string | undefined, reason: string): void;
4
9
  /**
5
10
  * cron turn 开始检测。由 provider.ts 在识别到 [cron:...] 首条消息时调用。
6
11
  * 同一 cron run 的每次模型调用都会重复触发检测 —— 此时仅刷新安全定时器,
7
12
  * 不重开 turn(避免 churn,也避免重置 agentEnded/缓冲状态)。
13
+ * 按 jobId 隔离:不同 job 并发触发时各自独立建 turn,后到 job 的 jobId
14
+ * 不再被覆盖丢弃。
8
15
  */
9
- export declare function notifyCronDetected(jobId?: string): void;
16
+ export declare function notifyCronDetected(jobId?: string, jobTitle?: string): void;
10
17
  /**
11
18
  * cron run 结束信号(lifecycle phase=end,由 index.ts 的 agent event
12
- * subscription 调用)。进入 announce 宽限期:
19
+ * subscription 调用,jobId 从 event.sessionKey 解析)。只结束该 job 的
20
+ * turn,进入 announce 宽限期:
13
21
  * - 宽限期内 announce 到达 → 放行(见 gateCronSendText);
14
22
  * - 宽限期结束无 announce → 合并缓冲文本发一条(路径 B)。
23
+ * jobId 缺失(sessionKey 解析失败)时:若恰有 1 个活跃 turn,兼容旧单槽
24
+ * 行为结束之;多个活跃 turn 时无法归属,跳过并告警。
15
25
  */
16
- export declare function notifyCronAgentEnd(): void;
26
+ export declare function notifyCronAgentEnd(jobId?: string): void;
17
27
  export type CronSendTextGate = "swallow" | "announce" | "none";
28
+ export interface CronGateResult {
29
+ gate: CronSendTextGate;
30
+ /**
31
+ * gate === "announce" 时的 cron 元数据同步快照(其余情况为 null)。
32
+ * 快照在 gate 返回时完成 —— 调用方后续 await 空窗期内 turn 可能被
33
+ * safety timer 关闭,届时再读 turn 已拿不到 meta。
34
+ */
35
+ meta: CronTurnMeta | null;
36
+ /** 本次 sendText 归属的 cron jobId(调用方据此精确关闭 turn)。 */
37
+ jobId?: string;
38
+ }
18
39
  /**
19
- * sendText 闸门:
20
- * - "swallow":cron turn 进行中(run 未结束),吞掉中间 sendText,文本入缓冲;
21
- * - "announce":run 已结束(宽限期内),当前 sendText 是 announce 最终
22
- * 结果,放行(唯一一条,缓冲丢弃);
23
- * - "none":无 cron turn,正常发送。
40
+ * sendText 闸门。sendText 层拿不到 jobId/runId,只能凭 announceLike
41
+ * (to 是否为 DEFAULT_PUSH_MARKER,即是否有显式 target)做粗路由:
42
+ * - announceLike(无 target):run 结束后的 announce 最终结果,路由到处于
43
+ * agentEnded 宽限的 turn。多个 ended turn(两端几乎同时结束的窄竞态)按
44
+ * endedAt FIFO,与 openclaw 按 run 完成顺序投递 announce 一致。
45
+ * - 非 announceLike(有 target):run 期间的中间 sendText,吞入活跃 turn
46
+ * 缓冲。恰 1 个活跃 turn 正常吞;多活跃 turn(真并发、无法归属)安全回退
47
+ * 返回 "none"(不聚合、原样发送,宁可多推也不让 pushText/cronId 串台)。
48
+ * - "none":无 cron turn 或无法归属,按普通 push 发送。
24
49
  */
25
- export declare function gateCronSendText(swallowedText?: string, config?: XYChannelConfig): CronSendTextGate;
50
+ export declare function gateCronSendText(swallowedText: string | undefined, announceLike: boolean, config?: XYChannelConfig): CronGateResult;