@ynhcj/xiaoyi-channel 0.0.135-beta → 0.0.135-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 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 { callCsplApi } from "./src/cspl/call-api.js";
4
+ import { callCsplApiWithConfig } from "./src/cspl/call-api.js";
5
+ import { getCsplConfig, initCsplConfigFromXYConfig } from "./src/cspl/config.js";
5
6
  import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./src/cspl/constants.js";
6
7
  import { extractResultText, parseSecurityResult, processText, validateAndTruncateText, } from "./src/cspl/utils.js";
7
- import { setXYRuntime } from "./src/runtime.js";
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 { tryInjectSteer } from "./src/steer-injector.js";
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 response = await callCsplApi(finalJson, api.config);
61
+ const sessionCtx = getSessionContext(ctx.sessionKey ?? "");
62
+ const csplConfig = sessionCtx
63
+ ? initCsplConfigFromXYConfig(sessionCtx.config)
64
+ : getCsplConfig();
65
+ const csplStartTime = Date.now();
66
+ const response = await callCsplApiWithConfig(finalJson, csplConfig);
67
+ const csplElapsed = Date.now() - csplStartTime;
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
- await tryInjectSteer(ctx.sessionKey, STEER_ABORT_MESSAGE);
71
+ logger.log(`[SENTINEL HOOK] REJECT - injecting steer via tryInjectSteer`);
72
+ if (sessionCtx) {
73
+ await tryInjectSteer({
74
+ sessionId: sessionCtx.sessionId,
75
+ taskId: sessionCtx.taskId,
76
+ message: STEER_ABORT_MESSAGE,
77
+ source: "cspl",
78
+ });
79
+ }
80
+ else {
81
+ logger.error("[SENTINEL HOOK] No session context, cannot inject steer");
82
+ }
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 { registerTaskId, decrementTaskIdRef, lockTaskId, unlockTaskId, hasActiveTask, } from "./task-manager.js";
15
+ import { setCsplSteerContext } from "./cspl/steer-context.js";
16
+ import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
17
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
- // 每次收到消息时更新缓存,供 steer 注入使用
26
- setCachedContext(cfg, runtime, accountId);
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
- // 🔑 检测steer模式和是否是第二条消息
103
- const isSteerMode = cfg.messages?.queue?.mode === "steer";
104
- const isSecondMessage = isSteerMode && hasActiveTask(parsed.sessionId);
105
- if (isSecondMessage) {
106
- logger.log(`[BOT] 🔄 STEER MODE - Second message detected (will be follower)`);
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} (will replace current)`);
108
+ logger.log(`[BOT] - New taskId: ${parsed.taskId}`);
109
109
  }
110
- // 🔑 注册taskId(第二条消息会覆盖第一条的taskId
111
- const { isUpdate, refCount } = registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId, { incrementRef: true } // 增加引用计数
112
- );
113
- // 🔑 如果是第一条消息,锁定taskId防止被过早清理
114
- if (!isUpdate) {
115
- lockTaskId(parsed.sessionId);
116
- logger.log(`[BOT] 🔒 Locked taskId for first message`);
117
- }
118
- // Extract and update push_id if present
119
- const pushId = extractPushId(parsed.parts);
120
- if (pushId) {
121
- logger.log(`[BOT] 📌 Extracted push_id from user message`);
122
- configManager.updatePushId(parsed.sessionId, pushId);
123
- // 持久化 pushId 到本地文件(异步,不阻塞主流程)
124
- addPushId(pushId).catch((err) => {
125
- logger.error(`[BOT] Failed to persist pushId:`, err);
110
+ // Steer injections skip taskId registration to avoid overwriting the active taskId
111
+ if (!skipReg) {
112
+ registerTaskId(parsed.sessionId, parsed.taskId, parsed.messageId);
113
+ // Extract and update push_id if present
114
+ const pushId = extractPushId(parsed.parts);
115
+ if (pushId) {
116
+ logger.log(`[BOT] 📌 Extracted push_id from user message`);
117
+ configManager.updatePushId(parsed.sessionId, pushId);
118
+ // 持久化 pushId 到本地文件(异步,不阻塞主流程)
119
+ addPushId(pushId).catch((err) => {
120
+ logger.error(`[BOT] Failed to persist pushId:`, err);
121
+ });
122
+ }
123
+ else {
124
+ logger.log(`[BOT] ℹ️ No push_id found in message, will use config default`);
125
+ }
126
+ // 保存 runtime 信息到 .xiaoyiruntime 文件(异步,不阻塞主流程)
127
+ saveRuntimeInfo(webSocketSessionId || parsed.sessionId, // SESSION_ID (WebSocket 层级,如果没有则 fallback)
128
+ parsed.sessionId, // CONVERSATION_ID (param 里的 sessionId)
129
+ parsed.taskId // TASK_ID (param.id)
130
+ ).catch((err) => {
131
+ logger.error(`[BOT] Failed to save runtime info:`, err);
126
132
  });
127
133
  }
128
- else {
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
- registerSession(route.sessionKey, {
159
- config,
160
- sessionId: parsed.sessionId,
161
- taskId: parsed.taskId,
162
- messageId: parsed.messageId,
163
- agentId: route.accountId,
164
- deviceType,
165
- });
166
- // 🔑 发送初始状态更新(第二条消息也要发,用新taskId)
167
- logger.log(`[STATUS] Sending initial status update for session ${parsed.sessionId}`);
168
- void sendStatusUpdate({
169
- config,
170
- sessionId: parsed.sessionId,
171
- taskId: parsed.taskId,
172
- messageId: parsed.messageId,
173
- text: isSecondMessage ? "新消息已接收,正在处理..." : "任务正在处理中,请稍候~",
174
- state: "working",
175
- }).catch((err) => {
176
- logger.error(`Failed to send initial status update:`, err);
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
- if (route.sessionKey && textForAgent) {
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,36 @@ export async function handleXYMessage(params) {
197
197
  logger.error(`[SELF_EVOLUTION] Failed to append inline keyword nudge: ${String(selfEvolutionError)}`);
198
198
  }
199
199
  }
200
- const fileParts = extractFileParts(parsed.parts);
201
- // Download files to local disk
202
- const downloadedFiles = await downloadFilesFromParts(fileParts);
203
- logger.log("Downloaded files:", JSON.stringify(downloadedFiles, null, 2));
204
- const mediaPayload = buildXYMediaPayload(downloadedFiles);
200
+ // 🔑 Steer消息: 跳过旧路径直接进入 streaming-signal 队列
201
+ // /steer 前缀由 dispatchSteerWhenReady 内部添加
202
+ if (isUpdate) {
203
+ logger.log(`[BOT] 🔄 Steer message — enqueuing to streaming-signal queue`);
204
+ await enqueueSteer({
205
+ sessionId: parsed.sessionId,
206
+ sessionKey: route.sessionKey,
207
+ steerText: textForAgent, // 原始文本,不带 /steer 前缀
208
+ cfg,
209
+ runtime,
210
+ parsed,
211
+ route,
212
+ deviceType,
213
+ });
214
+ logger.log(`[BOT] ✅ Steer queue completed for session: ${parsed.sessionId}`);
215
+ logger.log(`xy: dispatch complete (session=${parsed.sessionId})`);
216
+ return;
217
+ }
218
+ // ── First message (non-steer) path below ──────────────────────
219
+ // 🔑 立即创建 streaming 信号——必须在文件下载等耗时操作之前,
220
+ // 否则 steer 消息的 dispatchSteerWhenReady 会找不到信号而跳过等待。
221
+ createStreamingSignal(parsed.sessionId);
222
+ // File download — only for real user messages, steer injections have no files
223
+ let mediaPayload = {};
224
+ if (!skipReg) {
225
+ const fileParts = extractFileParts(parsed.parts);
226
+ const downloadedFiles = await downloadFilesFromParts(fileParts);
227
+ logger.log("Downloaded files:", JSON.stringify(downloadedFiles, null, 2));
228
+ mediaPayload = buildXYMediaPayload(downloadedFiles);
229
+ }
205
230
  // Resolve envelope format options (following feishu pattern)
206
231
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
207
232
  // Build message body with speaker prefix (following feishu pattern)
@@ -242,7 +267,9 @@ export async function handleXYMessage(params) {
242
267
  ReplyToBody: undefined, // A2A protocol doesn't support reply/quote
243
268
  ...mediaPayload,
244
269
  });
245
- // 🔑 创建dispatcher(dispatcher会自动使用动态taskId)
270
+ // 🔑 Streaming 信号已在上方创建(在文件下载之前)
271
+ const steerState = { steered: false };
272
+ // 🔑 创建dispatcher
246
273
  logger.log(`[BOT-DISPATCHER] 🎯 Creating reply dispatcher`);
247
274
  logger.log(`[BOT-DISPATCHER] - taskId: ${parsed.taskId}`);
248
275
  const { dispatcher, replyOptions, markDispatchIdle, startStatusInterval } = createXYReplyDispatcher({
@@ -252,13 +279,11 @@ export async function handleXYMessage(params) {
252
279
  taskId: parsed.taskId,
253
280
  messageId: parsed.messageId,
254
281
  accountId: route.accountId,
255
- isSteerFollower: isSecondMessage, // 🔑 标记第二条消息
282
+ steerState,
256
283
  });
257
- // 🔑 只有第一条消息启动状态定时器
258
- // 第二条消息会很快返回,不需要定时器
259
- if (!isSecondMessage) {
284
+ // Steer injections don't need status intervals
285
+ if (!skipReg) {
260
286
  startStatusInterval();
261
- logger.log(`[BOT-DISPATCHER] ✅ Status interval started for first message`);
262
287
  }
263
288
  // Build session context for AsyncLocalStorage
264
289
  const sessionContext = {
@@ -274,15 +299,13 @@ export async function handleXYMessage(params) {
274
299
  dispatcher,
275
300
  onSettled: () => {
276
301
  logger.log(`[BOT] 🏁 onSettled called for session: ${route.sessionKey}`);
277
- logger.log(`[BOT] - isSecondMessage: ${isSecondMessage}`);
278
- // 🔑 减少引用计数
279
- decrementTaskIdRef(parsed.sessionId);
280
- // 🔑 如果是第一条消息完成,解锁
281
- if (!isSecondMessage) {
282
- unlockTaskId(parsed.sessionId);
283
- logger.log(`[BOT] 🔓 Unlocked taskId (first message completed)`);
302
+ logger.log(`[BOT] - steered: ${steerState.steered}`);
303
+ // 🔑 When steered, skip heavy cleanup — the first message's dispatcher is still running
304
+ if (steerState.steered) {
305
+ logger.log(`[BOT] Steered dispatch settled (skipping cleanup)`);
306
+ return;
284
307
  }
285
- // 减少session引用计数
308
+ decrementTaskIdRef(parsed.sessionId);
286
309
  unregisterSession(route.sessionKey);
287
310
  logger.log(`[BOT] ✅ Cleanup completed`);
288
311
  },
@@ -339,7 +362,6 @@ export async function handleXYMessage(params) {
339
362
  logger.log(`[BOT] 🧹 Cleaning up after error: ${sessionId}`);
340
363
  // 清理 taskId
341
364
  decrementTaskIdRef(sessionId);
342
- unlockTaskId(sessionId);
343
365
  // 清理 session
344
366
  const core = getXYRuntime();
345
367
  const route = core.channel.routing.resolveAgentRoute({
@@ -379,3 +401,158 @@ function buildXYMediaPayload(mediaList) {
379
401
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
380
402
  };
381
403
  }
404
+ // Use globalThis to survive module deduplication — provider.ts may load a
405
+ // different copy of bot.ts, so a plain module-level Map would be two objects.
406
+ const _g = globalThis;
407
+ if (!_g.__xyStreamingSignals)
408
+ _g.__xyStreamingSignals = new Map();
409
+ if (!_g.__xySteerQueues)
410
+ _g.__xySteerQueues = new Map();
411
+ const streamingSignals = _g.__xyStreamingSignals;
412
+ const steerQueues = _g.__xySteerQueues;
413
+ /**
414
+ * 由 provider.ts 在 wrapStreamFn 调用时触发。
415
+ * 这是模型 API 被调用的精确时刻,此时 isStreaming 一定为 true。
416
+ */
417
+ export function notifyModelStreaming(sessionId) {
418
+ const signal = streamingSignals.get(sessionId);
419
+ if (signal) {
420
+ streamingSignals.delete(sessionId);
421
+ signal.notify();
422
+ logger.log(`[STEER-QUEUE] 📡 Model streaming signal fired for session=${sessionId}`);
423
+ }
424
+ }
425
+ function createStreamingSignal(sessionId) {
426
+ let resolve;
427
+ const promise = new Promise(r => { resolve = r; });
428
+ const signal = { promise, notify: resolve };
429
+ streamingSignals.set(sessionId, signal);
430
+ logger.log(`[STEER-QUEUE] 🟢 Streaming signal created for session ${sessionId}`);
431
+ return signal;
432
+ }
433
+ /**
434
+ * 将 steer 消息放入 per-session 串行队列。
435
+ * 等待第一条消息的 streaming 信号(deliver 首次触发),然后 dispatch。
436
+ * 多个 steer 按到达顺序串行处理,无需重试。
437
+ */
438
+ function enqueueSteer(params) {
439
+ const { sessionId } = params;
440
+ // 取出当前队列尾部(或 undefined),然后链上新的 Promise
441
+ const prev = steerQueues.get(sessionId);
442
+ const next = (prev ?? Promise.resolve()).then(() => dispatchSteerWhenReady(params));
443
+ steerQueues.set(sessionId, next);
444
+ // 链条结束后清理
445
+ next.catch(() => { }).finally(() => {
446
+ if (steerQueues.get(sessionId) === next) {
447
+ steerQueues.delete(sessionId);
448
+ }
449
+ });
450
+ return next;
451
+ }
452
+ async function dispatchSteerWhenReady(params) {
453
+ const { sessionId, sessionKey, steerText } = params;
454
+ // 1. 等待第一条消息开始 streaming
455
+ // signal 可能尚未创建(第一条消息还在文件下载等耗时操作中),
456
+ // 轮询等待直到 signal 出现,最長等待 ~5 秒。
457
+ let signal = streamingSignals.get(sessionId);
458
+ if (!signal) {
459
+ logger.log(`[STEER-QUEUE] ⏳ Signal not yet created, polling for session=${sessionId}`);
460
+ for (let i = 0; i < 50; i++) {
461
+ await new Promise(r => setTimeout(r, 100));
462
+ signal = streamingSignals.get(sessionId);
463
+ if (signal)
464
+ break;
465
+ if (!hasActiveTask(sessionId)) {
466
+ logger.log(`[STEER-QUEUE] ℹ️ First message completed while waiting, skip steer`);
467
+ return;
468
+ }
469
+ }
470
+ }
471
+ if (signal) {
472
+ logger.log(`[STEER-QUEUE] ⏳ Waiting for streaming signal, session=${sessionId}`);
473
+ await signal.promise;
474
+ streamingSignals.delete(sessionId);
475
+ logger.log(`[STEER-QUEUE] ✅ Streaming signal received, session=${sessionId}`);
476
+ }
477
+ else {
478
+ logger.log(`[STEER-QUEUE] ⚠️ Signal never appeared, proceeding without wait`);
479
+ }
480
+ // 2. 第一条消息已结束 → 放弃
481
+ if (!hasActiveTask(sessionId)) {
482
+ logger.log(`[STEER-QUEUE] ℹ️ First message completed, skip steer`);
483
+ return;
484
+ }
485
+ // 3. 构建 dispatch 上下文并 dispatch /steer
486
+ const core = getXYRuntime();
487
+ const speaker = sessionId;
488
+ const steerCommand = `/steer ${steerText}`;
489
+ const messageBody = `${speaker}: ${steerCommand}`;
490
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(params.cfg);
491
+ const body = core.channel.reply.formatAgentEnvelope({
492
+ channel: "xiaoyi-channel",
493
+ from: speaker,
494
+ timestamp: new Date(),
495
+ envelope: envelopeOptions,
496
+ body: messageBody,
497
+ });
498
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
499
+ Body: body,
500
+ RawBody: steerCommand,
501
+ CommandBody: steerCommand,
502
+ From: sessionId,
503
+ To: sessionId,
504
+ SessionKey: params.route.sessionKey,
505
+ AccountId: params.route.accountId,
506
+ ChatType: "direct",
507
+ GroupSubject: undefined,
508
+ SenderName: sessionId,
509
+ SenderId: sessionId,
510
+ Provider: "xiaoyi-channel",
511
+ Surface: "xiaoyi-channel",
512
+ MessageSid: `${params.parsed.taskId}_${params.deviceType}`,
513
+ Timestamp: Date.now(),
514
+ WasMentioned: false,
515
+ CommandAuthorized: true,
516
+ OriginatingChannel: "xiaoyi-channel",
517
+ OriginatingTo: sessionId,
518
+ ReplyToBody: undefined,
519
+ });
520
+ const steerState = { steered: true };
521
+ const { dispatcher, replyOptions } = createXYReplyDispatcher({
522
+ cfg: params.cfg,
523
+ runtime: params.runtime,
524
+ sessionId,
525
+ taskId: params.parsed.taskId,
526
+ messageId: params.parsed.messageId,
527
+ accountId: params.route.accountId,
528
+ steerState,
529
+ });
530
+ const sessionContext = {
531
+ config: resolveXYConfig(params.cfg),
532
+ sessionId,
533
+ taskId: params.parsed.taskId,
534
+ messageId: params.parsed.messageId,
535
+ agentId: params.route.accountId,
536
+ deviceType: params.deviceType,
537
+ };
538
+ logger.log(`[STEER-QUEUE] 🚀 Dispatching steer for session=${sessionId}`);
539
+ await core.channel.reply.withReplyDispatcher({
540
+ dispatcher,
541
+ onSettled: () => {
542
+ logger.log(`[STEER-QUEUE] 🏁 Steer dispatch settled for session=${sessionId}`);
543
+ },
544
+ run: () => {
545
+ return runWithSessionContext(sessionContext, async () => {
546
+ const result = await core.channel.reply.dispatchReplyFromConfig({
547
+ ctx: ctxPayload,
548
+ cfg: params.cfg,
549
+ dispatcher,
550
+ replyOptions,
551
+ });
552
+ logger.log(`[STEER-QUEUE] dispatch result: ${JSON.stringify(result)}`);
553
+ return result;
554
+ });
555
+ },
556
+ });
557
+ logger.log(`[STEER-QUEUE] ✅ Steer dispatch completed for session=${sessionId}`);
558
+ }
@@ -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
- export async function callCsplApi(questionText, cfg) {
47
- const config = getCsplConfig(cfg);
48
- const headers = buildHeaders(config);
49
- const payload = {
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 options = buildRequestOptions(config.api.url, headers, config.api.timeout);
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] ⏰ 请求超时 (${config.api.timeout}ms)`);
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: ClawdbotConfig): CsplConfig;
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;
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentToolResultMiddleware } from "openclaw/plugin-sdk/agent-harness-runtime";
2
+ /**
3
+ * Create the CSPL AgentToolResultMiddleware.
4
+ *
5
+ * Gets XYChannelConfig from session context (via sessionKey) to initialize
6
+ * the CSPL API config on first call, then caches it for subsequent calls.
7
+ */
8
+ export declare function createCsplMiddleware(): AgentToolResultMiddleware;
@@ -0,0 +1,90 @@
1
+ // CSPL AgentToolResultMiddleware
2
+ // Replaces the after_tool_call hook with a middleware that intercepts tool results
3
+ // BEFORE they reach the LLM, enabling true security interruption.
4
+ import { callCsplApiWithConfig } from "./call-api.js";
5
+ import { getCsplConfig, initCsplConfigFromXYConfig } from "./config.js";
6
+ import { ALLOWED_TOOLS, MAX_TEXT_LENGTH, MAX_TOTAL_LENGTH, MIN_TEXT_LENGTH, STEER_ABORT_MESSAGE, } from "./constants.js";
7
+ import { parseSecurityResult, processText, validateAndTruncateText, } from "./utils.js";
8
+ import { getSessionContext } from "../tools/session-manager.js";
9
+ import { logger } from "../utils/logger.js";
10
+ /**
11
+ * Extract text content from an OpenClawAgentToolResult.
12
+ */
13
+ function extractMiddlewareResultText(event) {
14
+ const result = event.result;
15
+ if (!result?.content || !Array.isArray(result.content)) {
16
+ return "";
17
+ }
18
+ const texts = [];
19
+ // Special handling for web_fetch: text is in details.text
20
+ if (event.toolName === "web_fetch" && result.details?.text) {
21
+ texts.push(String(result.details.text));
22
+ }
23
+ else {
24
+ for (const item of result.content) {
25
+ if (item?.type === "text" && typeof item.text === "string") {
26
+ texts.push(item.text);
27
+ }
28
+ }
29
+ }
30
+ return texts.length > 0 ? texts.join("; ") : "";
31
+ }
32
+ /**
33
+ * Create the CSPL AgentToolResultMiddleware.
34
+ *
35
+ * Gets XYChannelConfig from session context (via sessionKey) to initialize
36
+ * the CSPL API config on first call, then caches it for subsequent calls.
37
+ */
38
+ export function createCsplMiddleware() {
39
+ return async (event, ctx) => {
40
+ if (!ALLOWED_TOOLS.includes(event.toolName)) {
41
+ return;
42
+ }
43
+ try {
44
+ const resultText = extractMiddlewareResultText(event);
45
+ const resultLength = resultText.length;
46
+ if (resultLength <= MIN_TEXT_LENGTH || resultLength > MAX_TOTAL_LENGTH) {
47
+ return;
48
+ }
49
+ logger.log(`[CSPL MIDDLEWARE] Scanning tool result: toolName=${event.toolName}, textLength=${resultLength}`);
50
+ // Build CSPL request payload
51
+ const questionText = {
52
+ subSceneID: "TOOL_OUTPUT",
53
+ tool: event.toolName,
54
+ output: [{ content: "" }],
55
+ };
56
+ const originText = processText(resultText);
57
+ questionText.output[0].content = originText;
58
+ let finalJson = JSON.stringify(questionText);
59
+ if (finalJson.length > MAX_TEXT_LENGTH) {
60
+ const diff = finalJson.length - MAX_TEXT_LENGTH;
61
+ const { text: trimmed } = validateAndTruncateText(originText, MAX_TEXT_LENGTH - diff);
62
+ questionText.output[0].content = trimmed;
63
+ finalJson = JSON.stringify(questionText);
64
+ }
65
+ // Get CSPL config (cached after first call)
66
+ // Try session context first (XYChannelConfig), then fall back to cached config
67
+ const sessionCtx = getSessionContext(ctx.sessionKey ?? "");
68
+ const csplConfig = sessionCtx
69
+ ? initCsplConfigFromXYConfig(sessionCtx.config)
70
+ : getCsplConfig();
71
+ const csplStartTime = Date.now();
72
+ const response = await callCsplApiWithConfig(finalJson, csplConfig);
73
+ const csplElapsed = Date.now() - csplStartTime;
74
+ const result = parseSecurityResult(response);
75
+ logger.log(`[CSPL MIDDLEWARE] Security result: status=${result.status}, toolName=${event.toolName}, elapsed=${csplElapsed}ms`);
76
+ if (result.status === "REJECT") {
77
+ logger.log(`[CSPL MIDDLEWARE] REJECT - replacing tool result with security message`);
78
+ return {
79
+ result: {
80
+ content: [{ type: "text", text: STEER_ABORT_MESSAGE }],
81
+ details: {},
82
+ },
83
+ };
84
+ }
85
+ }
86
+ catch (err) {
87
+ logger.error(`[CSPL MIDDLEWARE] Error: ${err}`);
88
+ }
89
+ };
90
+ }
@@ -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
+ }
@@ -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
  }
