@ynhcj/xiaoyi-channel 0.0.132-beta → 0.0.132-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 (69) hide show
  1. package/dist/index.js +36 -7
  2. package/dist/src/bot.d.ts +6 -0
  3. package/dist/src/bot.js +261 -122
  4. package/dist/src/client.d.ts +1 -5
  5. package/dist/src/client.js +25 -39
  6. package/dist/src/cspl/call-api.d.ts +6 -0
  7. package/dist/src/cspl/call-api.js +33 -13
  8. package/dist/src/cspl/config.d.ts +11 -1
  9. package/dist/src/cspl/config.js +30 -0
  10. package/dist/src/cspl/middleware.d.ts +8 -0
  11. package/dist/src/cspl/middleware.js +90 -0
  12. package/dist/src/cspl/steer-context.d.ts +21 -0
  13. package/dist/src/cspl/steer-context.js +78 -0
  14. package/dist/src/file-download.js +3 -3
  15. package/dist/src/formatter.d.ts +0 -2
  16. package/dist/src/formatter.js +12 -14
  17. package/dist/src/heartbeat.js +3 -2
  18. package/dist/src/login-token-handler.js +13 -10
  19. package/dist/src/message-queue.js +2 -1
  20. package/dist/src/monitor.js +42 -46
  21. package/dist/src/outbound.js +3 -0
  22. package/dist/src/provider.js +15 -14
  23. package/dist/src/push.js +9 -9
  24. package/dist/src/reply-dispatcher.d.ts +5 -1
  25. package/dist/src/reply-dispatcher.js +67 -68
  26. package/dist/src/self-evolution-handler.js +11 -14
  27. package/dist/src/skill-retriever/hooks.js +0 -1
  28. package/dist/src/skill-retriever/tool-search.js +7 -12
  29. package/dist/src/task-manager.d.ts +4 -27
  30. package/dist/src/task-manager.js +13 -78
  31. package/dist/src/tools/calendar-tool.js +5 -1
  32. package/dist/src/tools/call-phone-tool.js +5 -1
  33. package/dist/src/tools/create-alarm-tool.js +5 -1
  34. package/dist/src/tools/delete-alarm-tool.js +5 -1
  35. package/dist/src/tools/location-tool.js +5 -1
  36. package/dist/src/tools/login-token-tool.js +13 -2
  37. package/dist/src/tools/modify-alarm-tool.js +5 -1
  38. package/dist/src/tools/modify-note-tool.js +5 -1
  39. package/dist/src/tools/note-tool.js +5 -1
  40. package/dist/src/tools/query-app-message-tool.js +5 -1
  41. package/dist/src/tools/query-memory-data-tool.js +5 -1
  42. package/dist/src/tools/query-todo-task-tool.js +5 -1
  43. package/dist/src/tools/save-file-to-phone-tool.js +5 -1
  44. package/dist/src/tools/save-media-to-gallery-tool.js +5 -1
  45. package/dist/src/tools/search-alarm-tool.js +5 -1
  46. package/dist/src/tools/search-calendar-tool.js +5 -1
  47. package/dist/src/tools/search-contact-tool.js +5 -1
  48. package/dist/src/tools/search-email-tool.js +5 -1
  49. package/dist/src/tools/search-file-tool.js +5 -1
  50. package/dist/src/tools/search-message-tool.js +5 -1
  51. package/dist/src/tools/search-note-tool.js +5 -1
  52. package/dist/src/tools/search-photo-gallery-tool.js +5 -1
  53. package/dist/src/tools/send-email-tool.js +5 -1
  54. package/dist/src/tools/send-file-to-user-tool.js +0 -1
  55. package/dist/src/tools/send-message-tool.js +5 -1
  56. package/dist/src/tools/session-manager.js +1 -13
  57. package/dist/src/tools/upload-file-tool.js +5 -1
  58. package/dist/src/tools/upload-photo-tool.js +5 -1
  59. package/dist/src/tools/xiaoyi-add-collection-tool.js +5 -1
  60. package/dist/src/tools/xiaoyi-collection-tool.js +5 -1
  61. package/dist/src/tools/xiaoyi-delete-collection-tool.js +5 -1
  62. package/dist/src/tools/xiaoyi-gui-tool.js +3 -1
  63. package/dist/src/trigger-handler.js +8 -9
  64. package/dist/src/utils/logger.js +106 -22
  65. package/dist/src/utils/throw.d.ts +5 -0
  66. package/dist/src/utils/throw.js +10 -0
  67. package/dist/src/websocket.js +4 -2
  68. package/openclaw.plugin.json +3 -0
  69. package/package.json +6 -5
package/dist/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { definePluginEntry } from "openclaw/plugin-sdk/core";
2
2
  import { xiaoyiProvider } from "./src/provider.js";
3
3
  import { xyPlugin } from "./src/channel.js";
