@ynhcj/xiaoyi-channel 0.0.131-beta → 0.0.132-beta
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/src/bot.d.ts +2 -0
- package/dist/src/bot.js +37 -29
- package/dist/src/monitor.js +13 -0
- package/dist/src/provider.js +70 -50
- package/dist/src/tools/session-helper.d.ts +24 -0
- package/dist/src/tools/session-helper.js +45 -0
- package/dist/src/tools/session-manager.d.ts +8 -0
- package/dist/src/tools/session-manager.js +2 -2
- package/dist/src/xy-session-store.d.ts +79 -0
- package/dist/src/xy-session-store.js +153 -0
- package/package.json +1 -1
package/dist/src/bot.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface HandleXYMessageParams {
|
|
|
9
9
|
message: A2AJsonRpcRequest;
|
|
10
10
|
accountId: string;
|
|
11
11
|
webSocketSessionId?: string;
|
|
12
|
+
/** Called after dispatch init is complete (agentTools/wrapStreamFn done). */
|
|
13
|
+
onInitComplete?: () => void;
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
16
|
* Handle an incoming A2A message.
|
package/dist/src/bot.js
CHANGED
|
@@ -236,7 +236,7 @@ export async function handleXYMessage(params) {
|
|
|
236
236
|
SenderId: parsed.sessionId,
|
|
237
237
|
Provider: "xiaoyi-channel",
|
|
238
238
|
Surface: "xiaoyi-channel",
|
|
239
|
-
MessageSid: parsed.
|
|
239
|
+
MessageSid: `${parsed.taskId}_${deviceType}`,
|
|
240
240
|
Timestamp: Date.now(),
|
|
241
241
|
WasMentioned: false,
|
|
242
242
|
CommandAuthorized: true,
|
|
@@ -289,34 +289,42 @@ export async function handleXYMessage(params) {
|
|
|
289
289
|
unregisterSession(route.sessionKey);
|
|
290
290
|
log(`[BOT] ✅ Cleanup completed`);
|
|
291
291
|
},
|
|
292
|
-
run: () =>
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
292
|
+
run: () => {
|
|
293
|
+
// 🔐 Use AsyncLocalStorage to provide session context to tools.
|
|
294
|
+
// runWithSessionContext returns after the sync part of dispatch
|
|
295
|
+
// (including agentTools + wrapStreamFn) has executed, so we
|
|
296
|
+
// signal init complete to release the global dispatch gate
|
|
297
|
+
// for the next session.
|
|
298
|
+
const dispatchPromise = runWithSessionContext(sessionContext, async () => {
|
|
299
|
+
log(`[BOT-DISPATCH] ⏳ dispatchReplyFromConfig starting...`);
|
|
300
|
+
log(`[BOT-DISPATCH] - sessionKey: ${ctxPayload.SessionKey}`);
|
|
301
|
+
log(`[BOT-DISPATCH] - provider: ${ctxPayload.Provider}`);
|
|
302
|
+
log(`[BOT-DISPATCH] - surface: ${ctxPayload.Surface}`);
|
|
303
|
+
log(`[BOT-DISPATCH] - from: ${ctxPayload.From}`);
|
|
304
|
+
log(`[BOT-DISPATCH] - body length: ${ctxPayload.Body?.length ?? 0}`);
|
|
305
|
+
try {
|
|
306
|
+
const result = await core.channel.reply.dispatchReplyFromConfig({
|
|
307
|
+
ctx: ctxPayload,
|
|
308
|
+
cfg,
|
|
309
|
+
dispatcher,
|
|
310
|
+
replyOptions,
|
|
311
|
+
});
|
|
312
|
+
log(`[BOT-DISPATCH] ✅ dispatchReplyFromConfig returned`);
|
|
313
|
+
log(`[BOT-DISPATCH] - result: ${JSON.stringify(result)}`);
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
catch (dispatchErr) {
|
|
317
|
+
error(`[BOT-DISPATCH] ❌ dispatchReplyFromConfig threw`);
|
|
318
|
+
error(`[BOT-DISPATCH] - error name: ${dispatchErr instanceof Error ? dispatchErr.name : "unknown"}`);
|
|
319
|
+
error(`[BOT-DISPATCH] - error message: ${String(dispatchErr)}`);
|
|
320
|
+
error(`[BOT-DISPATCH] - error stack: ${dispatchErr instanceof Error ? dispatchErr.stack?.slice(0, 500) : "N/A"}`);
|
|
321
|
+
throw dispatchErr;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
// Signal init complete — sync part (agentTools, wrapStreamFn) is done
|
|
325
|
+
params.onInitComplete?.();
|
|
326
|
+
return dispatchPromise;
|
|
327
|
+
},
|
|
320
328
|
});
|
|
321
329
|
log(`[BOT] ✅ Dispatcher completed for session: ${parsed.sessionId}`);
|
|
322
330
|
log(`xy: dispatch complete (session=${parsed.sessionId})`);
|
package/dist/src/monitor.js
CHANGED
|
@@ -68,6 +68,12 @@ export async function monitorXYProvider(opts = {}) {
|
|
|
68
68
|
const activeMessages = new Set();
|
|
69
69
|
// Create session queue for ordered message processing
|
|
70
70
|
const enqueue = createSessionQueue();
|
|
71
|
+
// Global gate that serializes dispatch initialization across sessions.
|
|
72
|
+
// When a new session starts dispatching, it acquires this gate and holds it
|
|
73
|
+
// until agent setup (agentTools + wrapStreamFn) is complete, then releases it.
|
|
74
|
+
// This prevents lastRegisteredKey races when multiple sessions initialize
|
|
75
|
+
// concurrently.
|
|
76
|
+
let globalDispatchInitGate = Promise.resolve();
|
|
71
77
|
// Health check interval
|
|
72
78
|
let healthCheckInterval = null;
|
|
73
79
|
return new Promise((resolve, reject) => {
|
|
@@ -83,6 +89,11 @@ export async function monitorXYProvider(opts = {}) {
|
|
|
83
89
|
}
|
|
84
90
|
activeMessages.add(messageKey);
|
|
85
91
|
const task = async () => {
|
|
92
|
+
// Wait for the previous session's init to complete (global gate),
|
|
93
|
+
// then acquire the gate for this session's init.
|
|
94
|
+
await globalDispatchInitGate;
|
|
95
|
+
let releaseGate;
|
|
96
|
+
globalDispatchInitGate = new Promise((r) => { releaseGate = r; });
|
|
86
97
|
try {
|
|
87
98
|
await handleXYMessage({
|
|
88
99
|
cfg,
|
|
@@ -90,10 +101,12 @@ export async function monitorXYProvider(opts = {}) {
|
|
|
90
101
|
message,
|
|
91
102
|
accountId, // ✅ Pass accountId ("default")
|
|
92
103
|
webSocketSessionId: sessionId, // ✅ 传递 WebSocket 层级的 sessionId
|
|
104
|
+
onInitComplete: () => releaseGate(),
|
|
93
105
|
});
|
|
94
106
|
}
|
|
95
107
|
catch (err) {
|
|
96
108
|
// ✅ Only log error, don't re-throw to prevent gateway restart
|
|
109
|
+
releaseGate();
|
|
97
110
|
error(`XY gateway: error handling message from ${serverId}: ${String(err)}`);
|
|
98
111
|
}
|
|
99
112
|
finally {
|
package/dist/src/provider.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// models.providers.xiaoyiprovider.models = [...]
|
|
10
10
|
import { createHash } from "crypto";
|
|
11
11
|
import { getCurrentSessionContext } from "./tools/session-manager.js";
|
|
12
|
-
import { getCurrentTaskId } from "./task-manager.js";
|
|
13
12
|
import { selfEvolutionManager } from "./utils/self-evolution-manager.js";
|
|
14
13
|
// ── Retry config ──────────────────────────────────────────────
|
|
15
14
|
const RETRY_DELAYS_MS = [10_000, 20_000, 40_000, 60_000, 60_000];
|
|
@@ -389,6 +388,22 @@ function trimUserMetadata(text) {
|
|
|
389
388
|
text = text.replace(/\n*Sender \(untrusted metadata\):\n```json\n[\s\S]*?\n```\n*/, "\n");
|
|
390
389
|
return text.replace(/\n{3,}/g, "\n\n");
|
|
391
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Extract A2A taskId and deviceType from Conversation info JSON.
|
|
393
|
+
* bot.ts stores them as MessageSid = "taskId_deviceType".
|
|
394
|
+
*/
|
|
395
|
+
function extractA2AFromConversationInfo(text) {
|
|
396
|
+
const match = text.match(/Conversation info \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
|
|
397
|
+
if (!match)
|
|
398
|
+
return null;
|
|
399
|
+
const msgIdMatch = match[1].match(/"message_id"\s*:\s*"([^"]+)"/);
|
|
400
|
+
if (!msgIdMatch)
|
|
401
|
+
return null;
|
|
402
|
+
const parts = msgIdMatch[1].split("_");
|
|
403
|
+
if (parts.length < 2)
|
|
404
|
+
return null;
|
|
405
|
+
return { taskId: parts[0], deviceType: parts[1] };
|
|
406
|
+
}
|
|
392
407
|
export const xiaoyiProvider = {
|
|
393
408
|
id: "xiaoyiprovider",
|
|
394
409
|
label: "Xiaoyi Provider",
|
|
@@ -443,17 +458,34 @@ export const xiaoyiProvider = {
|
|
|
443
458
|
const underlying = ctx.streamFn;
|
|
444
459
|
if (!underlying)
|
|
445
460
|
return underlying;
|
|
446
|
-
// Capture A2A sessionId at agent setup time for multi-session isolation.
|
|
447
|
-
// openclaw calls wrapStreamFn per-agent (per session), so this runs inside
|
|
448
|
-
// the correct runWithSessionContext() ALS scope. When multiple sessions are
|
|
449
|
-
// active concurrently, getCurrentSessionContext() may later return the WRONG
|
|
450
|
-
// session (lastRegisteredKey fallback). The captured sessionId lets us
|
|
451
|
-
// bypass that fallback and look up the correct taskId directly from
|
|
452
|
-
// task-manager.
|
|
453
|
-
const capturedA2ASessionId = getCurrentSessionContext()?.sessionId ?? null;
|
|
454
461
|
return async (model, context, options) => {
|
|
455
|
-
// 每次请求时从 ctx.extraParams 动态读取 header
|
|
456
462
|
const dynamicHeaders = {};
|
|
463
|
+
// ── Extract A2A taskId/deviceType from Conversation info ──
|
|
464
|
+
// bot.ts stores taskId_deviceType as MessageSid, which the framework
|
|
465
|
+
// renders as message_id in the Conversation info JSON block.
|
|
466
|
+
let extractedTaskId = null;
|
|
467
|
+
let extractedDeviceType = null;
|
|
468
|
+
if (context.messages) {
|
|
469
|
+
for (let i = context.messages.length - 1; i >= 0; i--) {
|
|
470
|
+
const msg = context.messages[i];
|
|
471
|
+
if (msg.role !== "user")
|
|
472
|
+
continue;
|
|
473
|
+
const text = typeof msg.content === "string"
|
|
474
|
+
? msg.content
|
|
475
|
+
: Array.isArray(msg.content)
|
|
476
|
+
? msg.content.find((b) => b.type === "text")?.text ?? ""
|
|
477
|
+
: "";
|
|
478
|
+
if (!text)
|
|
479
|
+
continue;
|
|
480
|
+
const extracted = extractA2AFromConversationInfo(text);
|
|
481
|
+
if (extracted) {
|
|
482
|
+
extractedTaskId = extracted.taskId;
|
|
483
|
+
extractedDeviceType = extracted.deviceType;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// ── Build dynamic headers ────────────────────────────
|
|
457
489
|
if (ctx.extraParams) {
|
|
458
490
|
const fallbackPrefix = ctx.extraParams[FALLBACK_PREFIX_KEY];
|
|
459
491
|
if (typeof fallbackPrefix === "string") {
|
|
@@ -471,40 +503,30 @@ export const xiaoyiProvider = {
|
|
|
471
503
|
dynamicHeaders["x-cron-flag"] = "begin";
|
|
472
504
|
}
|
|
473
505
|
}
|
|
474
|
-
else {
|
|
475
|
-
// Session mode:
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
if (!resolvedTaskId) {
|
|
489
|
-
resolvedTaskId = getCurrentSessionContext()?.taskId ?? null;
|
|
490
|
-
}
|
|
491
|
-
const traceId = resolvedTaskId ?? ctx.extraParams[HEADER_TRACE_ID];
|
|
492
|
-
const sessionId = resolvedTaskId?.split("&")[0]
|
|
493
|
-
?? ctx.extraParams[HEADER_SESSION_ID];
|
|
494
|
-
const interactionId = resolvedTaskId?.split("&")[1]
|
|
495
|
-
?? ctx.extraParams[HEADER_INTERACTION_ID]
|
|
496
|
-
?? "";
|
|
497
|
-
if (typeof traceId === "string") {
|
|
498
|
-
const isCron = isCronTriggered(context.messages);
|
|
499
|
-
dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}_${Date.now()}` : traceId;
|
|
500
|
-
if (isCron) {
|
|
501
|
-
const cronTitle = extractCronTitle(context.messages);
|
|
502
|
-
if (cronTitle)
|
|
503
|
-
dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
|
|
504
|
-
if (context.messages?.length === 1)
|
|
505
|
-
dynamicHeaders["x-cron-flag"] = "begin";
|
|
506
|
-
}
|
|
506
|
+
else if (extractedTaskId) {
|
|
507
|
+
// Session mode: taskId extracted from Conversation info
|
|
508
|
+
const traceId = extractedTaskId;
|
|
509
|
+
const sessionId = traceId.split("&")[0];
|
|
510
|
+
const interactionId = traceId.split("&")[1] ?? "";
|
|
511
|
+
const isCron = isCronTriggered(context.messages);
|
|
512
|
+
dynamicHeaders[HEADER_TRACE_ID] = isCron ? `cron_${traceId}_${Date.now()}` : traceId;
|
|
513
|
+
if (isCron) {
|
|
514
|
+
const cronTitle = extractCronTitle(context.messages);
|
|
515
|
+
if (cronTitle)
|
|
516
|
+
dynamicHeaders["x-cron-title"] = encodeURIComponent(cronTitle);
|
|
517
|
+
if (context.messages?.length === 1)
|
|
518
|
+
dynamicHeaders["x-cron-flag"] = "begin";
|
|
507
519
|
}
|
|
520
|
+
dynamicHeaders[HEADER_SESSION_ID] = sessionId;
|
|
521
|
+
dynamicHeaders[HEADER_INTERACTION_ID] = interactionId;
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
// Fallback: use extraParams cached values
|
|
525
|
+
const traceId = ctx.extraParams[HEADER_TRACE_ID];
|
|
526
|
+
const sessionId = ctx.extraParams[HEADER_SESSION_ID];
|
|
527
|
+
const interactionId = ctx.extraParams[HEADER_INTERACTION_ID];
|
|
528
|
+
if (typeof traceId === "string")
|
|
529
|
+
dynamicHeaders[HEADER_TRACE_ID] = traceId;
|
|
508
530
|
if (typeof sessionId === "string")
|
|
509
531
|
dynamicHeaders[HEADER_SESSION_ID] = sessionId;
|
|
510
532
|
if (typeof interactionId === "string")
|
|
@@ -516,13 +538,11 @@ export const xiaoyiProvider = {
|
|
|
516
538
|
if (context.systemPrompt) {
|
|
517
539
|
console.log(`[xiaoyiprovider] system prompt length: ${context.systemPrompt.length}`);
|
|
518
540
|
}
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// resolvePreparedExtraParams by provider/modelId – the cache key does
|
|
522
|
-
// not include session-specific data, so deviceType may be missing
|
|
523
|
-
// from the cached extraParams even when a session is active.
|
|
541
|
+
// deviceType: prefer value extracted from Conversation info,
|
|
542
|
+
// then extraParams, then ALS fallback.
|
|
524
543
|
const extraParamsDeviceType = ctx.extraParams?.[DEVICE_TYPE_KEY] || undefined;
|
|
525
|
-
const deviceType =
|
|
544
|
+
const deviceType = (extractedDeviceType || extraParamsDeviceType)
|
|
545
|
+
?? getCurrentSessionContext()?.deviceType;
|
|
526
546
|
// 在发送给模型前,优化 systemPrompt 结构
|
|
527
547
|
if (context.systemPrompt) {
|
|
528
548
|
let sp = context.systemPrompt;
|
|
@@ -555,7 +575,7 @@ export const xiaoyiProvider = {
|
|
|
555
575
|
const selfEvolutionEnabled = await selfEvolutionManager.isEnabled();
|
|
556
576
|
console.log(`[selfEvolution] selfEvolution flag: ${selfEvolutionEnabled}`);
|
|
557
577
|
context.systemPrompt = applySelfEvolutionPrompt(context.systemPrompt, selfEvolutionEnabled);
|
|
558
|
-
// Append device context to systemPrompt
|
|
578
|
+
// Append device context to systemPrompt
|
|
559
579
|
if (deviceType) {
|
|
560
580
|
const displayDevice = (deviceType === "2in1") ? "鸿蒙PC" : deviceType;
|
|
561
581
|
const deviceSection = `\n\n## Current User Device Context\nThe current user is using the following device: ${displayDevice}\nYou need to be aware of the user's current device and provide guidance accordingly. If the response involves device-related tools or actions, you must tailor the reply based on the user's current device, using device-specific references such as "saved to the Notes/Calendar on your {deviceType}.\n"`;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool session helper — provides a simple API for tools to access
|
|
3
|
+
* the current session context via sessionKey.
|
|
4
|
+
*
|
|
5
|
+
* Tools capture `sessionKey` at creation time (may be empty if ALS
|
|
6
|
+
* was not available at agentTools factory time) and call
|
|
7
|
+
* `requireSession()` at execute time. If the captured sessionKey
|
|
8
|
+
* doesn't resolve, we fall back to ALS — the agent run is always
|
|
9
|
+
* within the ALS context set by bot.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { type XYSession } from "../xy-session-store.js";
|
|
12
|
+
/**
|
|
13
|
+
* Get the session for a sessionKey, throwing if not found.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order:
|
|
16
|
+
* 1. Closure-captured sessionKey (set at agentTools factory time)
|
|
17
|
+
* 2. ALS (set by bot.ts before the agent run)
|
|
18
|
+
* 3. Store enumeration — safe only when a single session is active
|
|
19
|
+
*
|
|
20
|
+
* Layers 1 and 2 can both fail: agentTools may be called before ALS is
|
|
21
|
+
* active, and pi-agent-core tool execute may cross async boundaries that
|
|
22
|
+
* lose the ALS context. Layer 3 is the ultimate fallback.
|
|
23
|
+
*/
|
|
24
|
+
export declare function requireSession(sessionKey?: string | null): XYSession;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool session helper — provides a simple API for tools to access
|
|
3
|
+
* the current session context via sessionKey.
|
|
4
|
+
*
|
|
5
|
+
* Tools capture `sessionKey` at creation time (may be empty if ALS
|
|
6
|
+
* was not available at agentTools factory time) and call
|
|
7
|
+
* `requireSession()` at execute time. If the captured sessionKey
|
|
8
|
+
* doesn't resolve, we fall back to ALS — the agent run is always
|
|
9
|
+
* within the ALS context set by bot.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { getSession, xyAsyncLocalStorage, getAllActiveSessions } from "../xy-session-store.js";
|
|
12
|
+
/**
|
|
13
|
+
* Get the session for a sessionKey, throwing if not found.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order:
|
|
16
|
+
* 1. Closure-captured sessionKey (set at agentTools factory time)
|
|
17
|
+
* 2. ALS (set by bot.ts before the agent run)
|
|
18
|
+
* 3. Store enumeration — safe only when a single session is active
|
|
19
|
+
*
|
|
20
|
+
* Layers 1 and 2 can both fail: agentTools may be called before ALS is
|
|
21
|
+
* active, and pi-agent-core tool execute may cross async boundaries that
|
|
22
|
+
* lose the ALS context. Layer 3 is the ultimate fallback.
|
|
23
|
+
*/
|
|
24
|
+
export function requireSession(sessionKey) {
|
|
25
|
+
// Layer 1: closure-captured sessionKey
|
|
26
|
+
if (sessionKey) {
|
|
27
|
+
const session = getSession(sessionKey);
|
|
28
|
+
if (session)
|
|
29
|
+
return session;
|
|
30
|
+
}
|
|
31
|
+
// Layer 2: ALS (works when the async chain preserves the context)
|
|
32
|
+
const alsContext = xyAsyncLocalStorage.getStore();
|
|
33
|
+
const alsKey = alsContext?.openclawSessionKey;
|
|
34
|
+
if (alsKey) {
|
|
35
|
+
const session = getSession(alsKey);
|
|
36
|
+
if (session)
|
|
37
|
+
return session;
|
|
38
|
+
}
|
|
39
|
+
// Layer 3: store enumeration — reliable when only one session is active
|
|
40
|
+
const allSessions = getAllActiveSessions();
|
|
41
|
+
if (allSessions.length === 1) {
|
|
42
|
+
return allSessions[0].session;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`XY session not found (sessionKey=${sessionKey ?? "none"}, alsKey=${alsKey ?? "none"}, activeSessions=${allSessions.length})`);
|
|
45
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
1
2
|
import type { XYChannelConfig } from "../types.js";
|
|
2
3
|
export interface SessionContext {
|
|
3
4
|
config: XYChannelConfig;
|
|
@@ -7,6 +8,12 @@ export interface SessionContext {
|
|
|
7
8
|
agentId: string;
|
|
8
9
|
deviceType?: string;
|
|
9
10
|
}
|
|
11
|
+
interface SessionContextWithRef extends SessionContext {
|
|
12
|
+
refCount: number;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const activeSessions: Map<string, SessionContextWithRef>;
|
|
16
|
+
export declare const asyncLocalStorage: AsyncLocalStorage<SessionContext>;
|
|
10
17
|
/**
|
|
11
18
|
* Register a session context for tool access.
|
|
12
19
|
* Should be called when starting to process a message.
|
|
@@ -58,3 +65,4 @@ export declare function cleanupStaleSessions(): number;
|
|
|
58
65
|
* Get the current number of active sessions (for diagnostics).
|
|
59
66
|
*/
|
|
60
67
|
export declare function getActiveSessionCount(): number;
|
|
68
|
+
export {};
|
|
@@ -18,7 +18,7 @@ const _g = globalThis;
|
|
|
18
18
|
if (!_g.__xyActiveSessions) {
|
|
19
19
|
_g.__xyActiveSessions = new Map();
|
|
20
20
|
}
|
|
21
|
-
const activeSessions = _g.__xyActiveSessions;
|
|
21
|
+
export const activeSessions = _g.__xyActiveSessions;
|
|
22
22
|
// Track the most recently registered sessionKey for reliable fallback
|
|
23
23
|
// when AsyncLocalStorage context is lost across openclaw's embedded runner boundary.
|
|
24
24
|
if (!_g.__xyLastRegisteredSessionKey) {
|
|
@@ -27,7 +27,7 @@ if (!_g.__xyLastRegisteredSessionKey) {
|
|
|
27
27
|
const getLastRegisteredKey = () => _g.__xyLastRegisteredSessionKey;
|
|
28
28
|
const setLastRegisteredKey = (key) => { _g.__xyLastRegisteredSessionKey = key; };
|
|
29
29
|
// AsyncLocalStorage for thread-safe session context isolation
|
|
30
|
-
const asyncLocalStorage = new AsyncLocalStorage();
|
|
30
|
+
export const asyncLocalStorage = new AsyncLocalStorage();
|
|
31
31
|
/**
|
|
32
32
|
* Register a session context for tool access.
|
|
33
33
|
* Should be called when starting to process a message.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XYSessionStore — unified session state management.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old session-manager.ts + task-manager.ts + tool-context.ts
|
|
5
|
+
* with a single Map<sessionKey, XYSession>.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Closures in tools/providers capture only `sessionKey` (a string).
|
|
9
|
+
* - Actual values (taskId, messageId, …) are looked up at execution time
|
|
10
|
+
* so steer-mode updates are always visible.
|
|
11
|
+
* - No refCount / lock — cleanup is driven by onSettled callbacks.
|
|
12
|
+
* - globalThis protection guards against duplicate module instances.
|
|
13
|
+
*/
|
|
14
|
+
import type { XYChannelConfig } from "./types.js";
|
|
15
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
16
|
+
export interface XYSession {
|
|
17
|
+
config: XYChannelConfig;
|
|
18
|
+
/** A2A protocol sessionId — identifies the conversation. */
|
|
19
|
+
a2aSessionId: string;
|
|
20
|
+
/** A2A taskId — updated in-place on steer. */
|
|
21
|
+
taskId: string;
|
|
22
|
+
/** A2A messageId — updated in-place on steer. */
|
|
23
|
+
messageId: string;
|
|
24
|
+
/** Device type (phone / tablet / 2in1). */
|
|
25
|
+
deviceType?: string;
|
|
26
|
+
/** OpenClaw accountId from route resolution ("default"). */
|
|
27
|
+
accountId: string;
|
|
28
|
+
}
|
|
29
|
+
export interface XYSessionALSContext {
|
|
30
|
+
openclawSessionKey: string;
|
|
31
|
+
}
|
|
32
|
+
export declare const xyAsyncLocalStorage: AsyncLocalStorage<XYSessionALSContext>;
|
|
33
|
+
/**
|
|
34
|
+
* Register or update a session.
|
|
35
|
+
* Called from bot.ts after resolveAgentRoute().
|
|
36
|
+
*/
|
|
37
|
+
export declare function registerSession(sessionKey: string, session: XYSession): void;
|
|
38
|
+
/**
|
|
39
|
+
* Update specific fields of an active session (used for steer).
|
|
40
|
+
*/
|
|
41
|
+
export declare function updateSession(sessionKey: string, partial: Partial<Pick<XYSession, "taskId" | "messageId" | "deviceType">>): void;
|
|
42
|
+
/**
|
|
43
|
+
* Get session by OpenClaw sessionKey.
|
|
44
|
+
* Returns null if not found.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getSession(sessionKey: string): XYSession | null;
|
|
47
|
+
/**
|
|
48
|
+
* Get session by A2A sessionId.
|
|
49
|
+
* Used by provider.ts which only knows the A2A sessionId.
|
|
50
|
+
*/
|
|
51
|
+
export declare function getSessionByA2AId(a2aSessionId: string): XYSession | null;
|
|
52
|
+
/**
|
|
53
|
+
* Unregister a session. Called from onSettled / error cleanup.
|
|
54
|
+
*/
|
|
55
|
+
export declare function unregisterSession(sessionKey: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Check whether a session has an active task (used for steer detection).
|
|
58
|
+
*/
|
|
59
|
+
export declare function hasActiveSession(sessionKey: string): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Get all active sessions (for gateway stop / diagnostics).
|
|
62
|
+
*/
|
|
63
|
+
export declare function getAllActiveSessions(): Array<{
|
|
64
|
+
sessionKey: string;
|
|
65
|
+
session: XYSession;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* Get count of active sessions.
|
|
69
|
+
*/
|
|
70
|
+
export declare function getActiveSessionCount(): number;
|
|
71
|
+
/**
|
|
72
|
+
* Force-clean all sessions (gateway shutdown / reload).
|
|
73
|
+
*/
|
|
74
|
+
export declare function cleanupAllSessions(): void;
|
|
75
|
+
/**
|
|
76
|
+
* Clean up stale sessions (older than TTL).
|
|
77
|
+
* Returns number of cleaned sessions.
|
|
78
|
+
*/
|
|
79
|
+
export declare function cleanupStaleSessions(ttlMs?: number): number;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { logger } from "./utils/logger.js";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
// ── AsyncLocalStorage ─────────────────────────────────────────
|
|
4
|
+
// Used by bot.ts to carry sessionKey through the agent run so
|
|
5
|
+
// channel.ts agentTools factory can read it.
|
|
6
|
+
export const xyAsyncLocalStorage = new AsyncLocalStorage();
|
|
7
|
+
const STORE_KEY = "__xySessionStore";
|
|
8
|
+
const A2A_INDEX_KEY = "__xyA2ASessionIndex";
|
|
9
|
+
const _g = globalThis;
|
|
10
|
+
function getStore() {
|
|
11
|
+
if (!_g[STORE_KEY]) {
|
|
12
|
+
_g[STORE_KEY] = new Map();
|
|
13
|
+
}
|
|
14
|
+
return _g[STORE_KEY];
|
|
15
|
+
}
|
|
16
|
+
/** Reverse index: a2aSessionId → openclawSessionKey (for provider lookups). */
|
|
17
|
+
function getA2AIndex() {
|
|
18
|
+
if (!_g[A2A_INDEX_KEY]) {
|
|
19
|
+
_g[A2A_INDEX_KEY] = new Map();
|
|
20
|
+
}
|
|
21
|
+
return _g[A2A_INDEX_KEY];
|
|
22
|
+
}
|
|
23
|
+
// ── API ───────────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Register or update a session.
|
|
26
|
+
* Called from bot.ts after resolveAgentRoute().
|
|
27
|
+
*/
|
|
28
|
+
export function registerSession(sessionKey, session) {
|
|
29
|
+
const store = getStore();
|
|
30
|
+
const a2aIndex = getA2AIndex();
|
|
31
|
+
const existing = store.get(sessionKey);
|
|
32
|
+
if (existing) {
|
|
33
|
+
// Update in place — steer mode
|
|
34
|
+
existing.taskId = session.taskId;
|
|
35
|
+
existing.messageId = session.messageId;
|
|
36
|
+
existing.deviceType = session.deviceType;
|
|
37
|
+
existing.createdAt = Date.now();
|
|
38
|
+
logger.log(`[SESSION-STORE] update: sessionKey=${sessionKey} a2a=${session.a2aSessionId} taskId=${session.taskId}`);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
store.set(sessionKey, { ...session, createdAt: Date.now() });
|
|
42
|
+
a2aIndex.set(session.a2aSessionId, sessionKey);
|
|
43
|
+
logger.log(`[SESSION-STORE] register: sessionKey=${sessionKey} a2a=${session.a2aSessionId} taskId=${session.taskId}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Update specific fields of an active session (used for steer).
|
|
48
|
+
*/
|
|
49
|
+
export function updateSession(sessionKey, partial) {
|
|
50
|
+
const store = getStore();
|
|
51
|
+
const entry = store.get(sessionKey);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
logger.log(`[SESSION-STORE] update skipped: sessionKey=${sessionKey} not found`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (partial.taskId !== undefined)
|
|
57
|
+
entry.taskId = partial.taskId;
|
|
58
|
+
if (partial.messageId !== undefined)
|
|
59
|
+
entry.messageId = partial.messageId;
|
|
60
|
+
if (partial.deviceType !== undefined)
|
|
61
|
+
entry.deviceType = partial.deviceType;
|
|
62
|
+
logger.log(`[SESSION-STORE] update: sessionKey=${sessionKey} taskId=${entry.taskId}`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get session by OpenClaw sessionKey.
|
|
66
|
+
* Returns null if not found.
|
|
67
|
+
*/
|
|
68
|
+
export function getSession(sessionKey) {
|
|
69
|
+
const store = getStore();
|
|
70
|
+
const entry = store.get(sessionKey);
|
|
71
|
+
if (!entry)
|
|
72
|
+
return null;
|
|
73
|
+
// Strip internal fields
|
|
74
|
+
const { createdAt, ...session } = entry;
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get session by A2A sessionId.
|
|
79
|
+
* Used by provider.ts which only knows the A2A sessionId.
|
|
80
|
+
*/
|
|
81
|
+
export function getSessionByA2AId(a2aSessionId) {
|
|
82
|
+
const a2aIndex = getA2AIndex();
|
|
83
|
+
const sessionKey = a2aIndex.get(a2aSessionId);
|
|
84
|
+
if (!sessionKey)
|
|
85
|
+
return null;
|
|
86
|
+
return getSession(sessionKey);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Unregister a session. Called from onSettled / error cleanup.
|
|
90
|
+
*/
|
|
91
|
+
export function unregisterSession(sessionKey) {
|
|
92
|
+
const store = getStore();
|
|
93
|
+
const a2aIndex = getA2AIndex();
|
|
94
|
+
const entry = store.get(sessionKey);
|
|
95
|
+
if (!entry)
|
|
96
|
+
return;
|
|
97
|
+
a2aIndex.delete(entry.a2aSessionId);
|
|
98
|
+
store.delete(sessionKey);
|
|
99
|
+
logger.log(`[SESSION-STORE] unregister: sessionKey=${sessionKey}`);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check whether a session has an active task (used for steer detection).
|
|
103
|
+
*/
|
|
104
|
+
export function hasActiveSession(sessionKey) {
|
|
105
|
+
return getStore().has(sessionKey);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get all active sessions (for gateway stop / diagnostics).
|
|
109
|
+
*/
|
|
110
|
+
export function getAllActiveSessions() {
|
|
111
|
+
const store = getStore();
|
|
112
|
+
const result = [];
|
|
113
|
+
for (const [key, entry] of store) {
|
|
114
|
+
const { createdAt, ...session } = entry;
|
|
115
|
+
result.push({ sessionKey: key, session });
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get count of active sessions.
|
|
121
|
+
*/
|
|
122
|
+
export function getActiveSessionCount() {
|
|
123
|
+
return getStore().size;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Force-clean all sessions (gateway shutdown / reload).
|
|
127
|
+
*/
|
|
128
|
+
export function cleanupAllSessions() {
|
|
129
|
+
getStore().clear();
|
|
130
|
+
getA2AIndex().clear();
|
|
131
|
+
logger.log("[SESSION-STORE] all sessions cleaned up");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Clean up stale sessions (older than TTL).
|
|
135
|
+
* Returns number of cleaned sessions.
|
|
136
|
+
*/
|
|
137
|
+
export function cleanupStaleSessions(ttlMs = 60 * 60 * 1000) {
|
|
138
|
+
const store = getStore();
|
|
139
|
+
const a2aIndex = getA2AIndex();
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
let cleaned = 0;
|
|
142
|
+
for (const [key, entry] of store) {
|
|
143
|
+
if (now - entry.createdAt > ttlMs) {
|
|
144
|
+
a2aIndex.delete(entry.a2aSessionId);
|
|
145
|
+
store.delete(key);
|
|
146
|
+
cleaned++;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cleaned > 0) {
|
|
150
|
+
logger.log(`[SESSION-STORE] cleaned ${cleaned} stale session(s)`);
|
|
151
|
+
}
|
|
152
|
+
return cleaned;
|
|
153
|
+
}
|