@ynhcj/xiaoyi-channel 0.0.136-beta → 0.0.136-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 +68 -6
- package/dist/src/bot.d.ts +11 -0
- package/dist/src/bot.js +246 -55
- package/dist/src/cspl/call-api.d.ts +1 -1
- package/dist/src/cspl/call-api.js +21 -49
- package/dist/src/cspl/middleware.js +4 -1
- package/dist/src/cspl/steer-context.d.ts +21 -0
- package/dist/src/cspl/steer-context.js +78 -0
- package/dist/src/provider.js +6 -0
- package/dist/src/tools/session-manager.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
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";
|
|
6
|
+
import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./src/cspl/constants.js";
|
|
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";
|
|
5
11
|
import { setXYRuntime } from "./src/runtime.js";
|
|
6
12
|
import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
|
|
7
13
|
import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
|
|
@@ -19,11 +25,66 @@ function registerFullHooks(api) {
|
|
|
19
25
|
const beforePromptBuildHandler = createBeforePromptBuildHandler(skillRetrieverConfig);
|
|
20
26
|
api.on("before_prompt_build", beforePromptBuildHandler);
|
|
21
27
|
registerSelfEvolutionToolResultNudge(api);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
36
|
+
api.on("after_tool_call", async (event, ctx) => {
|
|
37
|
+
if (!ALLOWED_TOOLS.includes(event.toolName)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const resultText = extractResultText(event, event.toolName);
|
|
42
|
+
const resultLength = resultText.length;
|
|
43
|
+
if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
logger.log(`[SENTINEL HOOK] after_tool_call: toolName=${event.toolName}, textLength=${resultLength}`);
|
|
47
|
+
const questionText = {
|
|
48
|
+
subSceneID: "TOOL_OUTPUT",
|
|
49
|
+
tool: event.toolName,
|
|
50
|
+
output: [{ content: "" }],
|
|
51
|
+
};
|
|
52
|
+
const originText = processText(resultText);
|
|
53
|
+
questionText.output[0].content = originText;
|
|
54
|
+
let finalJson = JSON.stringify(questionText);
|
|
55
|
+
if (finalJson.length > MAX_TEXT_LENGTH) {
|
|
56
|
+
const diff = finalJson.length - MAX_TEXT_LENGTH;
|
|
57
|
+
const { text: trimmed } = validateAndTruncateText(originText, MAX_TEXT_LENGTH - diff);
|
|
58
|
+
questionText.output[0].content = trimmed;
|
|
59
|
+
finalJson = JSON.stringify(questionText);
|
|
60
|
+
}
|
|
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;
|
|
68
|
+
const result = parseSecurityResult(response);
|
|
69
|
+
logger.log(`[SENTINEL HOOK] Security result: status=${result.status}, toolName=${event.toolName}, elapsed=${csplElapsed}ms`);
|
|
70
|
+
if (result.status === "REJECT") {
|
|
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
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
logger.error(`[SENTINEL HOOK] after_tool_call error: ${err}`);
|
|
87
|
+
}
|
|
27
88
|
});
|
|
28
89
|
}
|
|
29
90
|
export default definePluginEntry({
|
|
@@ -49,6 +110,7 @@ export default definePluginEntry({
|
|
|
49
110
|
}
|
|
50
111
|
if (api.registrationMode === "full") {
|
|
51
112
|
registerFullHooks(api);
|
|
113
|
+
registerCsplHook(api);
|
|
52
114
|
}
|
|
53
115
|
},
|
|
54
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
|
@@ -12,6 +12,7 @@ import { getPushDataById } from "./utils/pushdata-manager.js";
|
|
|
12
12
|
import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
|
|
13
13
|
import { saveRuntimeInfo } from "./utils/runtime-manager.js";
|
|
14
14
|
import { toolCallNudgeManager } from "./utils/tool-call-nudge-manager.js";
|
|
15
|
+
import { setCsplSteerContext } from "./cspl/steer-context.js";
|
|
15
16
|
import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
|
|
16
17
|
import { logger } from "./utils/logger.js";
|
|
17
18
|
/**
|
|
@@ -21,6 +22,8 @@ import { logger } from "./utils/logger.js";
|
|
|
21
22
|
*/
|
|
22
23
|
export async function handleXYMessage(params) {
|
|
23
24
|
const { cfg, runtime, message, accountId, webSocketSessionId } = params;
|
|
25
|
+
// Cache context for CSPL steer injection (after_tool_call hook)
|
|
26
|
+
setCsplSteerContext(cfg, runtime);
|
|
24
27
|
// Get runtime (already validated in monitor.ts, but get reference for use)
|
|
25
28
|
const core = getXYRuntime();
|
|
26
29
|
try {
|
|
@@ -98,37 +101,41 @@ export async function handleXYMessage(params) {
|
|
|
98
101
|
// ========================================
|
|
99
102
|
// 🔑 注册taskId(检测是否是已有活跃任务的 session)
|
|
100
103
|
const isUpdate = hasActiveTask(parsed.sessionId);
|
|
104
|
+
const skipReg = params.skipRegistration === true;
|
|
101
105
|
if (isUpdate) {
|
|
102
106
|
logger.log(`[BOT] 🔄 STEER MODE - Second message detected (core will handle steer)`);
|
|
103
107
|
logger.log(`[BOT] - Session: ${parsed.sessionId}`);
|
|
104
108
|
logger.log(`[BOT] - New taskId: ${parsed.taskId}`);
|
|
105
109
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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);
|
|
115
132
|
});
|
|
116
133
|
}
|
|
117
|
-
|
|
118
|
-
logger.log(`[BOT] ℹ️ No push_id found in message, will use config default`);
|
|
119
|
-
}
|
|
120
|
-
// Extract deviceType if present (same level as push_id in systemVariables)
|
|
134
|
+
// Extract deviceType if present (always parse — used in ctxPayload.MessageSid)
|
|
121
135
|
const deviceType = extractDeviceType(parsed.parts);
|
|
122
136
|
if (deviceType) {
|
|
123
137
|
logger.log(`[BOT] 📱 Extracted deviceType from user message: ${deviceType}`);
|
|
124
138
|
}
|
|
125
|
-
// 保存 runtime 信息到 .xiaoyiruntime 文件(异步,不阻塞主流程)
|
|
126
|
-
saveRuntimeInfo(webSocketSessionId || parsed.sessionId, // SESSION_ID (WebSocket 层级,如果没有则 fallback)
|
|
127
|
-
parsed.sessionId, // CONVERSATION_ID (param 里的 sessionId)
|
|
128
|
-
parsed.taskId // TASK_ID (param.id)
|
|
129
|
-
).catch((err) => {
|
|
130
|
-
logger.error(`[BOT] Failed to save runtime info:`, err);
|
|
131
|
-
});
|
|
132
139
|
// Resolve configuration (needed for status updates)
|
|
133
140
|
const config = resolveXYConfig(cfg);
|
|
134
141
|
// ✅ Resolve agent route (following feishu pattern)
|
|
@@ -144,30 +151,34 @@ export async function handleXYMessage(params) {
|
|
|
144
151
|
},
|
|
145
152
|
});
|
|
146
153
|
logger.log(`xy: resolved route accountId=${route.accountId}, sessionKey=${route.sessionKey}`);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
}
|
|
167
177
|
// Extract text and files from parts
|
|
168
178
|
const text = extractTextFromParts(parsed.parts);
|
|
169
179
|
let textForAgent = text || "";
|
|
170
|
-
|
|
180
|
+
// Self-evolution keyword nudge — only for real user messages, not steer injections
|
|
181
|
+
if (!skipReg && route.sessionKey && textForAgent) {
|
|
171
182
|
try {
|
|
172
183
|
const selfEvolutionEnabled = await selfEvolutionManager.isEnabled();
|
|
173
184
|
if (selfEvolutionEnabled && shouldNudgeForSelfEvolutionKeyword(textForAgent)) {
|
|
@@ -186,16 +197,40 @@ export async function handleXYMessage(params) {
|
|
|
186
197
|
logger.error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
|
|
187
198
|
}
|
|
188
199
|
}
|
|
189
|
-
// 🔑 Steer
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
200
|
+
// 🔑 Steer消息: 跳过旧路径直接进入 streaming-signal 队列
|
|
201
|
+
// /steer 前缀由 dispatchSteerWhenReady 内部添加
|
|
202
|
+
if (isUpdate) {
|
|
203
|
+
// 立即释放 init gate——steer 不走 withReplyDispatcher 的 run()
|
|
204
|
+
// 回调,onInitComplete 永远不会被触发。如果不释放,后续消息
|
|
205
|
+
// 会被 globalDispatchInitGate 永久阻塞。
|
|
206
|
+
params.onInitComplete?.();
|
|
207
|
+
logger.log(`[BOT] 🔄 Steer message — enqueuing to streaming-signal queue`);
|
|
208
|
+
await enqueueSteer({
|
|
209
|
+
sessionId: parsed.sessionId,
|
|
210
|
+
sessionKey: route.sessionKey,
|
|
211
|
+
steerText: textForAgent, // 原始文本,不带 /steer 前缀
|
|
212
|
+
cfg,
|
|
213
|
+
runtime,
|
|
214
|
+
parsed,
|
|
215
|
+
route,
|
|
216
|
+
deviceType,
|
|
217
|
+
});
|
|
218
|
+
logger.log(`[BOT] ✅ Steer queue completed for session: ${parsed.sessionId}`);
|
|
219
|
+
logger.log(`xy: dispatch complete (session=${parsed.sessionId})`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// ── First message (non-steer) path below ──────────────────────
|
|
223
|
+
// 🔑 立即创建 streaming 信号——必须在文件下载等耗时操作之前,
|
|
224
|
+
// 否则 steer 消息的 dispatchSteerWhenReady 会找不到信号而跳过等待。
|
|
225
|
+
createStreamingSignal(parsed.sessionId);
|
|
226
|
+
// File download — only for real user messages, steer injections have no files
|
|
227
|
+
let mediaPayload = {};
|
|
228
|
+
if (!skipReg) {
|
|
229
|
+
const fileParts = extractFileParts(parsed.parts);
|
|
230
|
+
const downloadedFiles = await downloadFilesFromParts(fileParts);
|
|
231
|
+
logger.log("Downloaded files:", JSON.stringify(downloadedFiles, null, 2));
|
|
232
|
+
mediaPayload = buildXYMediaPayload(downloadedFiles);
|
|
193
233
|
}
|
|
194
|
-
const fileParts = extractFileParts(parsed.parts);
|
|
195
|
-
// Download files to local disk
|
|
196
|
-
const downloadedFiles = await downloadFilesFromParts(fileParts);
|
|
197
|
-
logger.log("Downloaded files:", JSON.stringify(downloadedFiles, null, 2));
|
|
198
|
-
const mediaPayload = buildXYMediaPayload(downloadedFiles);
|
|
199
234
|
// Resolve envelope format options (following feishu pattern)
|
|
200
235
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
201
236
|
// Build message body with speaker prefix (following feishu pattern)
|
|
@@ -236,10 +271,8 @@ export async function handleXYMessage(params) {
|
|
|
236
271
|
ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
|
|
237
272
|
...mediaPayload,
|
|
238
273
|
});
|
|
239
|
-
// 🔑
|
|
240
|
-
|
|
241
|
-
// and onSettled skips cleanup.
|
|
242
|
-
const steerState = { steered: isUpdate };
|
|
274
|
+
// 🔑 Streaming 信号已在上方创建(在文件下载之前)
|
|
275
|
+
const steerState = { steered: false };
|
|
243
276
|
// 🔑 创建dispatcher
|
|
244
277
|
logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
|
|
245
278
|
logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
|
|
@@ -252,7 +285,10 @@ export async function handleXYMessage(params) {
|
|
|
252
285
|
accountId: route.accountId,
|
|
253
286
|
steerState,
|
|
254
287
|
});
|
|
255
|
-
|
|
288
|
+
// Steer injections don't need status intervals
|
|
289
|
+
if (!skipReg) {
|
|
290
|
+
startStatusInterval();
|
|
291
|
+
}
|
|
256
292
|
// Build session context for AsyncLocalStorage
|
|
257
293
|
const sessionContext = {
|
|
258
294
|
config,
|
|
@@ -369,3 +405,158 @@ function buildXYMediaPayload(mediaList) {
|
|
|
369
405
|
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
370
406
|
};
|
|
371
407
|
}
|
|
408
|
+
// Use globalThis to survive module deduplication — provider.ts may load a
|
|
409
|
+
// different copy of bot.ts, so a plain module-level Map would be two objects.
|
|
410
|
+
const _g = globalThis;
|
|
411
|
+
if (!_g.__xyStreamingSignals)
|
|
412
|
+
_g.__xyStreamingSignals = new Map();
|
|
413
|
+
if (!_g.__xySteerQueues)
|
|
414
|
+
_g.__xySteerQueues = new Map();
|
|
415
|
+
const streamingSignals = _g.__xyStreamingSignals;
|
|
416
|
+
const steerQueues = _g.__xySteerQueues;
|
|
417
|
+
/**
|
|
418
|
+
* 由 provider.ts 在 wrapStreamFn 调用时触发。
|
|
419
|
+
* 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
|
|
420
|
+
*/
|
|
421
|
+
export function notifyModelStreaming(sessionId) {
|
|
422
|
+
const signal = streamingSignals.get(sessionId);
|
|
423
|
+
if (signal) {
|
|
424
|
+
streamingSignals.delete(sessionId);
|
|
425
|
+
signal.notify();
|
|
426
|
+
logger.log(`[STEER-QUEUE] 📡 Model streaming signal fired for session=${sessionId}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function createStreamingSignal(sessionId) {
|
|
430
|
+
let resolve;
|
|
431
|
+
const promise = new Promise(r => { resolve = r; });
|
|
432
|
+
const signal = { promise, notify: resolve };
|
|
433
|
+
streamingSignals.set(sessionId, signal);
|
|
434
|
+
logger.log(`[STEER-QUEUE] 🟢 Streaming signal created for session ${sessionId}`);
|
|
435
|
+
return signal;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* 将 steer 消息放入 per-session 串行队列。
|
|
439
|
+
* 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
|
|
440
|
+
* 多个 steer 按到达顺序串行处理,无需重试。
|
|
441
|
+
*/
|
|
442
|
+
function enqueueSteer(params) {
|
|
443
|
+
const { sessionId } = params;
|
|
444
|
+
// 取出当前队列尾部(或 undefined),然后链上新的 Promise
|
|
445
|
+
const prev = steerQueues.get(sessionId);
|
|
446
|
+
const next = (prev ?? Promise.resolve()).then(() => dispatchSteerWhenReady(params));
|
|
447
|
+
steerQueues.set(sessionId, next);
|
|
448
|
+
// 链条结束后清理
|
|
449
|
+
next.catch(() => { }).finally(() => {
|
|
450
|
+
if (steerQueues.get(sessionId) === next) {
|
|
451
|
+
steerQueues.delete(sessionId);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
return next;
|
|
455
|
+
}
|
|
456
|
+
async function dispatchSteerWhenReady(params) {
|
|
457
|
+
const { sessionId, sessionKey, steerText } = params;
|
|
458
|
+
// 1. 等待第一条消息开始 streaming
|
|
459
|
+
// signal 可能尚未创建(第一条消息还在文件下载等耗时操作中),
|
|
460
|
+
// 轮询等待直到 signal 出现,最長等待 ~5 秒。
|
|
461
|
+
let signal = streamingSignals.get(sessionId);
|
|
462
|
+
if (!signal) {
|
|
463
|
+
logger.log(`[STEER-QUEUE] ⏳ Signal not yet created, polling for session=${sessionId}`);
|
|
464
|
+
for (let i = 0; i < 50; i++) {
|
|
465
|
+
await new Promise(r => setTimeout(r, 100));
|
|
466
|
+
signal = streamingSignals.get(sessionId);
|
|
467
|
+
if (signal)
|
|
468
|
+
break;
|
|
469
|
+
if (!hasActiveTask(sessionId)) {
|
|
470
|
+
logger.log(`[STEER-QUEUE] ℹ️ First message completed while waiting, skip steer`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (signal) {
|
|
476
|
+
logger.log(`[STEER-QUEUE] ⏳ Waiting for streaming signal, session=${sessionId}`);
|
|
477
|
+
await signal.promise;
|
|
478
|
+
streamingSignals.delete(sessionId);
|
|
479
|
+
logger.log(`[STEER-QUEUE] ✅ Streaming signal received, session=${sessionId}`);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
logger.log(`[STEER-QUEUE] ⚠️ Signal never appeared, proceeding without wait`);
|
|
483
|
+
}
|
|
484
|
+
// 2. 第一条消息已结束 → 放弃
|
|
485
|
+
if (!hasActiveTask(sessionId)) {
|
|
486
|
+
logger.log(`[STEER-QUEUE] ℹ️ First message completed, skip steer`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// 3. 构建 dispatch 上下文并 dispatch /steer
|
|
490
|
+
const core = getXYRuntime();
|
|
491
|
+
const speaker = sessionId;
|
|
492
|
+
const steerCommand = `/steer ${steerText}`;
|
|
493
|
+
const messageBody = `${speaker}: ${steerCommand}`;
|
|
494
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
|
|
495
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
496
|
+
channel: "xiaoyi-channel",
|
|
497
|
+
from: speaker,
|
|
498
|
+
timestamp: new Date(),
|
|
499
|
+
envelope: envelopeOptions,
|
|
500
|
+
body: messageBody,
|
|
501
|
+
});
|
|
502
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
503
|
+
Body: body,
|
|
504
|
+
RawBody: steerCommand,
|
|
505
|
+
CommandBody: steerCommand,
|
|
506
|
+
From: sessionId,
|
|
507
|
+
To: sessionId,
|
|
508
|
+
SessionKey: params.route.sessionKey,
|
|
509
|
+
AccountId: params.route.accountId,
|
|
510
|
+
ChatType: "direct",
|
|
511
|
+
GroupSubject: undefined,
|
|
512
|
+
SenderName: sessionId,
|
|
513
|
+
SenderId: sessionId,
|
|
514
|
+
Provider: "xiaoyi-channel",
|
|
515
|
+
Surface: "xiaoyi-channel",
|
|
516
|
+
MessageSid: `${params.parsed.taskId}_${params.deviceType}`,
|
|
517
|
+
Timestamp: Date.now(),
|
|
518
|
+
WasMentioned: false,
|
|
519
|
+
CommandAuthorized: true,
|
|
520
|
+
OriginatingChannel: "xiaoyi-channel",
|
|
521
|
+
OriginatingTo: sessionId,
|
|
522
|
+
ReplyToBody: undefined,
|
|
523
|
+
});
|
|
524
|
+
const steerState = { steered: true };
|
|
525
|
+
const { dispatcher, replyOptions } = createXYReplyDispatcher({
|
|
526
|
+
cfg: params.cfg,
|
|
527
|
+
runtime: params.runtime,
|
|
528
|
+
sessionId,
|
|
529
|
+
taskId: params.parsed.taskId,
|
|
530
|
+
messageId: params.parsed.messageId,
|
|
531
|
+
accountId: params.route.accountId,
|
|
532
|
+
steerState,
|
|
533
|
+
});
|
|
534
|
+
const sessionContext = {
|
|
535
|
+
config: resolveXYConfig(params.cfg),
|
|
536
|
+
sessionId,
|
|
537
|
+
taskId: params.parsed.taskId,
|
|
538
|
+
messageId: params.parsed.messageId,
|
|
539
|
+
agentId: params.route.accountId,
|
|
540
|
+
deviceType: params.deviceType,
|
|
541
|
+
};
|
|
542
|
+
logger.log(`[STEER-QUEUE] 🚀 Dispatching steer for session=${sessionId}`);
|
|
543
|
+
await core.channel.reply.withReplyDispatcher({
|
|
544
|
+
dispatcher,
|
|
545
|
+
onSettled: () => {
|
|
546
|
+
logger.log(`[STEER-QUEUE] 🏁 Steer dispatch settled for session=${sessionId}`);
|
|
547
|
+
},
|
|
548
|
+
run: () => {
|
|
549
|
+
return runWithSessionContext(sessionContext, async () => {
|
|
550
|
+
const result = await core.channel.reply.dispatchReplyFromConfig({
|
|
551
|
+
ctx: ctxPayload,
|
|
552
|
+
cfg: params.cfg,
|
|
553
|
+
dispatcher,
|
|
554
|
+
replyOptions,
|
|
555
|
+
});
|
|
556
|
+
logger.log(`[STEER-QUEUE] dispatch result: ${JSON.stringify(result)}`);
|
|
557
|
+
return result;
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
logger.log(`[STEER-QUEUE] ✅ Steer dispatch completed for session=${sessionId}`);
|
|
562
|
+
}
|
|
@@ -4,6 +4,6 @@ import type { ApiResponse } from "./constants.js";
|
|
|
4
4
|
export declare function callCsplApi(questionText: string, cfg: ClawdbotConfig): Promise<ApiResponse>;
|
|
5
5
|
/**
|
|
6
6
|
* Call CSPL API with a pre-resolved CsplConfig.
|
|
7
|
-
* Used by
|
|
7
|
+
* Used by after_tool_call hook which has session context but not ClawdbotConfig.
|
|
8
8
|
*/
|
|
9
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,9 +83,20 @@ 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
|
+
}
|
|
91
97
|
/**
|
|
92
98
|
* Call CSPL API with a pre-resolved CsplConfig.
|
|
93
|
-
* Used by
|
|
99
|
+
* Used by after_tool_call hook which has session context but not ClawdbotConfig.
|
|
94
100
|
*/
|
|
95
101
|
export async function callCsplApiWithConfig(questionText, config) {
|
|
96
102
|
const headers = buildHeaders(config);
|
|
@@ -100,39 +106,5 @@ export async function callCsplApiWithConfig(questionText, config) {
|
|
|
100
106
|
action: config.action,
|
|
101
107
|
extra: JSON.stringify({ userId: config.uid }),
|
|
102
108
|
};
|
|
103
|
-
return
|
|
104
|
-
const options = buildRequestOptions(config.api.url, headers, config.api.timeout);
|
|
105
|
-
const req = https.request(options, (res) => {
|
|
106
|
-
if (res.statusCode && res.statusCode >= HTTP_STATUS_BAD_REQUEST) {
|
|
107
|
-
reject(new Error(`[SENTINEL HOOK] HTTP error: ${res.statusCode}`));
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
let data = "";
|
|
111
|
-
res.on("data", (chunk) => {
|
|
112
|
-
data += chunk;
|
|
113
|
-
});
|
|
114
|
-
res.on("end", () => {
|
|
115
|
-
try {
|
|
116
|
-
const result = parseResponse(data);
|
|
117
|
-
logger.log(`[SENTINEL HOOK] ✅ 请求成功`);
|
|
118
|
-
resolve(result);
|
|
119
|
-
}
|
|
120
|
-
catch (e) {
|
|
121
|
-
logger.error(`[SENTINEL HOOK] ❌ 请求失败: ${e instanceof Error ? e.message : String(e)}`);
|
|
122
|
-
reject(e);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
req.on("error", (error) => {
|
|
127
|
-
logger.error(`[SENTINEL HOOK] ❌ 请求错误: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
-
reject(error);
|
|
129
|
-
});
|
|
130
|
-
req.on("timeout", () => {
|
|
131
|
-
logger.error(`[SENTINEL HOOK] ⏰ 请求超时 (${config.api.timeout}ms)`);
|
|
132
|
-
req.destroy();
|
|
133
|
-
reject(new Error("[SENTINEL HOOK] Request timeout"));
|
|
134
|
-
});
|
|
135
|
-
req.write(JSON.stringify(payload));
|
|
136
|
-
req.end();
|
|
137
|
-
});
|
|
109
|
+
return doApiRequest(config.api.url, headers, payload, config.api.timeout);
|
|
138
110
|
}
|
|
@@ -46,6 +46,7 @@ export function createCsplMiddleware() {
|
|
|
46
46
|
if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
|
+
logger.log(`[CSPL MIDDLEWARE] Scanning tool result: toolName=${event.toolName}, textLength=${resultLength}`);
|
|
49
50
|
// Build CSPL request payload
|
|
50
51
|
const questionText = {
|
|
51
52
|
subSceneID: "TOOL_OUTPUT",
|
|
@@ -67,9 +68,11 @@ export function createCsplMiddleware() {
|
|
|
67
68
|
const csplConfig = sessionCtx
|
|
68
69
|
? initCsplConfigFromXYConfig(sessionCtx.config)
|
|
69
70
|
: getCsplConfig();
|
|
71
|
+
const csplStartTime = Date.now();
|
|
70
72
|
const response = await callCsplApiWithConfig(finalJson, csplConfig);
|
|
73
|
+
const csplElapsed = Date.now() - csplStartTime;
|
|
71
74
|
const result = parseSecurityResult(response);
|
|
72
|
-
logger.log(`[CSPL MIDDLEWARE] Security result: status=${result.status}, toolName=${event.toolName}`);
|
|
75
|
+
logger.log(`[CSPL MIDDLEWARE] Security result: status=${result.status}, toolName=${event.toolName}, elapsed=${csplElapsed}ms`);
|
|
73
76
|
if (result.status === "REJECT") {
|
|
74
77
|
logger.log(`[CSPL MIDDLEWARE] REJECT - replacing tool result with security message`);
|
|
75
78
|
return {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
/** Called from handleXYMessage on every inbound A2A message to keep cfg/runtime fresh. */
|
|
3
|
+
export declare function setCsplSteerContext(cfg: ClawdbotConfig, runtime: RuntimeEnv): void;
|
|
4
|
+
/** Parameters for steer message injection. */
|
|
5
|
+
export interface SteerInjectionParams {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
taskId: string;
|
|
8
|
+
message: string;
|
|
9
|
+
/** Human-readable source label for logging (e.g. "cspl", "self-evolution"). */
|
|
10
|
+
source: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Inject a steer message into an active session by constructing a synthetic
|
|
14
|
+
* A2A tasks/send message and dispatching it through handleXYMessage.
|
|
15
|
+
*
|
|
16
|
+
* Uses skipRegistration so the steer message doesn't register a new taskId,
|
|
17
|
+
* increment session refCount, or send extra status updates.
|
|
18
|
+
*
|
|
19
|
+
* Returns true if the injection was dispatched successfully.
|
|
20
|
+
*/
|
|
21
|
+
export declare function tryInjectSteer(params: SteerInjectionParams): Promise<boolean>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { handleXYMessage } from "../bot.js";
|
|
2
|
+
import { logger } from "../utils/logger.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
// Use globalThis to ensure a single cache across all module copies.
|
|
5
|
+
// The xy_channel plugin may be loaded by openclaw from different module
|
|
6
|
+
// resolution paths, causing steer-context.ts to be instantiated multiple
|
|
7
|
+
// times. globalThis guarantees all copies share the same cfg/runtime.
|
|
8
|
+
const _g = globalThis;
|
|
9
|
+
if (!_g.__xySteerCachedCfg) {
|
|
10
|
+
_g.__xySteerCachedCfg = null;
|
|
11
|
+
}
|
|
12
|
+
if (!_g.__xySteerCachedRuntime) {
|
|
13
|
+
_g.__xySteerCachedRuntime = null;
|
|
14
|
+
}
|
|
15
|
+
function getCachedCfg() {
|
|
16
|
+
return _g.__xySteerCachedCfg;
|
|
17
|
+
}
|
|
18
|
+
function setCachedCfg(cfg) {
|
|
19
|
+
_g.__xySteerCachedCfg = cfg;
|
|
20
|
+
}
|
|
21
|
+
function getCachedRuntime() {
|
|
22
|
+
return _g.__xySteerCachedRuntime;
|
|
23
|
+
}
|
|
24
|
+
function setCachedRuntime(runtime) {
|
|
25
|
+
_g.__xySteerCachedRuntime = runtime;
|
|
26
|
+
}
|
|
27
|
+
/** Called from handleXYMessage on every inbound A2A message to keep cfg/runtime fresh. */
|
|
28
|
+
export function setCsplSteerContext(cfg, runtime) {
|
|
29
|
+
setCachedCfg(cfg);
|
|
30
|
+
setCachedRuntime(runtime);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Inject a steer message into an active session by constructing a synthetic
|
|
34
|
+
* A2A tasks/send message and dispatching it through handleXYMessage.
|
|
35
|
+
*
|
|
36
|
+
* Uses skipRegistration so the steer message doesn't register a new taskId,
|
|
37
|
+
* increment session refCount, or send extra status updates.
|
|
38
|
+
*
|
|
39
|
+
* Returns true if the injection was dispatched successfully.
|
|
40
|
+
*/
|
|
41
|
+
export async function tryInjectSteer(params) {
|
|
42
|
+
const { sessionId, taskId, message, source } = params;
|
|
43
|
+
const cfg = getCachedCfg();
|
|
44
|
+
const runtime = getCachedRuntime();
|
|
45
|
+
if (!cfg || !runtime) {
|
|
46
|
+
logger.error(`[STEER:${source}] No cached cfg/runtime, cannot inject steer`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const syntheticMessage = {
|
|
50
|
+
jsonrpc: "2.0",
|
|
51
|
+
method: "tasks/send",
|
|
52
|
+
id: `steer-${source}-${randomUUID()}`,
|
|
53
|
+
params: {
|
|
54
|
+
sessionId,
|
|
55
|
+
id: taskId,
|
|
56
|
+
agentLoginSessionId: "",
|
|
57
|
+
message: {
|
|
58
|
+
role: "user",
|
|
59
|
+
parts: [{ kind: "text", text: message }],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
logger.log(`[STEER:${source}] Injecting steer for sessionId=${sessionId}, taskId=${taskId}`);
|
|
64
|
+
try {
|
|
65
|
+
await handleXYMessage({
|
|
66
|
+
cfg,
|
|
67
|
+
runtime,
|
|
68
|
+
message: syntheticMessage,
|
|
69
|
+
accountId: "default",
|
|
70
|
+
skipRegistration: true,
|
|
71
|
+
});
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.error(`[STEER:${source}] Failed to inject steer: ${err}`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/src/provider.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createHash } from "crypto";
|
|
|
11
11
|
import { logger } from "./utils/logger.js";
|
|
12
12
|
import { getCurrentSessionContext } from "./tools/session-manager.js";
|
|
13
13
|
import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
|
|
14
|
+
import { notifyModelStreaming } from "./bot.js";
|
|
14
15
|
// ── Retry config ──────────────────────────────────────────────
|
|
15
16
|
const RETRY_DELAYS_MS = [10_000, 20_000, 40_000, 60_000, 60_000];
|
|
16
17
|
const MAX_RETRY_ATTEMPTS = 5;
|
|
@@ -536,6 +537,11 @@ export const xiaoyiProvider = {
|
|
|
536
537
|
}
|
|
537
538
|
// 记录输入
|
|
538
539
|
logger.log(`[xiaoyiprovider] input messages count: ${context.messages?.length ?? 0}`);
|
|
540
|
+
// 🔑 通知 steer 队列:模型 API 已被调用,此时 isStreaming 一定为 true
|
|
541
|
+
const sessionCtx = getCurrentSessionContext();
|
|
542
|
+
if (sessionCtx?.sessionId) {
|
|
543
|
+
notifyModelStreaming(sessionCtx.sessionId);
|
|
544
|
+
}
|
|
539
545
|
if (context.systemPrompt) {
|
|
540
546
|
logger.log(`[xiaoyiprovider] system prompt length: ${context.systemPrompt.length}`);
|
|
541
547
|
}
|
|
@@ -8,7 +8,7 @@ import { getCurrentTaskId, getCurrentMessageId } from "../task-manager.js";
|
|
|
8
8
|
* 仅用于全局 Map 回退路径的清理,不影响 ALS 路径。
|
|
9
9
|
* 工具已改为闭包捕获 ctx,此 TTL 仅作为防止 session 泄漏的最后防线。
|
|
10
10
|
* 正常对话中 registerSession 会刷新 createdAt,所以长对话不受影响。 */
|
|
11
|
-
const SESSION_TTL_MS = 60 * 60 * 1000; //
|
|
11
|
+
const SESSION_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
12
12
|
// Use globalThis to ensure a single Map instance across all module copies.
|
|
13
13
|
// The xy_channel plugin may be loaded by openclaw from different module resolution
|
|
14
14
|
// paths (plugin entry vs tool registration), causing session-manager.ts to be
|