@@ -6,7 +6,9 @@ export interface CreateXYReplyDispatcherParams {
6
6
  taskId: string;
7
7
  messageId: string;
8
8
  accountId: string;
9
- isSteerFollower?: boolean;
9
+ steerState: {
10
+ steered: boolean;
11
+ };
10
12
  }
11
13
  /**
12
14
  * 清理 /tmp/xy_channel 目录中超过 24 小时的旧文件
@@ -45,10 +45,9 @@ export async function cleanupStaleTempFiles(tempDir = "/tmp/xy_channel") {
45
45
  * Runtime is expected to be validated before calling this function.
46
46
  */
47
47
  export function createXYReplyDispatcher(params) {
48
- const { cfg, runtime, sessionId, taskId, messageId, accountId, isSteerFollower } = params;
48
+ const { cfg, runtime, sessionId, taskId, messageId, accountId, steerState } = params;
49
49
  logger.log(`[DISPATCHER-CREATE] ******* Creating dispatcher *******`);
50
50
  logger.log(`[DISPATCHER-CREATE] - taskId: ${taskId}`);
51
- logger.log(`[DISPATCHER-CREATE] - isSteerFollower: ${isSteerFollower ?? false}`);
52
51
  // 初始taskId和messageId(作为fallback)
53
52
  const initialTaskId = taskId;
54
53
  const initialMessageId = messageId;
@@ -111,9 +110,14 @@ export function createXYReplyDispatcher(params) {
111
110
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, accountId),
112
111
  onReplyStart: () => {
113
112
  const currentTaskId = getActiveTaskId();
114
- logger.log(`[REPLY START] Reply started for session ${sessionId}, taskId=${currentTaskId}, isSteerFollower=${isSteerFollower}`);
113
+ logger.log(`[REPLY START] Reply started for session ${sessionId}, taskId=${currentTaskId}, steered=${steerState.steered}`);
115
114
  },
116
115
  deliver: async (payload, info) => {
116
+ // 🔑 steered dispatch不发送内容(让主dispatcher处理)
117
+ if (steerState.steered) {
118
+ logger.log(`[DELIVER] Steered dispatch - skipping deliver, info.kind=${info?.kind}`);
119
+ return;
120
+ }
117
121
  const text = payload.text ?? "";
118
122
  const currentTaskId = getActiveTaskId();
119
123
  const currentMessageId = getActiveMessageId();
@@ -143,9 +147,9 @@ export function createXYReplyDispatcher(params) {
143
147
  onError: async (err, info) => {
144
148
  runtime.error?.(`xy: ${info.kind} reply failed: ${String(err)}`);
145
149
  stopStatusInterval();
146
- // 🔑 steer follower不发送错误状态(让主dispatcher处理)
147
- if (isSteerFollower) {
148
- logger.log(`[ON_ERROR] Steer follower - skipping error response`);
150
+ // 🔑 steered dispatcher不发送错误状态(让主dispatcher处理)
151
+ if (steerState.steered) {
152
+ logger.log(`[ON_ERROR] Steered dispatch - skipping error response`);
149
153
  return;
150
154
  }
151
155
  if (!hasSentResponse) {
@@ -172,17 +176,16 @@ export function createXYReplyDispatcher(params) {
172
176
  logger.log(`[ON_IDLE] Reply idle`);
173
177
  logger.log(`[ON_IDLE] - sessionId: ${sessionId}`);
174
178
  logger.log(`[ON_IDLE] - taskId: ${currentTaskId}`);
175
- logger.log(`[ON_IDLE] - isSteerFollower: ${isSteerFollower}`);
179
+ logger.log(`[ON_IDLE] - steered: ${steerState.steered}`);
176
180
  logger.log(`[ON_IDLE] - hasSentResponse: ${hasSentResponse}`);
177
181
  logger.log(`[ON_IDLE] - finalSent: ${finalSent}`);
178
- // 🔑 核心改动:steer follower不发送final响应
179
- if (isSteerFollower) {
180
- logger.log(`[ON_IDLE] Steer follower - skipping final response`);
181
- logger.log(`[ON_IDLE] - Message queued successfully, waiting for primary dispatcher`);
182
+ // 🔑 steered dispatch不发送final响应(核心已注入到活跃 Pi run)
183
+ if (steerState.steered) {
184
+ logger.log(`[ON_IDLE] Steered dispatch - skipping final response`);
182
185
  stopStatusInterval();
183
186
  return; // ← 直接返回,不发送任何东西!
184
187
  }
185
- // 正常模式(或steer的第一条消息)
188
+ // 正常模式(或未被steer的dispatch)
186
189
  if (hasSentResponse && !finalSent) {
187
190
  logger.log(`[ON_IDLE] Sending accumulated text, length=${accumulatedText.length}`);
188
191
  try {
@@ -214,7 +217,7 @@ export function createXYReplyDispatcher(params) {
214
217
  }
215
218
  }
216
219
  else {
217
- // 正常失败场景(非steer follower
220
+ // 正常失败场景(非steered
218
221
  logger.log(`[ON_IDLE] Skipping final message: hasSentResponse=${hasSentResponse}, finalSent=${finalSent}`);
219
222
  try {
220
223
  await sendStatusUpdate({
@@ -248,7 +251,7 @@ export function createXYReplyDispatcher(params) {
248
251
  },
249
252
  onCleanup: () => {
250
253
  const currentTaskId = getActiveTaskId();
251
- logger.log(`[ON_CLEANUP] Reply cleanup, taskId=${currentTaskId}, isSteerFollower=${isSteerFollower}`);
254
+ logger.log(`[ON_CLEANUP] Reply cleanup, taskId=${currentTaskId}, steered=${steerState.steered}`);
252
255
  },
253
256
  });
254
257
  return {
@@ -257,8 +260,8 @@ export function createXYReplyDispatcher(params) {
257
260
  ...replyOptions,
258
261
  onModelSelected: prefixContext.onModelSelected,
259
262
  onToolStart: async ({ name, phase }) => {
260
- // 🔑 steer follower不发送tool状态(让主dispatcher处理)
261
- if (isSteerFollower) {
263
+ // 🔑 steered dispatch不发送tool状态(让主dispatcher处理)
264
+ if (steerState.steered) {
262
265
  return;
263
266
  }
264
267
  const currentTaskId = getActiveTaskId();
@@ -289,8 +292,8 @@ export function createXYReplyDispatcher(params) {
289
292
  }
290
293
  },
291
294
  onToolResult: async (payload) => {
292
- // 🔑 steer follower不发送tool结果(让主dispatcher处理)
293
- if (isSteerFollower) {
295
+ // 🔑 steered dispatch不发送tool结果(让主dispatcher处理)
296
+ if (steerState.steered) {
294
297
  return;
295
298
  }
296
299
  const currentTaskId = getActiveTaskId();
@@ -317,8 +320,8 @@ export function createXYReplyDispatcher(params) {
317
320
  }
318
321
  },
319
322
  onReasoningStream: async (payload) => {
320
- // 🔑 steer follower不发送reasoning stream
321
- if (isSteerFollower) {
323
+ // 🔑 steered dispatch不发送reasoning stream
324
+ if (steerState.steered) {
322
325
  return;
323
326
  }
324
327
  const text = payload.text ?? "";
@@ -327,8 +330,8 @@ export function createXYReplyDispatcher(params) {
327
330
  // 如果需要可以启用
328
331
  },
329
332
  onPartialReply: async (payload) => {
330
- // 🔑 steer follower不发送partial reply(让主dispatcher处理)
331
- if (isSteerFollower) {
333
+ // 🔑 steered dispatch不发送partial reply(让主dispatcher处理)
334
+ if (steerState.steered) {
332
335
  return;
333
336
  }
334
337
  const currentTaskId = getActiveTaskId();
@@ -2,36 +2,17 @@ interface TaskIdBinding {
2
2
  sessionId: string;
3
3
  currentTaskId: string;
4
4
  currentMessageId: string;
5
- refCount: number;
6
5
  updatedAt: number;
7
- locked: boolean;
8
6
  }
9
7
  /**
10
- * 注册或更新session的活跃taskId
11
- * 返回是否是更新(用于判断是否是第二条消息)
8
+ * 注册或更新session的活跃taskId
9
+ * Returns true if this was an update (session already had an active task).
12
10
  */
13
- export declare function registerTaskId(sessionId: string, taskId: string, messageId: string, options?: {
14
- incrementRef?: boolean;
15
- }): {
16
- isUpdate: boolean;
17
- refCount: number;
18
- };
11
+ export declare function registerTaskId(sessionId: string, taskId: string, messageId: string): boolean;
19
12
  /**
20
- * 增加引用计数(消息开始处理时调用)
21
- */
22
- export declare function incrementTaskIdRef(sessionId: string): void;
23
- /**
24
- * 减少引用计数,当refCount=0时才真正清理
13
+ * 移除session的活跃taskId(消息处理完成时调用)。
25
14
  */
26
15
  export declare function decrementTaskIdRef(sessionId: string): void;
27
- /**
28
- * 锁定taskId,防止被清理(第一个消息使用)
29
- */
30
- export declare function lockTaskId(sessionId: string): void;
31
- /**
32
- * 解锁taskId(第一个消息完成时使用)
33
- */
34
- export declare function unlockTaskId(sessionId: string): void;
35
16
  /**
36
17
  * 获取session的当前活跃taskId
37
18
  */
@@ -44,10 +25,6 @@ export declare function getCurrentMessageId(sessionId: string): string | null;
44
25
  * 检查session是否有活跃的taskId
45
26
  */
46
27
  export declare function hasActiveTask(sessionId: string): boolean;
47
- /**
48
- * 获取完整的binding信息(用于调试)
49
- */
50
- export declare function getTaskIdBinding(sessionId: string): TaskIdBinding | null;
51
28
  /**
52
29
  * 获取所有活跃的 task bindings(用于 gateway_stop 通知等场景)
53
30
  */
@@ -1,9 +1,9 @@
1
1
  // TaskId Manager - 管理session级别的活跃taskId
2
- // 支持动态切换taskId,用于steer模式下的消息插队
2
+ // 用于 monitor.ts 检测活跃任务(决定是否并发执行steer消息)
3
3
  import { logger } from "./utils/logger.js";
4
4
  /**
5
5
  * Session到活跃TaskId的映射
6
- * Key: sessionId (注意:这里用sessionId,不是sessionKey)
6
+ * Key: sessionId
7
7
  * Value: TaskIdBinding
8
8
  * Uses globalThis to ensure a single Map across all module copies.
9
9
  */
@@ -13,98 +13,39 @@ if (!_g.__xyActiveTaskIds) {
13
13
  }
14
14
  const activeTaskIds = _g.__xyActiveTaskIds;
15
15
  /**
16
- * 注册或更新session的活跃taskId
17
- * 返回是否是更新(用于判断是否是第二条消息)
16
+ * 注册或更新session的活跃taskId
17
+ * Returns true if this was an update (session already had an active task).
18
18
  */
19
- export function registerTaskId(sessionId, taskId, messageId, options) {
19
+ export function registerTaskId(sessionId, taskId, messageId) {
20
20
  logger.log(`[TASK_MANAGER] 📝 Registering/Updating taskId for session: ${sessionId}`);
21
- logger.log(`[TASK_MANAGER] - New taskId: ${taskId}`);
22
- logger.log(`[TASK_MANAGER] - New messageId: ${messageId}`);
23
- logger.log(`[TASK_MANAGER] - incrementRef: ${options?.incrementRef ?? false}`);
21
+ logger.log(`[TASK_MANAGER] - taskId: ${taskId}`);
24
22
  const existing = activeTaskIds.get(sessionId);
25
23
  if (existing) {
26
24
  logger.log(`[TASK_MANAGER] - Previous taskId: ${existing.currentTaskId}`);
27
- logger.log(`[TASK_MANAGER] - Previous refCount: ${existing.refCount}`);
28
- logger.log(`[TASK_MANAGER] - 🔄 Switching taskId (steer mode detected)`);
29
- // 更新taskId,但保持引用计数
25
+ logger.log(`[TASK_MANAGER] - 🔄 Updating taskId`);
30
26
  existing.currentTaskId = taskId;
31
27
  existing.currentMessageId = messageId;
32
28
  existing.updatedAt = Date.now();
33
- if (options?.incrementRef) {
34
- existing.refCount++;
35
- logger.log(`[TASK_MANAGER] - Incremented refCount: ${existing.refCount}`);
36
- }
37
- logger.log(`[TASK_MANAGER] - ✅ TaskId updated, refCount=${existing.refCount}`);
38
- return { isUpdate: true, refCount: existing.refCount };
29
+ return true; // isUpdate
39
30
  }
40
31
  else {
41
- // 新注册
42
32
  const binding = {
43
33
  sessionId,
44
34
  currentTaskId: taskId,
45
35
  currentMessageId: messageId,
46
- refCount: 1,
47
36
  updatedAt: Date.now(),
48
- locked: false,
49
37
  };
50
38
  activeTaskIds.set(sessionId, binding);
51
- logger.log(`[TASK_MANAGER] - ✅ TaskId registered (new), refCount=1`);
52
- return { isUpdate: false, refCount: 1 };
39
+ logger.log(`[TASK_MANAGER] - ✅ TaskId registered (new)`);
40
+ return false;
53
41
  }
54
42
  }
55
43
  /**
56
- * 增加引用计数(消息开始处理时调用)
57
- */
58
- export function incrementTaskIdRef(sessionId) {
59
- const binding = activeTaskIds.get(sessionId);
60
- if (binding) {
61
- binding.refCount++;
62
- logger.log(`[TASK_MANAGER] ➕ Incremented refCount for ${sessionId}: ${binding.refCount}`);
63
- }
64
- }
65
- /**
66
- * 减少引用计数,当refCount=0时才真正清理
44
+ * 移除session的活跃taskId(消息处理完成时调用)。
67
45
  */
68
46
  export function decrementTaskIdRef(sessionId) {
69
- const binding = activeTaskIds.get(sessionId);
70
- if (!binding) {
71
- logger.log(`[TASK_MANAGER] ⚠️ No binding found for ${sessionId}`);
72
- return;
73
- }
74
- binding.refCount--;
75
- logger.log(`[TASK_MANAGER] ➖ Decremented refCount for ${sessionId}: ${binding.refCount}`);
76
- if (binding.refCount <= 0 && !binding.locked) {
77
- logger.log(`[TASK_MANAGER] 🗑️ RefCount=0 and unlocked, clearing taskId`);
78
- activeTaskIds.delete(sessionId);
79
- }
80
- else {
81
- logger.log(`[TASK_MANAGER] - Keeping binding (refCount=${binding.refCount}, locked=${binding.locked})`);
82
- }
83
- }
84
- /**
85
- * 锁定taskId,防止被清理(第一个消息使用)
86
- */
87
- export function lockTaskId(sessionId) {
88
- const binding = activeTaskIds.get(sessionId);
89
- if (binding) {
90
- binding.locked = true;
91
- logger.log(`[TASK_MANAGER] 🔒 Locked taskId for ${sessionId}`);
92
- }
93
- }
94
- /**
95
- * 解锁taskId(第一个消息完成时使用)
96
- */
97
- export function unlockTaskId(sessionId) {
98
- const binding = activeTaskIds.get(sessionId);
99
- if (binding) {
100
- binding.locked = false;
101
- logger.log(`[TASK_MANAGER] 🔓 Unlocked taskId for ${sessionId}`);
102
- // 解锁后,如果refCount=0,立即清理
103
- if (binding.refCount <= 0) {
104
- logger.log(`[TASK_MANAGER] 🗑️ Unlocked and refCount=0, clearing taskId`);
105
- activeTaskIds.delete(sessionId);
106
- }
107
- }
47
+ logger.log(`[TASK_MANAGER] 🗑️ Removing taskId for ${sessionId}`);
48
+ activeTaskIds.delete(sessionId);
108
49
  }
109
50
  /**
110
51
  * 获取session的当前活跃taskId
@@ -126,12 +67,6 @@ export function getCurrentMessageId(sessionId) {
126
67
  export function hasActiveTask(sessionId) {
127
68
  return activeTaskIds.has(sessionId);
128
69
  }
129
- /**
130
- * 获取完整的binding信息(用于调试)
131
- */
132
- export function getTaskIdBinding(sessionId) {
133
- return activeTaskIds.get(sessionId) ?? null;
134
- }
135
70
  /**
136
71
  * 获取所有活跃的 task bindings(用于 gateway_stop 通知等场景)
137
72
  */
@@ -107,7 +107,7 @@ export function createLoginTokenTool(ctx) {
107
107
  // (3) Found valid token
108
108
  const code = match.code ?? "";
109
109
  let resultText;
110
- if (code === "0") {
110
+ if (code === "0" || code === "") {
111
111
  resultText = "获取用户授权成功";
112
112
  }
113
113
  else if (code === "400") {
@@ -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; // 1 hour
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
@@ -3,6 +3,9 @@
3
3
  "channels": ["xiaoyi-channel"],
4
4
  "providers": ["xiaoyiprovider"],
5
5
  "providerDiscoveryEntry": "./dist/provider-discovery.js",
6
+ "contracts": {
7
+ "agentToolResultMiddleware": ["pi"]
8
+ },
6
9
  "skills": [],
7
10
  "configSchema": {
8
11
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.135-beta",
3
+ "version": "0.0.135-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",