@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.
- package/dist/index.js +171 -6
- package/dist/src/channel.js +5 -2
- package/dist/src/conversation/conversation-manager.d.ts +12 -3
- package/dist/src/conversation/conversation-manager.js +89 -8
- package/dist/src/conversation/conversation-session.d.ts +16 -0
- package/dist/src/conversation/conversation-session.js +5 -0
- package/dist/src/conversation/cron-buffer.d.ts +36 -11
- package/dist/src/conversation/cron-buffer.js +157 -56
- package/dist/src/conversation/outbound-gateway.d.ts +4 -0
- package/dist/src/conversation/outbound-gateway.js +2 -2
- package/dist/src/conversation/outbound-queue.d.ts +35 -0
- package/dist/src/conversation/outbound-queue.js +96 -0
- package/dist/src/conversation/stream-assembler.d.ts +52 -0
- package/dist/src/conversation/stream-assembler.js +122 -0
- package/dist/src/cron-command.js +2 -24
- package/dist/src/cspl/call_api.js +8 -2
- package/dist/src/cspl/constants.d.ts +1 -0
- package/dist/src/cspl/constants.js +2 -0
- package/dist/src/cspl/sentinel_hook.js +6 -9
- package/dist/src/cspl/utils.d.ts +2 -2
- package/dist/src/cspl/utils.js +8 -8
- package/dist/src/dispatch/bot.js +43 -3
- package/dist/src/dispatch/outbound.js +14 -5
- package/dist/src/dispatch/reply-dispatcher.js +283 -155
- package/dist/src/dynamic-skill-trigger.d.ts +8 -0
- package/dist/src/dynamic-skill-trigger.js +62 -0
- package/dist/src/formatter.d.ts +7 -0
- package/dist/src/formatter.js +71 -94
- package/dist/src/provider.js +87 -4
- package/dist/src/reply-dispatcher.js +16 -12
- package/dist/src/self-evolution-handler.d.ts +1 -1
- package/dist/src/skill-retriever/config.js +2 -2
- package/dist/src/skill-retriever/hooks.js +34 -13
- package/dist/src/skill-retriever/skill-status.d.ts +22 -0
- package/dist/src/skill-retriever/skill-status.js +125 -0
- package/dist/src/skill-retriever/tool-search.d.ts +4 -2
- package/dist/src/skill-retriever/tool-search.js +75 -15
- package/dist/src/skill-retriever/types.d.ts +1 -0
- package/dist/src/tools/agent-as-skill-tool.js +1 -1
- package/dist/src/tools/calendar-tool.js +1 -1
- package/dist/src/tools/call-device-tool.js +24 -13
- package/dist/src/tools/call-phone-tool.js +4 -2
- package/dist/src/tools/check-plugin-privilege-tool.js +1 -1
- package/dist/src/tools/create-alarm-tool.js +1 -1
- package/dist/src/tools/delete-alarm-tool.js +1 -1
- package/dist/src/tools/discover-cross-devices-tool.js +1 -1
- package/dist/src/tools/display-a2ui-card-bypath-tool.js +43 -10
- package/dist/src/tools/hmos-cli.js +2 -2
- package/dist/src/tools/invoke.js +4 -4
- package/dist/src/tools/location-tool.js +1 -1
- package/dist/src/tools/login-token-tool.js +1 -1
- package/dist/src/tools/modify-alarm-tool.js +1 -1
- package/dist/src/tools/modify-note-tool.js +1 -1
- package/dist/src/tools/note-tool.js +1 -1
- package/dist/src/tools/query-app-message-tool.js +1 -1
- package/dist/src/tools/query-memory-data-tool.js +1 -1
- package/dist/src/tools/query-todo-task-tool.js +1 -1
- package/dist/src/tools/save-file-to-phone-tool.js +1 -1
- package/dist/src/tools/save-media-to-gallery-tool.js +1 -1
- package/dist/src/tools/search-alarm-tool.js +1 -1
- package/dist/src/tools/search-calendar-tool.js +1 -1
- package/dist/src/tools/search-contact-tool.js +1 -1
- package/dist/src/tools/search-email-tool.js +1 -1
- package/dist/src/tools/search-file-tool.js +1 -1
- package/dist/src/tools/search-message-tool.js +1 -1
- package/dist/src/tools/search-note-tool.js +1 -1
- package/dist/src/tools/search-photo-gallery-tool.js +1 -1
- package/dist/src/tools/send-cross-device-task-tool.js +1 -1
- package/dist/src/tools/send-email-tool.js +1 -2
- package/dist/src/tools/send-file-to-user-tool.js +1 -1
- package/dist/src/tools/send-message-tool.js +4 -2
- package/dist/src/tools/session-manager.d.ts +4 -4
- package/dist/src/tools/session-manager.js +23 -12
- package/dist/src/tools/upload-file-tool.js +1 -1
- package/dist/src/tools/upload-photo-tool.js +1 -1
- package/dist/src/tools/xiaoyi-add-collection-tool.js +1 -1
- package/dist/src/tools/xiaoyi-collection-tool.js +1 -1
- package/dist/src/tools/xiaoyi-delete-collection-tool.js +1 -1
- package/dist/src/tools/xiaoyi-gui-tool.js +3 -3
- package/dist/src/transport/push.d.ts +3 -1
- package/dist/src/transport/push.js +9 -1
- package/dist/src/trigger-handler.js +1 -1
- package/dist/src/utils/async-mutex.d.ts +5 -0
- package/dist/src/utils/async-mutex.js +14 -0
- package/dist/src/utils/cron-push-map.js +29 -21
- package/dist/src/utils/pushdata-manager.d.ts +9 -1
- package/dist/src/utils/pushdata-manager.js +28 -19
- 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>"
|
|
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
|
-
//
|
|
103
|
-
|
|
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 ??
|
|
491
|
+
maxTools: pluginConfig.skillRetrieverMaxTools ?? 6,
|
|
327
492
|
includeUninstalledOnly: true,
|
|
328
493
|
envFilePath: "~/.openclaw/.xiaoyienv",
|
|
329
|
-
timeoutMs: pluginConfig.skillRetrieverTimeoutMs ??
|
|
494
|
+
timeoutMs: pluginConfig.skillRetrieverTimeoutMs ?? 10000,
|
|
330
495
|
});
|
|
331
496
|
const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
|
|
332
497
|
api.on("before_prompt_build", async (event, ctx) => {
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
-
*
|
|
79
|
-
* 1.
|
|
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
|
-
*
|
|
359
|
-
* 1.
|
|
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
|
-
|
|
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:
|
|
382
|
-
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
|
-
|
|
468
|
+
// 清理整个任务链(无 expectedTaskId),避免 steer 引入的额外 taskId 残留。
|
|
469
|
+
completeTask(state.sessionId);
|
|
389
470
|
setSessionState(state.sessionId, "completed");
|
|
390
|
-
log.log(`[SUBAGENT-FINAL] Subagent final delivered
|
|
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
|
-
/**
|
|
3
|
-
export
|
|
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
|
|
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
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
50
|
+
export declare function gateCronSendText(swallowedText: string | undefined, announceLike: boolean, config?: XYChannelConfig): CronGateResult;
|