@ynhcj/xiaoyi-channel 0.0.133-beta → 0.0.133-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 +35 -7
- package/dist/src/bot.d.ts +11 -0
- package/dist/src/bot.js +232 -78
- 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/login-token-handler.js +8 -4
- package/dist/src/provider.js +6 -0
- package/dist/src/reply-dispatcher.d.ts +3 -1
- package/dist/src/reply-dispatcher.js +25 -22
- 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-message-tool.js +5 -1
- package/dist/src/tools/session-manager.js +1 -1
- 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/openclaw.plugin.json +3 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +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";
|
|
7
|
-
import {
|
|
8
|
+
import { tryInjectSteer } from "./src/cspl/steer-context.js";
|
|
9
|
+
import { getSessionContext } from "./src/tools/session-manager.js";
|
|
8
10
|
import { logger } from "./src/utils/logger.js";
|
|
9
|
-
import {
|
|
11
|
+
import { setXYRuntime } from "./src/runtime.js";
|
|
10
12
|
import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
|
|
11
13
|
import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
|
|
12
14
|
import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
|
|
@@ -23,17 +25,25 @@ function registerFullHooks(api) {
|
|
|
23
25
|
const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
|
|
24
26
|
api.on("before_prompt_build", beforePromptBuildHandler);
|
|
25
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.
|
|
26
36
|
api.on("after_tool_call", async (event, ctx) => {
|
|
27
37
|
if (!ALLOWED_TOOLS.includes(event.toolName)) {
|
|
28
38
|
return;
|
|
29
39
|
}
|
|
30
|
-
logger.log(`[SENTINEL HOOK] after_tool_call triggered: toolName=${event.toolName}, sessionKey=${ctx.sessionKey ?? "none"}`);
|
|
31
40
|
try {
|
|
32
41
|
const resultText = extractResultText(event, event.toolName);
|
|
33
42
|
const resultLength = resultText.length;
|
|
34
43
|
if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
|
|
35
44
|
return;
|
|
36
45
|
}
|
|
46
|
+
logger.log(`[SENTINEL HOOK] after_tool_call: toolName=${event.toolName}, textLength=${resultLength}`);
|
|
37
47
|
const questionText = {
|
|
38
48
|
subSceneID: "TOOL_OUTPUT",
|
|
39
49
|
tool: event.toolName,
|
|
@@ -48,11 +58,28 @@ function registerFullHooks(api) {
|
|
|
48
58
|
questionText.output[0].content = trimmed;
|
|
49
59
|
finalJson = JSON.stringify(questionText);
|
|
50
60
|
}
|
|
51
|
-
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;
|
|
52
68
|
const result = parseSecurityResult(response);
|
|
53
|
-
logger.log(`[SENTINEL HOOK] Security result: status=${result.status}`);
|
|
69
|
+
logger.log(`[SENTINEL HOOK] Security result: status=${result.status}, toolName=${event.toolName}, elapsed=${csplElapsed}ms`);
|
|
54
70
|
if (result.status === "REJECT") {
|
|
55
|
-
|
|
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
|
+
}
|
|
56
83
|
}
|
|
57
84
|
}
|
|
58
85
|
catch (err) {
|
|
@@ -83,6 +110,7 @@ export default definePluginEntry({
|
|
|
83
110
|
}
|
|
84
111
|
if (api.registrationMode === "full") {
|
|
85
112
|
registerFullHooks(api);
|
|
113
|
+
registerCsplHook(api);
|
|
86
114
|
}
|
|
87
115
|
},
|
|
88
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.
|
|
@@ -18,3 +24,8 @@ export interface HandleXYMessageParams {
|
|
|
18
24
|
* Runtime is expected to be validated before calling this function.
|
|
19
25
|
*/
|
|
20
26
|
export declare function handleXYMessage(params: HandleXYMessageParams): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* 由 provider.ts 在 wrapStreamFn 调用时触发。
|
|
29
|
+
* 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
|
|
30
|
+
*/
|
|
31
|
+
export declare function notifyModelStreaming(sessionId: string): void;
|
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,8 @@ 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
17
|
import { logger } from "./utils/logger.js";
|
|
18
18
|
/**
|
|
19
19
|
* Handle an incoming A2A message.
|
|
@@ -22,8 +22,8 @@ import { logger } from "./utils/logger.js";
|
|
|
22
22
|
*/
|
|
23
23
|
export async function handleXYMessage(params) {
|
|
24
24
|
const { cfg, runtime, message, accountId, webSocketSessionId } = params;
|
|
25
|
-
//
|
|
26
|
-
|
|
25
|
+
// Cache context for CSPL steer injection (after_tool_call hook)
|
|
26
|
+
setCsplSteerContext(cfg, runtime);
|
|
27
27
|
// Get runtime (already validated in monitor.ts, but get reference for use)
|
|
28
28
|
const core = getXYRuntime();
|
|
29
29
|
try {
|
|
@@ -99,47 +99,43 @@ export async function handleXYMessage(params) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
// ========================================
|
|
102
|
-
// 🔑
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
logger.log(`[BOT] 🔄 STEER MODE - Second message detected (will
|
|
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
107
|
logger.log(`[BOT] - Session: ${parsed.sessionId}`);
|
|
108
|
-
logger.log(`[BOT] - New taskId: ${parsed.taskId}
|
|
108
|
+
logger.log(`[BOT] - New taskId: ${parsed.taskId}`);
|
|
109
109
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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);
|
|
126
132
|
});
|
|
127
133
|
}
|
|
128
|
-
|
|
129
|
-
logger.log(`[BOT] ℹ️ No push_id found in message, will use config default`);
|
|
130
|
-
}
|
|
131
|
-
// Extract deviceType if present (same level as push_id in systemVariables)
|
|
134
|
+
// Extract deviceType if present (always parse — used in ctxPayload.MessageSid)
|
|
132
135
|
const deviceType = extractDeviceType(parsed.parts);
|
|
133
136
|
if (deviceType) {
|
|
134
137
|
logger.log(`[BOT] 📱 Extracted deviceType from user message: ${deviceType}`);
|
|
135
138
|
}
|
|
136
|
-
// 保存 runtime 信息到 .xiaoyiruntime 文件(异步,不阻塞主流程)
|
|
137
|
-
saveRuntimeInfo(webSocketSessionId || parsed.sessionId, // SESSION_ID (WebSocket 层级,如果没有则 fallback)
|
|
138
|
-
parsed.sessionId, // CONVERSATION_ID (param 里的 sessionId)
|
|
139
|
-
parsed.taskId // TASK_ID (param.id)
|
|
140
|
-
).catch((err) => {
|
|
141
|
-
logger.error(`[BOT] Failed to save runtime info:`, err);
|
|
142
|
-
});
|
|
143
139
|
// Resolve configuration (needed for status updates)
|
|
144
140
|
const config = resolveXYConfig(cfg);
|
|
145
141
|
// ✅ Resolve agent route (following feishu pattern)
|
|
@@ -155,30 +151,34 @@ export async function handleXYMessage(params) {
|
|
|
155
151
|
},
|
|
156
152
|
});
|
|
157
153
|
logger.log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
|
178
177
|
// Extract text and files from parts
|
|
179
178
|
const text = extractTextFromParts(parsed.parts);
|
|
180
179
|
let textForAgent = text || "";
|
|
181
|
-
|
|
180
|
+
// Self-evolution keyword nudge — only for real user messages, not steer injections
|
|
181
|
+
if (!skipReg && route.sessionKey && textForAgent) {
|
|
182
182
|
try {
|
|
183
183
|
const selfEvolutionEnabled = await selfEvolutionManager.isEnabled();
|
|
184
184
|
if (selfEvolutionEnabled && shouldNudgeForSelfEvolutionKeyword(textForAgent)) {
|
|
@@ -197,11 +197,19 @@ export async function handleXYMessage(params) {
|
|
|
197
197
|
logger.error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
}
|
|
205
213
|
// Resolve envelope format options (following feishu pattern)
|
|
206
214
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
207
215
|
// Build message body with speaker prefix (following feishu pattern)
|
|
@@ -242,7 +250,15 @@ export async function handleXYMessage(params) {
|
|
|
242
250
|
ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
|
|
243
251
|
...mediaPayload,
|
|
244
252
|
});
|
|
245
|
-
// 🔑
|
|
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 信号(provider.ts 的 wrapStreamFn 触发)
|
|
258
|
+
if (!isUpdate) {
|
|
259
|
+
createStreamingSignal(parsed.sessionId);
|
|
260
|
+
}
|
|
261
|
+
// 🔑 创建dispatcher
|
|
246
262
|
logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
|
|
247
263
|
logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
|
|
248
264
|
const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = createXYReplyDispatcher({
|
|
@@ -252,13 +268,11 @@ export async function handleXYMessage(params) {
|
|
|
252
268
|
taskId: parsed.taskId,
|
|
253
269
|
messageId: parsed.messageId,
|
|
254
270
|
accountId: route.accountId,
|
|
255
|
-
|
|
271
|
+
steerState,
|
|
256
272
|
});
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
if (!isSecondMessage) {
|
|
273
|
+
// Steer injections don't need status intervals
|
|
274
|
+
if (!skipReg) {
|
|
260
275
|
startStatusInterval();
|
|
261
|
-
logger.log(`[BOT-DISPATCHER] ✅ Status interval started for first message`);
|
|
262
276
|
}
|
|
263
277
|
// Build session context for AsyncLocalStorage
|
|
264
278
|
const sessionContext = {
|
|
@@ -274,15 +288,13 @@ export async function handleXYMessage(params) {
|
|
|
274
288
|
dispatcher,
|
|
275
289
|
onSettled: () => {
|
|
276
290
|
logger.log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
|
|
277
|
-
logger.log(`[BOT] -
|
|
278
|
-
// 🔑
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
unlockTaskId(parsed.sessionId);
|
|
283
|
-
logger.log(`[BOT] 🔓 Unlocked taskId (first message completed)`);
|
|
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;
|
|
284
296
|
}
|
|
285
|
-
|
|
297
|
+
decrementTaskIdRef(parsed.sessionId);
|
|
286
298
|
unregisterSession(route.sessionKey);
|
|
287
299
|
logger.log(`[BOT] ✅ Cleanup completed`);
|
|
288
300
|
},
|
|
@@ -323,6 +335,19 @@ export async function handleXYMessage(params) {
|
|
|
323
335
|
return dispatchPromise;
|
|
324
336
|
},
|
|
325
337
|
});
|
|
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
|
+
}
|
|
326
351
|
logger.log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
|
|
327
352
|
logger.log(`xy: dispatch complete (session=${parsed.sessionId})`);
|
|
328
353
|
}
|
|
@@ -339,7 +364,6 @@ export async function handleXYMessage(params) {
|
|
|
339
364
|
logger.log(`[BOT] 🧹 Cleaning up after error: ${sessionId}`);
|
|
340
365
|
// 清理 taskId
|
|
341
366
|
decrementTaskIdRef(sessionId);
|
|
342
|
-
unlockTaskId(sessionId);
|
|
343
367
|
// 清理 session
|
|
344
368
|
const core = getXYRuntime();
|
|
345
369
|
const route = core.channel.routing.resolveAgentRoute({
|
|
@@ -379,3 +403,133 @@ function buildXYMediaPayload(mediaList) {
|
|
|
379
403
|
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
380
404
|
};
|
|
381
405
|
}
|
|
406
|
+
const streamingSignals = new Map();
|
|
407
|
+
/**
|
|
408
|
+
* 由 provider.ts 在 wrapStreamFn 调用时触发。
|
|
409
|
+
* 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
|
|
410
|
+
*/
|
|
411
|
+
export function notifyModelStreaming(sessionId) {
|
|
412
|
+
const signal = streamingSignals.get(sessionId);
|
|
413
|
+
if (signal) {
|
|
414
|
+
streamingSignals.delete(sessionId);
|
|
415
|
+
signal.notify();
|
|
416
|
+
logger.log(`[STEER-QUEUE] 📡 Model streaming signal fired for session=${sessionId}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function createStreamingSignal(sessionId) {
|
|
420
|
+
let resolve;
|
|
421
|
+
const promise = new Promise(r => { resolve = r; });
|
|
422
|
+
const signal = { promise, notify: resolve };
|
|
423
|
+
streamingSignals.set(sessionId, signal);
|
|
424
|
+
logger.log(`[STEER-QUEUE] 🟢 Streaming signal created for session ${sessionId}`);
|
|
425
|
+
return signal;
|
|
426
|
+
}
|
|
427
|
+
/** Per-session 串行队列:保证同一 session 的 steer 消息按顺序处理 */
|
|
428
|
+
const steerQueues = new Map();
|
|
429
|
+
/**
|
|
430
|
+
* 将 steer 消息放入 per-session 串行队列。
|
|
431
|
+
* 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
|
|
432
|
+
* 多个 steer 按到达顺序串行处理,无需重试。
|
|
433
|
+
*/
|
|
434
|
+
function enqueueSteer(params) {
|
|
435
|
+
const { sessionId } = params;
|
|
436
|
+
// 取出当前队列尾部(或 undefined),然后链上新的 Promise
|
|
437
|
+
const prev = steerQueues.get(sessionId);
|
|
438
|
+
const next = (prev ?? Promise.resolve()).then(() => dispatchSteerWhenReady(params));
|
|
439
|
+
steerQueues.set(sessionId, next);
|
|
440
|
+
// 链条结束后清理
|
|
441
|
+
next.catch(() => { }).finally(() => {
|
|
442
|
+
if (steerQueues.get(sessionId) === next) {
|
|
443
|
+
steerQueues.delete(sessionId);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
return next;
|
|
447
|
+
}
|
|
448
|
+
async function dispatchSteerWhenReady(params) {
|
|
449
|
+
const { sessionId, sessionKey, steerText } = params;
|
|
450
|
+
// 1. 等待第一条消息开始 streaming
|
|
451
|
+
const signal = streamingSignals.get(sessionId);
|
|
452
|
+
if (signal) {
|
|
453
|
+
logger.log(`[STEER-QUEUE] ⏳ Waiting for streaming signal, session=${sessionId}`);
|
|
454
|
+
await signal.promise;
|
|
455
|
+
streamingSignals.delete(sessionId);
|
|
456
|
+
logger.log(`[STEER-QUEUE] ✅ Streaming signal received, session=${sessionId}`);
|
|
457
|
+
}
|
|
458
|
+
// 2. 第一条消息已结束 → 放弃
|
|
459
|
+
if (!hasActiveTask(sessionId)) {
|
|
460
|
+
logger.log(`[STEER-QUEUE] ℹ️ First message completed, skip steer`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// 3. 构建 dispatch 上下文并 dispatch /steer
|
|
464
|
+
const core = getXYRuntime();
|
|
465
|
+
const speaker = sessionId;
|
|
466
|
+
const messageBody = `${speaker}: ${steerText}`;
|
|
467
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
|
|
468
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
469
|
+
channel: "xiaoyi-channel",
|
|
470
|
+
from: speaker,
|
|
471
|
+
timestamp: new Date(),
|
|
472
|
+
envelope: envelopeOptions,
|
|
473
|
+
body: messageBody,
|
|
474
|
+
});
|
|
475
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
476
|
+
Body: body,
|
|
477
|
+
RawBody: steerText,
|
|
478
|
+
CommandBody: steerText,
|
|
479
|
+
From: sessionId,
|
|
480
|
+
To: sessionId,
|
|
481
|
+
SessionKey: params.route.sessionKey,
|
|
482
|
+
AccountId: params.route.accountId,
|
|
483
|
+
ChatType: "direct",
|
|
484
|
+
GroupSubject: undefined,
|
|
485
|
+
SenderName: sessionId,
|
|
486
|
+
SenderId: sessionId,
|
|
487
|
+
Provider: "xiaoyi-channel",
|
|
488
|
+
Surface: "xiaoyi-channel",
|
|
489
|
+
MessageSid: `${params.parsed.taskId}_${params.deviceType}`,
|
|
490
|
+
Timestamp: Date.now(),
|
|
491
|
+
WasMentioned: false,
|
|
492
|
+
CommandAuthorized: true,
|
|
493
|
+
OriginatingChannel: "xiaoyi-channel",
|
|
494
|
+
OriginatingTo: sessionId,
|
|
495
|
+
ReplyToBody: undefined,
|
|
496
|
+
});
|
|
497
|
+
const steerState = { steered: true };
|
|
498
|
+
const { dispatcher, replyOptions } = createXYReplyDispatcher({
|
|
499
|
+
cfg: params.cfg,
|
|
500
|
+
runtime: params.runtime,
|
|
501
|
+
sessionId,
|
|
502
|
+
taskId: params.parsed.taskId,
|
|
503
|
+
messageId: params.parsed.messageId,
|
|
504
|
+
accountId: params.route.accountId,
|
|
505
|
+
steerState,
|
|
506
|
+
});
|
|
507
|
+
const sessionContext = {
|
|
508
|
+
config: resolveXYConfig(params.cfg),
|
|
509
|
+
sessionId,
|
|
510
|
+
taskId: params.parsed.taskId,
|
|
511
|
+
messageId: params.parsed.messageId,
|
|
512
|
+
agentId: params.route.accountId,
|
|
513
|
+
deviceType: params.deviceType,
|
|
514
|
+
};
|
|
515
|
+
logger.log(`[STEER-QUEUE] 🚀 Dispatching steer for session=${sessionId}`);
|
|
516
|
+
await core.channel.reply.withReplyDispatcher({
|
|
517
|
+
dispatcher,
|
|
518
|
+
onSettled: () => {
|
|
519
|
+
logger.log(`[STEER-QUEUE] 🏁 Steer dispatch settled for session=${sessionId}`);
|
|
520
|
+
},
|
|
521
|
+
run: () => {
|
|
522
|
+
return runWithSessionContext(sessionContext, async () => {
|
|
523
|
+
const result = await core.channel.reply.dispatchReplyFromConfig({
|
|
524
|
+
ctx: ctxPayload,
|
|
525
|
+
cfg: params.cfg,
|
|
526
|
+
dispatcher,
|
|
527
|
+
replyOptions,
|
|
528
|
+
});
|
|
529
|
+
logger.log(`[STEER-QUEUE] dispatch result: ${JSON.stringify(result)}`);
|
|
530
|
+
return result;
|
|
531
|
+
});
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
logger.log(`[STEER-QUEUE] ✅ Steer dispatch completed for session=${sessionId}`);
|
|
535
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { type CsplConfig } from "./config.js";
|
|
2
3
|
import type { ApiResponse } from "./constants.js";
|
|
3
4
|
export declare function callCsplApi(questionText: string, cfg: ClawdbotConfig): Promise<ApiResponse>;
|
|
5
|
+
/**
|
|
6
|
+
* Call CSPL API with a pre-resolved CsplConfig.
|
|
7
|
+
* Used by after_tool_call hook which has session context but not ClawdbotConfig.
|
|
8
|
+
*/
|
|
9
|
+
export declare function callCsplApiWithConfig(questionText: string, config: CsplConfig): Promise<ApiResponse>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// SENTINEL HOOK API 请求模块
|
|
2
|
+
import http from "node:http";
|
|
2
3
|
import https from "node:https";
|
|
3
4
|
import { URL } from "node:url";
|
|
4
5
|
import { randomBytes } from "node:crypto";
|
|
@@ -43,18 +44,12 @@ function parseResponse(data) {
|
|
|
43
44
|
}
|
|
44
45
|
return json;
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
questionText,
|
|
51
|
-
textSource: config.textSource,
|
|
52
|
-
action: config.action,
|
|
53
|
-
extra: JSON.stringify({ userId: config.uid }),
|
|
54
|
-
};
|
|
47
|
+
function doApiRequest(url, headers, payload, timeout) {
|
|
48
|
+
const isHttp = url.startsWith("http://");
|
|
49
|
+
const module = isHttp ? http : https;
|
|
50
|
+
const options = buildRequestOptions(url, headers, timeout);
|
|
55
51
|
return new Promise((resolve, reject) => {
|
|
56
|
-
const
|
|
57
|
-
const req = https.request(options, (res) => {
|
|
52
|
+
const req = module.request(options, (res) => {
|
|
58
53
|
if (res.statusCode && res.statusCode >= HTTP_STATUS_BAD_REQUEST) {
|
|
59
54
|
reject(new Error(`[SENTINEL HOOK] HTTP error: ${res.statusCode}`));
|
|
60
55
|
return;
|
|
@@ -66,7 +61,7 @@ export async function callCsplApi(questionText, cfg) {
|
|
|
66
61
|
res.on("end", () => {
|
|
67
62
|
try {
|
|
68
63
|
const result = parseResponse(data);
|
|
69
|
-
logger.log(`[SENTINEL HOOK] ✅
|
|
64
|
+
logger.log(`[SENTINEL HOOK] ✅ 请求成功, securityResult=${result?.data?.securityResult ?? "N/A"}`);
|
|
70
65
|
resolve(result);
|
|
71
66
|
}
|
|
72
67
|
catch (e) {
|
|
@@ -80,7 +75,7 @@ export async function callCsplApi(questionText, cfg) {
|
|
|
80
75
|
reject(error);
|
|
81
76
|
});
|
|
82
77
|
req.on("timeout", () => {
|
|
83
|
-
logger.error(`[SENTINEL HOOK] ⏰ 请求超时 (${
|
|
78
|
+
logger.error(`[SENTINEL HOOK] ⏰ 请求超时 (${timeout}ms)`);
|
|
84
79
|
req.destroy();
|
|
85
80
|
reject(new Error("[SENTINEL HOOK] Request timeout"));
|
|
86
81
|
});
|
|
@@ -88,3 +83,28 @@ export async function callCsplApi(questionText, cfg) {
|
|
|
88
83
|
req.end();
|
|
89
84
|
});
|
|
90
85
|
}
|
|
86
|
+
export async function callCsplApi(questionText, cfg) {
|
|
87
|
+
const config = getCsplConfig(cfg);
|
|
88
|
+
const headers = buildHeaders(config);
|
|
89
|
+
const payload = {
|
|
90
|
+
questionText,
|
|
91
|
+
textSource: config.textSource,
|
|
92
|
+
action: config.action,
|
|
93
|
+
extra: JSON.stringify({ userId: config.uid }),
|
|
94
|
+
};
|
|
95
|
+
return doApiRequest(config.api.url, headers, payload, config.api.timeout);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Call CSPL API with a pre-resolved CsplConfig.
|
|
99
|
+
* Used by after_tool_call hook which has session context but not ClawdbotConfig.
|
|
100
|
+
*/
|
|
101
|
+
export async function callCsplApiWithConfig(questionText, config) {
|
|
102
|
+
const headers = buildHeaders(config);
|
|
103
|
+
const payload = {
|
|
104
|
+
questionText,
|
|
105
|
+
textSource: config.textSource,
|
|
106
|
+
action: config.action,
|
|
107
|
+
extra: JSON.stringify({ userId: config.uid }),
|
|
108
|
+
};
|
|
109
|
+
return doApiRequest(config.api.url, headers, payload, config.api.timeout);
|
|
110
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { XYChannelConfig } from "../types.js";
|
|
2
3
|
export interface ApiConfig {
|
|
3
4
|
url: string;
|
|
4
5
|
timeout: number;
|
|
@@ -15,5 +16,14 @@ export interface CsplConfig {
|
|
|
15
16
|
/**
|
|
16
17
|
* 构建 CSPL 配置。uid 和 apiKey 复用 XYChannelConfig,避免重复配置。
|
|
17
18
|
* serviceUrl 从 .xiaoyienv 文件读取,skillId 写死在常量中。
|
|
19
|
+
*
|
|
20
|
+
* Accepts either ClawdbotConfig (legacy after_tool_call path) or
|
|
21
|
+
* XYChannelConfig (AgentToolResultMiddleware path). Config is cached
|
|
22
|
+
* after the first successful call so subsequent calls can omit the arg.
|
|
18
23
|
*/
|
|
19
|
-
export declare function getCsplConfig(cfg
|
|
24
|
+
export declare function getCsplConfig(cfg?: ClawdbotConfig): CsplConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Initialize CSPL config from an already-resolved XYChannelConfig.
|
|
27
|
+
* Used by AgentToolResultMiddleware which has session context but not ClawdbotConfig.
|
|
28
|
+
*/
|
|
29
|
+
export declare function initCsplConfigFromXYConfig(xyConfig: XYChannelConfig): CsplConfig;
|
package/dist/src/cspl/config.js
CHANGED
|
@@ -27,10 +27,17 @@ function readServiceUrl() {
|
|
|
27
27
|
/**
|
|
28
28
|
* 构建 CSPL 配置。uid 和 apiKey 复用 XYChannelConfig,避免重复配置。
|
|
29
29
|
* serviceUrl 从 .xiaoyienv 文件读取,skillId 写死在常量中。
|
|
30
|
+
*
|
|
31
|
+
* Accepts either ClawdbotConfig (legacy after_tool_call path) or
|
|
32
|
+
* XYChannelConfig (AgentToolResultMiddleware path). Config is cached
|
|
33
|
+
* after the first successful call so subsequent calls can omit the arg.
|
|
30
34
|
*/
|
|
31
35
|
export function getCsplConfig(cfg) {
|
|
32
36
|
if (cachedConfig)
|
|
33
37
|
return cachedConfig;
|
|
38
|
+
if (!cfg) {
|
|
39
|
+
throw new Error("[SENTINEL HOOK] CSPL config not initialized: pass ClawdbotConfig on first call");
|
|
40
|
+
}
|
|
34
41
|
const xyConfig = resolveXYConfig(cfg);
|
|
35
42
|
const serviceUrl = readServiceUrl();
|
|
36
43
|
cachedConfig = {
|
|
@@ -48,3 +55,26 @@ export function getCsplConfig(cfg) {
|
|
|
48
55
|
logger.log("[SENTINEL HOOK] Config loaded (uid/apiKey from XYChannelConfig)");
|
|
49
56
|
return cachedConfig;
|
|
50
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Initialize CSPL config from an already-resolved XYChannelConfig.
|
|
60
|
+
* Used by AgentToolResultMiddleware which has session context but not ClawdbotConfig.
|
|
61
|
+
*/
|
|
62
|
+
export function initCsplConfigFromXYConfig(xyConfig) {
|
|
63
|
+
if (cachedConfig)
|
|
64
|
+
return cachedConfig;
|
|
65
|
+
const serviceUrl = readServiceUrl();
|
|
66
|
+
cachedConfig = {
|
|
67
|
+
api: {
|
|
68
|
+
url: `${serviceUrl}${API_URL_SUFFIX}`,
|
|
69
|
+
timeout: CSPL_STATIC_CONFIG.api.timeout,
|
|
70
|
+
},
|
|
71
|
+
uid: xyConfig.uid,
|
|
72
|
+
apiKey: xyConfig.apiKey,
|
|
73
|
+
skillId: CSPL_STATIC_CONFIG.skillId,
|
|
74
|
+
requestFrom: CSPL_STATIC_CONFIG.requestFrom,
|
|
75
|
+
textSource: CSPL_STATIC_CONFIG.textSource,
|
|
76
|
+
action: CSPL_STATIC_CONFIG.action,
|
|
77
|
+
};
|
|
78
|
+
logger.log("[SENTINEL HOOK] Config loaded via XYChannelConfig");
|
|
79
|
+
return cachedConfig;
|
|
80
|
+
}
|