4
- import { callCsplApi } from "./src/cspl/call-api.js";
4
+ import { callCsplApiWithConfig } from "./src/cspl/call-api.js";
5
+ import { getCsplConfig, initCsplConfigFromXYConfig } from "./src/cspl/config.js";
5
6
  import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./src/cspl/constants.js";
6
7
  import { extractResultText, parseSecurityResult, processText, validateAndTruncateText, } from "./src/cspl/utils.js";
8
+ import { tryInjectSteer } from "./src/cspl/steer-context.js";
9
+ import { getSessionContext } from "./src/tools/session-manager.js";
10
+ import { logger } from "./src/utils/logger.js";
7
11
  import { setXYRuntime } from "./src/runtime.js";
8
- import { tryInjectSteer } from "./src/steer-injector.js";
9
12
  import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
10
13
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
11
14
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
@@ -22,17 +25,25 @@ function registerFullHooks(api) {
22
25
  const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
23
26
  api.on("before_prompt_build", beforePromptBuildHandler);
24
27
  registerSelfEvolutionToolResultNudge(api);
28
+ }
29
+ function registerCsplHook(api) {
30
+ // CSPL security scanning via after_tool_call hook.
31
+ // When CSPL returns REJECT, injects a steer message via tryInjectSteer
32
+ // to interrupt the agent. Uses skipRegistration to avoid refCount leaks
33
+ // and taskId overwrites.
34
+ // Only registered in "full" mode because it depends on handleXYMessage
35
+ // having cached cfg/runtime via setCsplSteerContext.
25
36
  api.on("after_tool_call", async (event, ctx) => {
26
37
  if (!ALLOWED_TOOLS.includes(event.toolName)) {
27
38
  return;
28
39
  }
29
- console.log(`[SENTINEL HOOK] after_tool_call triggered: toolName=${event.toolName}, sessionKey=${ctx.sessionKey ?? "none"}`);
30
40
  try {
31
41
  const resultText = extractResultText(event, event.toolName);
32
42
  const resultLength = resultText.length;
33
43
  if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
34
44
  return;
35
45
  }
46
+ logger.log(`[SENTINEL HOOK] after_tool_call: toolName=${event.toolName}, textLength=${resultLength}`);
36
47
  const questionText = {
37
48
  subSceneID: "TOOL_OUTPUT",
38
49
  tool: event.toolName,
@@ -47,15 +58,32 @@ function registerFullHooks(api) {
47
58
  questionText.output[0].content = trimmed;
48
59
  finalJson = JSON.stringify(questionText);
49
60
  }
50
- const response = await callCsplApi(finalJson, api.config);
61
+ const sessionCtx = getSessionContext(ctx.sessionKey ?? "");
62
+ const csplConfig = sessionCtx
63
+ ? initCsplConfigFromXYConfig(sessionCtx.config)
64
+ : getCsplConfig();
65
+ const csplStartTime = Date.now();
66
+ const response = await callCsplApiWithConfig(finalJson, csplConfig);
67
+ const csplElapsed = Date.now() - csplStartTime;
51
68
  const result = parseSecurityResult(response);
52
- console.log(`[SENTINEL HOOK] Security result: status=${result.status}`);
69
+ logger.log(`[SENTINEL HOOK] Security result: status=${result.status}, toolName=${event.toolName}, elapsed=${csplElapsed}ms`);
53
70
  if (result.status === "REJECT") {
54
- await tryInjectSteer(ctx.sessionKey, STEER_ABORT_MESSAGE);
71
+ logger.log(`[SENTINEL HOOK] REJECT - injecting steer via tryInjectSteer`);
72
+ if (sessionCtx) {
73
+ await tryInjectSteer({
74
+ sessionId: sessionCtx.sessionId,
75
+ taskId: sessionCtx.taskId,
76
+ message: STEER_ABORT_MESSAGE,
77
+ source: "cspl",
78
+ });
79
+ }
80
+ else {
81
+ logger.error("[SENTINEL HOOK] No session context, cannot inject steer");
82
+ }
55
83
  }
56
84
  }
57
85
  catch (err) {
58
- api.logger.error(`[SENTINEL HOOK] after_tool_call error: ${err}`);
86
+ logger.error(`[SENTINEL HOOK] after_tool_call error: ${err}`);
59
87
  }
60
88
  });
61
89
  }
@@ -82,6 +110,7 @@ export default definePluginEntry({
82
110
  }
83
111
  if (api.registrationMode === "full") {
84
112
  registerFullHooks(api);
113
+ registerCsplHook(api);
85
114
  }
86
115
  },
87
116
  });
package/dist/src/bot.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface HandleXYMessageParams {
11
11
  webSocketSessionId?: string;
12
12
  /** Called after dispatch init is complete (agentTools/wrapStreamFn done). */
13
13
  onInitComplete?: () => void;
14
+ /**
15
+ * When true, skip taskId/session registration. Used by tryInjectSteer to
16
+ * inject a steer message without overwriting the active taskId or leaking
17
+ * session refCount.
18
+ */
19
+ skipRegistration?: boolean;
14
20
  }
