@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.
- package/dist/index.js +36 -7
- package/dist/src/bot.d.ts +6 -0
- package/dist/src/bot.js +261 -122
- package/dist/src/client.d.ts +1 -5
- package/dist/src/client.js +25 -39
- package/dist/src/cspl/call-api.d.ts +6 -0
- package/dist/src/cspl/call-api.js +33 -13
- package/dist/src/cspl/config.d.ts +11 -1
- package/dist/src/cspl/config.js +30 -0
- package/dist/src/cspl/middleware.d.ts +8 -0
- package/dist/src/cspl/middleware.js +90 -0
- package/dist/src/cspl/steer-context.d.ts +21 -0
- package/dist/src/cspl/steer-context.js +78 -0
- package/dist/src/file-download.js +3 -3
- package/dist/src/formatter.d.ts +0 -2
- package/dist/src/formatter.js +12 -14
- package/dist/src/heartbeat.js +3 -2
- package/dist/src/login-token-handler.js +13 -10
- package/dist/src/message-queue.js +2 -1
- package/dist/src/monitor.js +42 -46
- package/dist/src/outbound.js +3 -0
- package/dist/src/provider.js +15 -14
- package/dist/src/push.js +9 -9
- package/dist/src/reply-dispatcher.d.ts +5 -1
- package/dist/src/reply-dispatcher.js +67 -68
- package/dist/src/self-evolution-handler.js +11 -14
- package/dist/src/skill-retriever/hooks.js +0 -1
- package/dist/src/skill-retriever/tool-search.js +7 -12
- package/dist/src/task-manager.d.ts +4 -27
- package/dist/src/task-manager.js +13 -78
- package/dist/src/tools/calendar-tool.js +5 -1
- package/dist/src/tools/call-phone-tool.js +5 -1
- package/dist/src/tools/create-alarm-tool.js +5 -1
- package/dist/src/tools/delete-alarm-tool.js +5 -1
- package/dist/src/tools/location-tool.js +5 -1
- package/dist/src/tools/login-token-tool.js +13 -2
- package/dist/src/tools/modify-alarm-tool.js +5 -1
- package/dist/src/tools/modify-note-tool.js +5 -1
- package/dist/src/tools/note-tool.js +5 -1
- package/dist/src/tools/query-app-message-tool.js +5 -1
- package/dist/src/tools/query-memory-data-tool.js +5 -1
- package/dist/src/tools/query-todo-task-tool.js +5 -1
- package/dist/src/tools/save-file-to-phone-tool.js +5 -1
- package/dist/src/tools/save-media-to-gallery-tool.js +5 -1
- package/dist/src/tools/search-alarm-tool.js +5 -1
- package/dist/src/tools/search-calendar-tool.js +5 -1
- package/dist/src/tools/search-contact-tool.js +5 -1
- package/dist/src/tools/search-email-tool.js +5 -1
- package/dist/src/tools/search-file-tool.js +5 -1
- package/dist/src/tools/search-message-tool.js +5 -1
- package/dist/src/tools/search-note-tool.js +5 -1
- package/dist/src/tools/search-photo-gallery-tool.js +5 -1
- package/dist/src/tools/send-email-tool.js +5 -1
- package/dist/src/tools/send-file-to-user-tool.js +0 -1
- package/dist/src/tools/send-message-tool.js +5 -1
- package/dist/src/tools/session-manager.js +1 -13
- package/dist/src/tools/upload-file-tool.js +5 -1
- package/dist/src/tools/upload-photo-tool.js +5 -1
- package/dist/src/tools/xiaoyi-add-collection-tool.js +5 -1
- package/dist/src/tools/xiaoyi-collection-tool.js +5 -1
- package/dist/src/tools/xiaoyi-delete-collection-tool.js +5 -1
- package/dist/src/tools/xiaoyi-gui-tool.js +3 -1
- package/dist/src/trigger-handler.js +8 -9
- package/dist/src/utils/logger.js +106 -22
- package/dist/src/utils/throw.d.ts +5 -0
- package/dist/src/utils/throw.js +10 -0
- package/dist/src/websocket.js +4 -2
- package/openclaw.plugin.json +3 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
69
|
+
logger.log(`[SENTINEL HOOK] Security result: status=${result.status}, toolName=${event.toolName}, elapsed=${csplElapsed}ms`);
|
|
53
70
|
if (result.status === "REJECT") {
|
|
54
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
// 🔑
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
log(`[BOT] 🔄 STEER MODE - Second message detected (will
|
|
109
|
-
log(`[BOT] - Session: ${parsed.sessionId}`);
|
|
110
|
-
log(`[BOT] - New taskId: ${parsed.taskId}
|
|
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
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
// 🔑
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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] -
|
|
281
|
-
// 🔑
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
+
}
|