15
21
  /**
16
22
  * Handle an incoming A2A message.
package/dist/src/bot.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { getXYRuntime } from "./runtime.js";
2
- import { setCachedContext } from "./steer-injector.js";
3
2
  import { createXYReplyDispatcher } from "./reply-dispatcher.js";
4
3
  import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractTriggerData } from "./parser.js";
5
4
  import { downloadFilesFromParts } from "./file-download.js";
@@ -13,7 +12,9 @@ import { getPushDataById } from "./utils/pushdata-manager.js";
13
12
  import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
14
13
  import { saveRuntimeInfo } from "./utils/runtime-manager.js";
15
14
  import { toolCallNudgeManager } from "./utils/tool-call-nudge-manager.js";
16
- import { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActiveTask, } from "./task-manager.js";
15
+ import { setCsplSteerContext } from "./cspl/steer-context.js";
16
+ import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
17
+ import { logger } from "./utils/logger.js";
17
18
  /**
18
19
  * Handle an incoming A2A message.
19
20
  * This is the main entry point for message processing.
@@ -21,10 +22,8 @@ import { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActive
21
22
  */
22
23
  export async function handleXYMessage(params) {
23
24
  const { cfg, runtime, message, accountId, webSocketSessionId } = params;
24
- const log = runtime?.log ?? console.log;
25
- const error = runtime?.error ?? console.error;
26
- // 每次收到消息时更新缓存,供 steer 注入使用
27
- setCachedContext(cfg, runtime, accountId);
25
+ // Cache context for CSPL steer injection (after_tool_call hook)
26
+ setCsplSteerContext(cfg, runtime);
28
27
  // Get runtime (already validated in monitor.ts, but get reference for use)
29
28
  const core = getXYRuntime();
30
29
  try {
@@ -36,7 +35,7 @@ export async function handleXYMessage(params) {
36
35
  if (!sessionId) {
37
36
  throw new Error("clearContext request missing sessionId in params");
38
37
  }
39
- log(`Clear context request for session ${sessionId}`);
38
+ logger.log(`Clear context request for session ${sessionId}`);
40
39
  const config = resolveXYConfig(cfg);
41
40
  await sendClearContextResponse({
42
41
  config,
@@ -52,7 +51,7 @@ export async function handleXYMessage(params) {
52
51
  if (!sessionId) {
53
52
  throw new Error("tasks/cancel request missing sessionId in params");
54
53
  }
55
- log(`Tasks cancel request for session ${sessionId}, task ${taskId}`);
54
+ logger.log(`Tasks cancel request for session ${sessionId}, task ${taskId}`);
56
55
  const config = resolveXYConfig(cfg);
57
56
  await sendTasksCancelResponse({
58
57
  config,
@@ -68,18 +67,18 @@ export async function handleXYMessage(params) {
68
67
  // 如果消息中包含 Trigger 事件数据,直接返回 pushData 内容,不走正常流程
69
68
  const triggerData = extractTriggerData(parsed.parts);
70
69
  if (triggerData) {
71
- log(`[BOT] 📌 Detected Trigger message with pushDataId: ${triggerData.pushDataId}`);
72
- log(`[BOT] - Session ID: ${parsed.sessionId}`);
73
- log(`[BOT] - Task ID: ${parsed.taskId}`);
70
+ logger.log(`[BOT] 📌 Detected Trigger message with pushDataId: ${triggerData.pushDataId}`);
71
+ logger.log(`[BOT] - Session ID: ${parsed.sessionId}`);
72
+ logger.log(`[BOT] - Task ID: ${parsed.taskId}`);
74
73
  try {
75
74
  // 读取 pushData
76
75
  const pushDataItem = await getPushDataById(triggerData.pushDataId);
77
76
  if (!pushDataItem) {
78
- error(`[BOT] ❌ pushData not found for ID: ${triggerData.pushDataId}`);
77
+ logger.error(`[BOT] ❌ pushData not found for ID: ${triggerData.pushDataId}`);
79
78
  return;
80
79
  }
81
- log(`[BOT] ✅ Found pushData, sending direct response`);
82
- log(`[BOT] - pushDataId: ${pushDataItem.pushDataId}`);
80
+ logger.log(`[BOT] ✅ Found pushData, sending direct response`);
81
+ logger.log(`[BOT] - pushDataId: ${pushDataItem.pushDataId}`);
83
82
  const config = resolveXYConfig(cfg);
84
83
  // 直接发送响应(final=true,不走 openclaw 流程)
85
84
  await sendA2AResponse({
@@ -90,58 +89,53 @@ export async function handleXYMessage(params) {
90
89
  text: pushDataItem.dataDetail,
91
90
  append: false,
92
91
  final: true,
93
- runtime,
94
92
  });
95
- log(`[BOT] ✅ Trigger response sent successfully, exiting early`);
93
+ logger.log(`[BOT] ✅ Trigger response sent successfully, exiting early`);
96
94
  return; // 提前返回,不继续处理
97
95
  }
98
96
  catch (err) {
99
- error(`[BOT] ❌ Failed to handle Trigger message:`, err);
97
+ logger.error(`[BOT] ❌ Failed to handle Trigger message:`, err);
100
98
  return;
101
99
  }
102
100
  }
103
101
  // ========================================
104
- // 🔑 检测steer模式和是否是第二条消息
105
- const isSteerMode = cfg.messages?.queue?.mode === "steer";
106
- const isSecondMessage = isSteerMode && hasActiveTask(parsed.sessionId);
107
- if (isSecondMessage) {
108
- log(`[BOT] 🔄 STEER MODE - Second message detected (will be follower)`);
109
- log(`[BOT] - Session: ${parsed.sessionId}`);
110
- log(`[BOT] - New taskId: ${parsed.taskId} (will replace current)`);
102
+ // 🔑 注册taskId(检测是否是已有活跃任务的 session)
103
+ const isUpdate = hasActiveTask(parsed.sessionId);
104
+ const skipReg = params.skipRegistration === true;
105
+ if (isUpdate) {
106
+ logger.log(`[BOT] 🔄 STEER MODE - Second message detected (core will handle steer)`);
107
+ logger.log(`[BOT] - Session: ${parsed.sessionId}`);
108
+ logger.log(`[BOT] - New taskId: ${parsed.taskId}`);
111
109
  }
112
- // 🔑 注册taskId(第二条消息会覆盖第一条的taskId
113
- const { isUpdate, refCount } = registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId, { incrementRef: true } // 增加引用计数
114
- );
115
- // 🔑 如果是第一条消息,锁定taskId防止被过早清理
116
- if (!isUpdate) {
117
- lockTaskId(parsed.sessionId);
118
- log(`[BOT] 🔒 Locked taskId for first message`);
119
- }
120
- // Extract and update push_id if present
121
- const pushId = extractPushId(parsed.parts);
122
- if (pushId) {
123
- log(`[BOT] 📌 Extracted push_id from user message`);
124
- configManager.updatePushId(parsed.sessionId, pushId);
125
- // 持久化 pushId 到本地文件(异步,不阻塞主流程)
126
- addPushId(pushId).catch((err) => {
127
- error(`[BOT] Failed to persist pushId:`, err);
110
+ // Steer injections skip taskId registration to avoid overwriting the active taskId
111
+ if (!skipReg) {
112
+ registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId);
113
+ // Extract and update push_id if present
114
+ const pushId = extractPushId(parsed.parts);
115
+ if (pushId) {
116
+ logger.log(`[BOT] 📌 Extracted push_id from user message`);
117
+ configManager.updatePushId(parsed.sessionId, pushId);
118
+ // 持久化 pushId 到本地文件(异步,不阻塞主流程)
119
+ addPushId(pushId).catch((err) => {
120
+ logger.error(`[BOT] Failed to persist pushId:`, err);
121
+ });
122
+ }
123
+ else {
124
+ logger.log(`[BOT] ℹ️ No push_id found in message, will use config default`);
125
+ }
126
+ // 保存 runtime 信息到 .xiaoyiruntime 文件(异步,不阻塞主流程)
127
+ saveRuntimeInfo(webSocketSessionId || parsed.sessionId, // SESSION_ID (WebSocket 层级,如果没有则 fallback)
128
+ parsed.sessionId, // CONVERSATION_ID (param 里的 sessionId)
129
+ parsed.taskId // TASK_ID (param.id)
130
+ ).catch((err) => {
131
+ logger.error(`[BOT] Failed to save runtime info:`, err);
128
132
  });
129
133
  }
130
- else {
131
- log(`[BOT] ℹ️ No push_id found in message, will use config default`);
132
- }
133
- // Extract deviceType if present (same level as push_id in systemVariables)
134
+ // Extract deviceType if present (always parse — used in ctxPayload.MessageSid)
134
135
  const deviceType = extractDeviceType(parsed.parts);
135
136
  if (deviceType) {
136
- log(`[BOT] 📱 Extracted deviceType from user message: ${deviceType}`);
137
+ logger.log(`[BOT] 📱 Extracted deviceType from user message: ${deviceType}`);
137
138
  }
138
- // 保存 runtime 信息到 .xiaoyiruntime 文件(异步,不阻塞主流程)
139
- saveRuntimeInfo(webSocketSessionId || parsed.sessionId, // SESSION_ID (WebSocket 层级,如果没有则 fallback)
140
- parsed.sessionId, // CONVERSATION_ID (param 里的 sessionId)
141
- parsed.taskId // TASK_ID (param.id)
142
- ).catch((err) => {
143
- error(`[BOT] Failed to save runtime info:`, err);
144
- });
145
139
  // Resolve configuration (needed for status updates)
146
140
  const config = resolveXYConfig(cfg);
147
141
  // ✅ Resolve agent route (following feishu pattern)
@@ -156,55 +150,66 @@ export async function handleXYMessage(params) {
156
150
  id: parsed.sessionId, // ✅ Use sessionId to share context within the same conversation session
157
151
  },
158
152
  });
159
- log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
160
- registerSession(route.sessionKey, {
161
- config,
162
- sessionId: parsed.sessionId,
163
- taskId: parsed.taskId,
164
- messageId: parsed.messageId,
165
- agentId: route.accountId,
166
- deviceType,
167
- });
168
- // 🔑 发送初始状态更新(第二条消息也要发,用新taskId)
169
- log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
170
- void sendStatusUpdate({
171
- config,
172
- sessionId: parsed.sessionId,
173
- taskId: parsed.taskId,
174
- messageId: parsed.messageId,
175
- text: isSecondMessage ? "新消息已接收,正在处理..." : "任务正在处理中,请稍候~",
176
- state: "working",
177
- runtime,
178
- }).catch((err) => {
179
- error(`Failed to send initial status update:`, err);
180
- });
153
+ logger.log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
154
+ // Steer injections skip session registration to avoid refCount leaks
155
+ if (!skipReg) {
156
+ registerSession(route.sessionKey, {
157
+ config,
158
+ sessionId: parsed.sessionId,
159
+ taskId: parsed.taskId,
160
+ messageId: parsed.messageId,
161
+ agentId: route.accountId,
162
+ deviceType,
163
+ });
164
+ // 🔑 发送初始状态更新
165
+ logger.log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
166
+ void sendStatusUpdate({
167
+ config,
168
+ sessionId: parsed.sessionId,
169
+ taskId: parsed.taskId,
170
+ messageId: parsed.messageId,
171
+ text: "任务正在处理中,请稍候~",
172
+ state: "working",
173
+ }).catch((err) => {
174
+ logger.error(`Failed to send initial status update:`, err);
175
+ });
176
+ }
181
177
  // Extract text and files from parts
182
178
  const text = extractTextFromParts(parsed.parts);
183
179
  let textForAgent = text || "";
184
- if (route.sessionKey && textForAgent) {
180
+ // Self-evolution keyword nudge — only for real user messages, not steer injections
181
+ if (!skipReg && route.sessionKey && textForAgent) {
185
182
  try {
186
183
  const selfEvolutionEnabled = await selfEvolutionManager.isEnabled();
187
184
  if (selfEvolutionEnabled && shouldNudgeForSelfEvolutionKeyword(textForAgent)) {
188
185
  const shouldNudge = toolCallNudgeManager.tryMarkKeywordNudge(route.sessionKey);
189
- log(`[SELF_EVOLUTION] Keyword check hit during inbound build: sessionKey=${route.sessionKey}, shouldNudge=${shouldNudge}`);
186
+ logger.log(`[SELF_EVOLUTION] Keyword check hit during inbound build: sessionKey=${route.sessionKey}, shouldNudge=${shouldNudge}`);
190
187
  if (shouldNudge) {
191
188
  const augmented = appendSelfEvolutionKeywordNudge(textForAgent);
192
189
  textForAgent = augmented.text;
193
190
  if (augmented.appended) {
194
- log(`[SELF_EVOLUTION] Keyword-triggered inline nudge appended: sessionKey=${route.sessionKey}`);
191
+ logger.log(`[SELF_EVOLUTION] Keyword-triggered inline nudge appended: sessionKey=${route.sessionKey}`);
195
192
  }
196
193
  }
197
194
  }
198
195
  }
199
196
  catch (selfEvolutionError) {
200
- error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
197
+ logger.error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
201
198
  }
202
199
  }
203
- const fileParts = extractFileParts(parsed.parts);
204
- // Download files to local disk
205
- const downloadedFiles = await downloadFilesFromParts(fileParts);
206
- log("Downloaded files:", JSON.stringify(downloadedFiles, null, 2));
207
- const mediaPayload = buildXYMediaPayload(downloadedFiles);
200
+ // 🔑 Steer消息加 /steer 前缀,触发core的 queueEmbeddedPiMessage
201
+ if (isUpdate && textForAgent) {
202
+ textForAgent = `/steer ${textForAgent}`;
203
+ logger.log(`[BOT] 🔄 Prepended /steer for steer injection`);
204
+ }
205
+ // File download — only for real user messages, steer injections have no files
206
+ let mediaPayload = {};
207
+ if (!skipReg) {
208
+ const fileParts = extractFileParts(parsed.parts);
209
+ const downloadedFiles = await downloadFilesFromParts(fileParts);
210
+ logger.log("Downloaded files:", JSON.stringify(downloadedFiles, null, 2));
211
+ mediaPayload = buildXYMediaPayload(downloadedFiles);
212
+ }
208
213
  // Resolve envelope format options (following feishu pattern)
209
214
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
210
215
  // Build message body with speaker prefix (following feishu pattern)
@@ -245,9 +250,16 @@ export async function handleXYMessage(params) {
245
250
  ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
246
251
  ...mediaPayload,
247
252
  });
248
- // 🔑 创建dispatcher(dispatcher会自动使用动态taskId)
249
- log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
250
- log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
253
+ // 🔑 Dynamic steer state: when isUpdate (second message), start as steered=true
254
+ // so the dispatcher skips all user-facing callbacks (deliver, onIdle, etc.)
255
+ // and onSettled skips cleanup.
256
+ const steerState = { steered: isUpdate };
257
+ // 🔑 第一条消息的 streaming 信号:deliver 首次触发时 resolve
258
+ // steer 消息通过串行队列等待此信号后再 dispatch
259
+ const streamingSignal = !isUpdate ? createStreamingSignal(parsed.sessionId) : undefined;
260
+ // 🔑 创建dispatcher
261
+ logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
262
+ logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
251
263
  const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = createXYReplyDispatcher({
252
264
  cfg,
253
265
  runtime,
@@ -255,13 +267,12 @@ export async function handleXYMessage(params) {
255
267
  taskId: parsed.taskId,
256
268
  messageId: parsed.messageId,
257
269
  accountId: route.accountId,
258
- isSteerFollower: isSecondMessage, // 🔑 标记第二条消息
270
+ steerState,
271
+ onFirstStream: streamingSignal?.notify,
259
272
  });
260
- // 🔑 只有第一条消息启动状态定时器
261
- // 第二条消息会很快返回,不需要定时器
262
- if (!isSecondMessage) {
273
+ // Steer injections don't need status intervals
274
+ if (!skipReg) {
263
275
  startStatusInterval();
264
- log(`[BOT-DISPATCHER] ✅ Status interval started for first message`);
265
276
  }
266
277
  // Build session context for AsyncLocalStorage
267
278
  const sessionContext = {
@@ -272,22 +283,20 @@ export async function handleXYMessage(params) {
272
283
  agentId: route.accountId,
273
284
  deviceType,
274
285
  };
275
- log(`[BOT-DISPATCH] ⏳ withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
286
+ logger.log(`[BOT-DISPATCH] ⏳ withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
276
287
  await core.channel.reply.withReplyDispatcher({
277
288
  dispatcher,
278
289
  onSettled: () => {
279
- log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
280
- log(`[BOT] - isSecondMessage: ${isSecondMessage}`);
281
- // 🔑 减少引用计数
282
- decrementTaskIdRef(parsed.sessionId);
283
- // 🔑 如果是第一条消息完成,解锁
284
- if (!isSecondMessage) {
285
- unlockTaskId(parsed.sessionId);
286
- log(`[BOT] 🔓 Unlocked taskId (first message completed)`);
290
+ logger.log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
291
+ logger.log(`[BOT] - steered: ${steerState.steered}`);
292
+ // 🔑 When steered, skip heavy cleanup — the first message's dispatcher is still running
293
+ if (steerState.steered) {
294
+ logger.log(`[BOT] Steered dispatch settled (skipping cleanup)`);
295
+ return;
287
296
  }
288
- // 减少session引用计数
297
+ decrementTaskIdRef(parsed.sessionId);
289
298
  unregisterSession(route.sessionKey);
290
- log(`[BOT] ✅ Cleanup completed`);
299
+ logger.log(`[BOT] ✅ Cleanup completed`);
291
300
  },
292
301
  run: () => {
293
302
  // 🔐 Use AsyncLocalStorage to provide session context to tools.
@@ -296,12 +305,12 @@ export async function handleXYMessage(params) {
296
305
  // signal init complete to release the global dispatch gate
297
306
  // for the next session.
298
307
  const dispatchPromise = runWithSessionContext(sessionContext, async () => {
299
- log(`[BOT-DISPATCH] ⏳ dispatchReplyFromConfig starting...`);
300
- log(`[BOT-DISPATCH] - sessionKey: ${ctxPayload.SessionKey}`);
301
- log(`[BOT-DISPATCH] - provider: ${ctxPayload.Provider}`);
302
- log(`[BOT-DISPATCH] - surface: ${ctxPayload.Surface}`);
303
- log(`[BOT-DISPATCH] - from: ${ctxPayload.From}`);
304
- log(`[BOT-DISPATCH] - body length: ${ctxPayload.Body?.length ?? 0}`);
308
+ logger.log(`[BOT-DISPATCH] ⏳ dispatchReplyFromConfig starting...`);
309
+ logger.log(`[BOT-DISPATCH] - sessionKey: ${ctxPayload.SessionKey}`);
310
+ logger.log(`[BOT-DISPATCH] - provider: ${ctxPayload.Provider}`);
311
+ logger.log(`[BOT-DISPATCH] - surface: ${ctxPayload.Surface}`);
312
+ logger.log(`[BOT-DISPATCH] - from: ${ctxPayload.From}`);
313
+ logger.log(`[BOT-DISPATCH] - body length: ${ctxPayload.Body?.length ?? 0}`);
305
314
  try {
306
315
  const result = await core.channel.reply.dispatchReplyFromConfig({
307
316
  ctx: ctxPayload,
@@ -309,15 +318,15 @@ export async function handleXYMessage(params) {
309
318
  dispatcher,
310
319
  replyOptions,
311
320
  });
312
- log(`[BOT-DISPATCH] ✅ dispatchReplyFromConfig returned`);
313
- log(`[BOT-DISPATCH] - result: ${JSON.stringify(result)}`);
321
+ logger.log(`[BOT-DISPATCH] ✅ dispatchReplyFromConfig returned`);
322
+ logger.log(`[BOT-DISPATCH] - result: ${JSON.stringify(result)}`);
314
323
  return result;
315
324
  }
316
325
  catch (dispatchErr) {
317
- error(`[BOT-DISPATCH] ❌ dispatchReplyFromConfig threw`);
318
- error(`[BOT-DISPATCH] - error name: ${dispatchErr instanceof Error ? dispatchErr.name : "unknown"}`);
319
- error(`[BOT-DISPATCH] - error message: ${String(dispatchErr)}`);
320
- error(`[BOT-DISPATCH] - error stack: ${dispatchErr instanceof Error ? dispatchErr.stack?.slice(0, 500) : "N/A"}`);
326
+ logger.error(`[BOT-DISPATCH] ❌ dispatchReplyFromConfig threw`);
327
+ logger.error(`[BOT-DISPATCH] - error name: ${dispatchErr instanceof Error ? dispatchErr.name : "unknown"}`);
328
+ logger.error(`[BOT-DISPATCH] - error message: ${String(dispatchErr)}`);
329
+ logger.error(`[BOT-DISPATCH] - error stack: ${dispatchErr instanceof Error ? dispatchErr.stack?.slice(0, 500) : "N/A"}`);
321
330
  throw dispatchErr;
322
331
  }
323
332
  });
@@ -326,23 +335,35 @@ export async function handleXYMessage(params) {
326
335
  return dispatchPromise;
327
336
  },
328
337
  });
329
- log(`[BOT] Dispatcher completed for session: ${parsed.sessionId}`);
330
- log(`xy: dispatch complete (session=${parsed.sessionId})`);
338
+ // 🔑 Steer 串行队列:等待 streaming 信号后 dispatch,多个 steer 按顺序处理
339
+ if (isUpdate) {
340
+ await enqueueSteer({
341
+ sessionId: parsed.sessionId,
342
+ sessionKey: route.sessionKey,
343
+ steerText: textForAgent,
344
+ cfg,
345
+ runtime,
346
+ parsed,
347
+ route,
348
+ deviceType,
349
+ });
350
+ }
351
+ logger.log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
352
+ logger.log(`xy: dispatch complete (session=${parsed.sessionId})`);
331
353
  }
332
354
  catch (err) {
333
355
  // ✅ Only log error, don't re-throw to prevent gateway restart
334
- error("Failed to handle XY message:", err);
356
+ logger.error("Failed to handle XY message:", err);
335
357
  runtime.error?.(`xy: Failed to handle message: ${String(err)}`);
336
- log(`[BOT] ❌ Error occurred, attempting cleanup...`);
358
+ logger.log(`[BOT] ❌ Error occurred, attempting cleanup...`);
337
359
  // 🔑 错误时也要清理taskId和session
338
360
  try {
339
361
  const params = message.params;
340
362
  const sessionId = params?.sessionId;
341
363
  if (sessionId) {
342
- log(`[BOT] 🧹 Cleaning up after error: ${sessionId}`);
364
+ logger.log(`[BOT] 🧹 Cleaning up after error: ${sessionId}`);
343
365
  // 清理 taskId
344
366
  decrementTaskIdRef(sessionId);
345
- unlockTaskId(sessionId);
346
367
  // 清理 session
347
368
  const core = getXYRuntime();
348
369
  const route = core.channel.routing.resolveAgentRoute({
@@ -355,11 +376,11 @@ export async function handleXYMessage(params) {
355
376
  },
356
377
  });
357
378
  unregisterSession(route.sessionKey);
358
- log(`[BOT] ✅ Cleanup completed after error`);
379
+ logger.log(`[BOT] ✅ Cleanup completed after error`);
359
380
  }
360
381
  }
361
382
  catch (cleanupErr) {
362
- log(`[BOT] ⚠️ Cleanup failed:`, cleanupErr);
383
+ logger.log(`[BOT] ⚠️ Cleanup failed:`, cleanupErr);
363
384
  // Ignore cleanup errors
364
385
  }
365
386
  // ❌ Don't re-throw: message processing error should not affect gateway stability
@@ -382,3 +403,121 @@ function buildXYMediaPayload(mediaList) {
382
403
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
383
404
  };
384
405
  }
406
+ const streamingSignals = new Map();
407
+ function createStreamingSignal(sessionId) {
408
+ let resolve;
409
+ const promise = new Promise(r => { resolve = r; });
410
+ const signal = { promise, notify: resolve };
411
+ streamingSignals.set(sessionId, signal);
412
+ logger.log(`[STEER-QUEUE] 🟢 Streaming signal created for session ${sessionId}`);
413
+ return signal;
414
+ }
415
+ /** Per-session 串行队列:保证同一 session 的 steer 消息按顺序处理 */
416
+ const steerQueues = new Map();
417
+ /**
418
+ * 将 steer 消息放入 per-session 串行队列。
419
+ * 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
420
+ * 多个 steer 按到达顺序串行处理,无需重试。
421
+ */
422
+ function enqueueSteer(params) {
423
+ const { sessionId } = params;
424
+ // 取出当前队列尾部(或 undefined),然后链上新的 Promise
425
+ const prev = steerQueues.get(sessionId);
426
+ const next = (prev ?? Promise.resolve()).then(() => dispatchSteerWhenReady(params));
427
+ steerQueues.set(sessionId, next);
428
+ // 链条结束后清理
429
+ next.catch(() => { }).finally(() => {
430
+ if (steerQueues.get(sessionId) === next) {
431
+ steerQueues.delete(sessionId);
432
+ }
433
+ });
434
+ return next;
435
+ }
436
+ async function dispatchSteerWhenReady(params) {
437
+ const { sessionId, sessionKey, steerText } = params;
438
+ // 1. 等待第一条消息开始 streaming
439
+ const signal = streamingSignals.get(sessionId);
440
+ if (signal) {
441
+ logger.log(`[STEER-QUEUE] ⏳ Waiting for streaming signal, session=${sessionId}`);
442
+ await signal.promise;
443
+ streamingSignals.delete(sessionId);
444
+ logger.log(`[STEER-QUEUE] ✅ Streaming signal received, session=${sessionId}`);
445
+ }
446
+ // 2. 第一条消息已结束 → 放弃
447
+ if (!hasActiveTask(sessionId)) {
448
+ logger.log(`[STEER-QUEUE] ℹ️ First message completed, skip steer`);
449
+ return;
450
+ }
451
+ // 3. 构建 dispatch 上下文并 dispatch /steer
452
+ const core = getXYRuntime();
453
+ const speaker = sessionId;
454
+ const messageBody = `${speaker}: ${steerText}`;
455
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
456
+ const body = core.channel.reply.formatAgentEnvelope({
457
+ channel: "xiaoyi-channel",
458
+ from: speaker,
459
+ timestamp: new Date(),
460
+ envelope: envelopeOptions,
461
+ body: messageBody,
462
+ });
463
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
464
+ Body: body,
465
+ RawBody: steerText,
466
+ CommandBody: steerText,
467
+ From: sessionId,
468
+ To: sessionId,
469
+ SessionKey: params.route.sessionKey,
470
+ AccountId: params.route.accountId,
471
+ ChatType: "direct",
472
+ GroupSubject: undefined,
473
+ SenderName: sessionId,
474
+ SenderId: sessionId,
475
+ Provider: "xiaoyi-channel",
476
+ Surface: "xiaoyi-channel",
477
+ MessageSid: `${params.parsed.taskId}_${params.deviceType}`,
478
+ Timestamp: Date.now(),
479
+ WasMentioned: false,
480
+ CommandAuthorized: true,
481
+ OriginatingChannel: "xiaoyi-channel",
482
+ OriginatingTo: sessionId,
483
+ ReplyToBody: undefined,
484
+ });
485
+ const steerState = { steered: true };
486
+ const { dispatcher, replyOptions } = createXYReplyDispatcher({
487
+ cfg: params.cfg,
488
+ runtime: params.runtime,
489
+ sessionId,
490
+ taskId: params.parsed.taskId,
491
+ messageId: params.parsed.messageId,
492
+ accountId: params.route.accountId,
493
+ steerState,
494
+ });
495
+ const sessionContext = {
496
+ config: resolveXYConfig(params.cfg),
497
+ sessionId,
498
+ taskId: params.parsed.taskId,
499
+ messageId: params.parsed.messageId,
500
+ agentId: params.route.accountId,
501
+ deviceType: params.deviceType,
502
+ };
503
+ logger.log(`[STEER-QUEUE] 🚀 Dispatching steer for session=${sessionId}`);
504
+ await core.channel.reply.withReplyDispatcher({
505
+ dispatcher,
506
+ onSettled: () => {
507
+ logger.log(`[STEER-QUEUE] 🏁 Steer dispatch settled for session=${sessionId}`);
508
+ },
509
+ run: () => {
510
+ return runWithSessionContext(sessionContext, async () => {
511
+ const result = await core.channel.reply.dispatchReplyFromConfig({
512
+ ctx: ctxPayload,
513
+ cfg: params.cfg,
514
+ dispatcher,
515
+ replyOptions,
516
+ });
517
+ logger.log(`[STEER-QUEUE] dispatch result: ${JSON.stringify(result)}`);
518
+ return result;
519
+ });
520
+ },
521
+ });
522
+ logger.log(`[STEER-QUEUE] ✅ Steer dispatch completed for session=${sessionId}`);
523
+ }