@ynhcj/xiaoyi-channel 0.0.204-beta → 0.0.206-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/provider.js
CHANGED
|
@@ -128,6 +128,7 @@ function createRetryingStream(createStream, cronJob) {
|
|
|
128
128
|
let hasContent = false;
|
|
129
129
|
const buffer = [];
|
|
130
130
|
let errorResult = null;
|
|
131
|
+
const fullResponseEvents = [];
|
|
131
132
|
for await (const event of stream) {
|
|
132
133
|
const isContent = CONTENT_EVENT_TYPES.has(event.type);
|
|
133
134
|
if (!hasContent && !isContent) {
|
|
@@ -150,9 +151,14 @@ function createRetryingStream(createStream, cronJob) {
|
|
|
150
151
|
if (!hasContent) {
|
|
151
152
|
logger.log("[xiaoyiprovider] first content event received, switching to streaming mode");
|
|
152
153
|
hasContent = true;
|
|
153
|
-
for (const b of buffer)
|
|
154
|
+
for (const b of buffer) {
|
|
155
|
+
if (CONTENT_EVENT_TYPES.has(b.type))
|
|
156
|
+
fullResponseEvents.push(b);
|
|
154
157
|
yield b;
|
|
158
|
+
}
|
|
155
159
|
}
|
|
160
|
+
if (isContent)
|
|
161
|
+
fullResponseEvents.push(event);
|
|
156
162
|
// IMPORTANT: resolve result() BEFORE yielding terminal events to avoid deadlock.
|
|
157
163
|
// The SDK calls result() when it sees done/error — if we yield first, the generator
|
|
158
164
|
// suspends and can never reach resolve, causing a permanent deadlock.
|
|
@@ -424,9 +424,20 @@ export function createXYReplyDispatcher(params) {
|
|
|
424
424
|
// 模型可能返回完整文本(非纯增量),如果新文本以已累积内容开头,
|
|
425
425
|
// 则只取新增的后缀部分,避免 append 模式下重复拼接。
|
|
426
426
|
let sendText = text;
|
|
427
|
-
|
|
427
|
+
const dedupApplied = accumulatedText.length > 0 && text.startsWith(accumulatedText);
|
|
428
|
+
if (dedupApplied) {
|
|
428
429
|
sendText = text.slice(accumulatedText.length);
|
|
429
430
|
}
|
|
431
|
+
else {
|
|
432
|
+
// 新文本不以已累积内容为前缀(如工具调用后模型重新开始生成),
|
|
433
|
+
// 更新 accumulatedText 为当前文本,后续基于此新前缀做去重
|
|
434
|
+
const wasFirstRound = accumulatedText.length === 0;
|
|
435
|
+
accumulatedText = "";
|
|
436
|
+
// 新一轮输出前加换行分隔(第一轮除外)
|
|
437
|
+
if (sendText.length > 0 && !wasFirstRound) {
|
|
438
|
+
sendText = "\n" + sendText;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
430
441
|
accumulatedText += sendText;
|
|
431
442
|
hasSentResponse = true;
|
|
432
443
|
if (sendText.length > 0) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface SubagentWaitState {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
sessionKey: string;
|
|
4
|
+
taskId: string;
|
|
5
|
+
messageId: string;
|
|
6
|
+
artifactId: string;
|
|
7
|
+
startedAt: number;
|
|
8
|
+
expectedCompletions: number;
|
|
9
|
+
deliveredCompletions: number;
|
|
10
|
+
completionTexts: string[];
|
|
11
|
+
parentSettled: boolean;
|
|
12
|
+
finalizationClaimed: boolean;
|
|
13
|
+
stopHeartbeat?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export interface SubagentWaitTransition {
|
|
16
|
+
state: SubagentWaitState;
|
|
17
|
+
isComplete: boolean;
|
|
18
|
+
shouldFinalize: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Register a mapping from openclaw sessionKey to A2A sessionId/taskId.
|
|
22
|
+
* Called in bot.ts when a new message dispatch begins.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerSessionKeyMapping(sessionKey: string, sessionId: string, taskId: string, messageId: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Look up the A2A sessionId for a given openclaw sessionKey.
|
|
27
|
+
* Used in hooks (index.ts) to translate requesterSessionKey to A2A sessionId.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveSessionIdFromSessionKey(sessionKey: string): {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
taskId: string;
|
|
32
|
+
messageId: string;
|
|
33
|
+
} | null;
|
|
34
|
+
/**
|
|
35
|
+
* Remove a sessionKey→sessionId mapping (cleanup when wait state resolved).
|
|
36
|
+
*/
|
|
37
|
+
export declare function unregisterSessionKeyMapping(sessionKey: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Called from subagent_spawned hook. Increments expected completion count.
|
|
40
|
+
* If no wait state exists yet, creates one (sessions_spawn may happen before sessions_yield).
|
|
41
|
+
*/
|
|
42
|
+
export declare function markSubagentSpawned(sessionKey: string): number;
|
|
43
|
+
/**
|
|
44
|
+
* Called from subagent_ended hook. Marks a subagent run as ended AND
|
|
45
|
+
* counts it as a delivery. This is the primary completion tracking
|
|
46
|
+
* mechanism because subagent completions may not route through
|
|
47
|
+
* xyOutbound.sendText (they go through openclaw's internal gateway
|
|
48
|
+
* agent path with potentially deliver=false).
|
|
49
|
+
*
|
|
50
|
+
* Returns transition with shouldFinalize if all completions have ended.
|
|
51
|
+
*/
|
|
52
|
+
export declare function markSubagentEnded(sessionKey: string): SubagentWaitTransition | null;
|
|
53
|
+
/**
|
|
54
|
+
* Called from bot.ts onSettled. Marks parent dispatcher as settled.
|
|
55
|
+
* Returns transition with shouldFinalize if all completions already arrived.
|
|
56
|
+
*/
|
|
57
|
+
export declare function markParentSettled(sessionId: string, taskId: string): SubagentWaitTransition | null;
|
|
58
|
+
/**
|
|
59
|
+
* Store a completion text snippet captured from xyOutbound.sendText.
|
|
60
|
+
* Does NOT increment delivery count (that's done by markSubagentEnded).
|
|
61
|
+
*/
|
|
62
|
+
export declare function addCompletionText(sessionId: string, taskId: string, text: string): void;
|
|
63
|
+
export declare function attachHeartbeat(sessionId: string, taskId: string, stopHeartbeat: () => void): void;
|
|
64
|
+
export declare function getWaitState(sessionId: string, taskId?: string): SubagentWaitState | null;
|
|
65
|
+
export declare function hasWaitState(sessionId: string, taskId?: string): boolean;
|
|
66
|
+
export declare function clearWaitState(sessionId: string, reason: string, taskId?: string): SubagentWaitState | null;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Subagent wait state manager.
|
|
2
|
+
//
|
|
3
|
+
// Tracks sessions that are waiting for subagent completions after sessions_yield.
|
|
4
|
+
// Subagent spawn/end is detected via subagent_spawned / subagent_ended hooks (index.ts).
|
|
5
|
+
// Completion delivery is intercepted in xyOutbound.sendText (outbound.ts).
|
|
6
|
+
//
|
|
7
|
+
// Isolation: xyOutbound.sendText also serves cron pushes and normal message-tool calls.
|
|
8
|
+
// Guards ensure these paths never collide with subagent result interception:
|
|
9
|
+
// 1. ALS context guard — subagent completions arrive with ALS null
|
|
10
|
+
// 2. Wait state match — only sessions that have yielded have a wait state
|
|
11
|
+
// 3. TaskId match — subagent delivery's "to" is a bare sessionId, enhanced via wait state
|
|
12
|
+
//
|
|
13
|
+
// TTL: wait states expire after WAIT_TTL_MS (30 min) to avoid stale leaks.
|
|
14
|
+
// Only one finalization path wins via finalizationClaimed flag.
|
|
15
|
+
import { randomUUID } from "crypto";
|
|
16
|
+
import { logger } from "./utils/logger.js";
|
|
17
|
+
// ─── Global state ──────────────────────────────────────────────
|
|
18
|
+
const WAIT_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
19
|
+
const _g = globalThis;
|
|
20
|
+
if (!_g.__xySubagentWaitStates) {
|
|
21
|
+
_g.__xySubagentWaitStates = new Map();
|
|
22
|
+
}
|
|
23
|
+
if (!_g.__xySessionKeyToSessionId) {
|
|
24
|
+
_g.__xySessionKeyToSessionId = new Map();
|
|
25
|
+
}
|
|
26
|
+
const waitStates = _g.__xySubagentWaitStates;
|
|
27
|
+
const sessionKeyMap = _g.__xySessionKeyToSessionId;
|
|
28
|
+
// ─── Helpers ───────────────────────────────────────────────────
|
|
29
|
+
function waitKey(sessionId, taskId) {
|
|
30
|
+
return `${sessionId}::${taskId}`;
|
|
31
|
+
}
|
|
32
|
+
function isExpired(state) {
|
|
33
|
+
return Date.now() - state.startedAt > WAIT_TTL_MS;
|
|
34
|
+
}
|
|
35
|
+
function isComplete(state) {
|
|
36
|
+
return state.deliveredCompletions >= Math.max(1, state.expectedCompletions);
|
|
37
|
+
}
|
|
38
|
+
function claimFinalizationIfReady(state) {
|
|
39
|
+
if (state.finalizationClaimed)
|
|
40
|
+
return false;
|
|
41
|
+
if (!state.parentSettled || !isComplete(state))
|
|
42
|
+
return false;
|
|
43
|
+
state.finalizationClaimed = true;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// ─── SessionKey ↔ SessionId mapping ────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Register a mapping from openclaw sessionKey to A2A sessionId/taskId.
|
|
49
|
+
* Called in bot.ts when a new message dispatch begins.
|
|
50
|
+
*/
|
|
51
|
+
export function registerSessionKeyMapping(sessionKey, sessionId, taskId, messageId) {
|
|
52
|
+
if (!sessionKey || !sessionId)
|
|
53
|
+
return;
|
|
54
|
+
sessionKeyMap.set(sessionKey, { sessionId, taskId, messageId });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Look up the A2A sessionId for a given openclaw sessionKey.
|
|
58
|
+
* Used in hooks (index.ts) to translate requesterSessionKey to A2A sessionId.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveSessionIdFromSessionKey(sessionKey) {
|
|
61
|
+
return sessionKeyMap.get(sessionKey) ?? null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Remove a sessionKey→sessionId mapping (cleanup when wait state resolved).
|
|
65
|
+
*/
|
|
66
|
+
export function unregisterSessionKeyMapping(sessionKey) {
|
|
67
|
+
sessionKeyMap.delete(sessionKey);
|
|
68
|
+
}
|
|
69
|
+
// ─── Wait state management ─────────────────────────────────────
|
|
70
|
+
function getStatesArray(sessionId) {
|
|
71
|
+
const raw = waitStates.get(sessionId);
|
|
72
|
+
if (!raw)
|
|
73
|
+
return [];
|
|
74
|
+
const active = raw.filter((s) => {
|
|
75
|
+
if (!isExpired(s))
|
|
76
|
+
return true;
|
|
77
|
+
s.stopHeartbeat?.();
|
|
78
|
+
logger.withContext(sessionId, s.taskId).log(`[SUBAGENT-WAIT] Expired wait state cleared, expected=${s.expectedCompletions}, delivered=${s.deliveredCompletions}`);
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
if (active.length > 0) {
|
|
82
|
+
waitStates.set(sessionId, active);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
waitStates.delete(sessionId);
|
|
86
|
+
}
|
|
87
|
+
return active;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Called from subagent_spawned hook. Increments expected completion count.
|
|
91
|
+
* If no wait state exists yet, creates one (sessions_spawn may happen before sessions_yield).
|
|
92
|
+
*/
|
|
93
|
+
export function markSubagentSpawned(sessionKey) {
|
|
94
|
+
const mapped = resolveSessionIdFromSessionKey(sessionKey);
|
|
95
|
+
if (!mapped) {
|
|
96
|
+
logger.log(`[SUBAGENT-WAIT] No session mapping for sessionKey=${sessionKey.slice(0, 30)}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
const { sessionId, taskId, messageId } = mapped;
|
|
100
|
+
const states = getStatesArray(sessionId);
|
|
101
|
+
let state = states.find((s) => s.taskId === taskId);
|
|
102
|
+
if (!state) {
|
|
103
|
+
state = {
|
|
104
|
+
sessionId,
|
|
105
|
+
sessionKey,
|
|
106
|
+
taskId,
|
|
107
|
+
messageId,
|
|
108
|
+
artifactId: randomUUID(),
|
|
109
|
+
startedAt: Date.now(),
|
|
110
|
+
expectedCompletions: 0,
|
|
111
|
+
deliveredCompletions: 0,
|
|
112
|
+
completionTexts: [],
|
|
113
|
+
parentSettled: false,
|
|
114
|
+
finalizationClaimed: false,
|
|
115
|
+
};
|
|
116
|
+
states.push(state);
|
|
117
|
+
}
|
|
118
|
+
state.expectedCompletions = Math.max(1, state.expectedCompletions + 1);
|
|
119
|
+
waitStates.set(sessionId, states);
|
|
120
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Subagent spawned, expected=${state.expectedCompletions}`);
|
|
121
|
+
return state.expectedCompletions;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Called from subagent_ended hook. Marks a subagent run as ended AND
|
|
125
|
+
* counts it as a delivery. This is the primary completion tracking
|
|
126
|
+
* mechanism because subagent completions may not route through
|
|
127
|
+
* xyOutbound.sendText (they go through openclaw's internal gateway
|
|
128
|
+
* agent path with potentially deliver=false).
|
|
129
|
+
*
|
|
130
|
+
* Returns transition with shouldFinalize if all completions have ended.
|
|
131
|
+
*/
|
|
132
|
+
export function markSubagentEnded(sessionKey) {
|
|
133
|
+
const mapped = resolveSessionIdFromSessionKey(sessionKey);
|
|
134
|
+
if (!mapped)
|
|
135
|
+
return null;
|
|
136
|
+
const { sessionId, taskId } = mapped;
|
|
137
|
+
const states = getStatesArray(sessionId);
|
|
138
|
+
const state = states.find((s) => s.taskId === taskId);
|
|
139
|
+
if (!state) {
|
|
140
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Subagent ended but no wait state found`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
state.deliveredCompletions += 1;
|
|
144
|
+
const complete = isComplete(state);
|
|
145
|
+
const shouldFinalize = claimFinalizationIfReady(state);
|
|
146
|
+
waitStates.set(sessionId, states);
|
|
147
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Subagent ended, delivered=${state.deliveredCompletions}/${state.expectedCompletions}, complete=${complete}, shouldFinalize=${shouldFinalize}`);
|
|
148
|
+
return { state, isComplete: complete, shouldFinalize };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Called from bot.ts onSettled. Marks parent dispatcher as settled.
|
|
152
|
+
* Returns transition with shouldFinalize if all completions already arrived.
|
|
153
|
+
*/
|
|
154
|
+
export function markParentSettled(sessionId, taskId) {
|
|
155
|
+
const states = getStatesArray(sessionId);
|
|
156
|
+
const state = states.find((s) => s.taskId === taskId);
|
|
157
|
+
if (!state) {
|
|
158
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] No wait state matched for parent settled`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
state.parentSettled = true;
|
|
162
|
+
const complete = isComplete(state);
|
|
163
|
+
const shouldFinalize = claimFinalizationIfReady(state);
|
|
164
|
+
waitStates.set(sessionId, states);
|
|
165
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Parent settled, completions=${state.deliveredCompletions}/${state.expectedCompletions}, shouldFinalize=${shouldFinalize}`);
|
|
166
|
+
return { state, isComplete: complete, shouldFinalize };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Store a completion text snippet captured from xyOutbound.sendText.
|
|
170
|
+
* Does NOT increment delivery count (that's done by markSubagentEnded).
|
|
171
|
+
*/
|
|
172
|
+
export function addCompletionText(sessionId, taskId, text) {
|
|
173
|
+
const states = getStatesArray(sessionId);
|
|
174
|
+
const state = states.find((s) => s.taskId === taskId);
|
|
175
|
+
if (!state || !text.trim())
|
|
176
|
+
return;
|
|
177
|
+
state.completionTexts.push(text.trim());
|
|
178
|
+
waitStates.set(sessionId, states);
|
|
179
|
+
}
|
|
180
|
+
export function attachHeartbeat(sessionId, taskId, stopHeartbeat) {
|
|
181
|
+
const states = getStatesArray(sessionId);
|
|
182
|
+
const state = states.find((s) => s.taskId === taskId);
|
|
183
|
+
if (!state) {
|
|
184
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] No wait state matched for heartbeat attachment`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
state.stopHeartbeat = stopHeartbeat;
|
|
188
|
+
waitStates.set(sessionId, states);
|
|
189
|
+
logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Heartbeat attached`);
|
|
190
|
+
}
|
|
191
|
+
// ─── Query functions ───────────────────────────────────────────
|
|
192
|
+
export function getWaitState(sessionId, taskId) {
|
|
193
|
+
const states = getStatesArray(sessionId);
|
|
194
|
+
if (states.length === 0)
|
|
195
|
+
return null;
|
|
196
|
+
if (taskId) {
|
|
197
|
+
return states.find((s) => s.taskId === taskId) ?? null;
|
|
198
|
+
}
|
|
199
|
+
// FIFO: return oldest wait state when taskId not specified
|
|
200
|
+
return states[0] ?? null;
|
|
201
|
+
}
|
|
202
|
+
export function hasWaitState(sessionId, taskId) {
|
|
203
|
+
return getWaitState(sessionId, taskId) !== null;
|
|
204
|
+
}
|
|
205
|
+
export function clearWaitState(sessionId, reason, taskId) {
|
|
206
|
+
const states = getStatesArray(sessionId);
|
|
207
|
+
if (states.length === 0)
|
|
208
|
+
return null;
|
|
209
|
+
const index = taskId
|
|
210
|
+
? states.findIndex((s) => s.taskId === taskId)
|
|
211
|
+
: 0;
|
|
212
|
+
if (index < 0) {
|
|
213
|
+
logger.withContext(sessionId, taskId ?? "").log(`[SUBAGENT-WAIT] No wait state matched for clear, reason=${reason}`);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const [state] = states.splice(index, 1);
|
|
217
|
+
state.stopHeartbeat?.();
|
|
218
|
+
if (states.length > 0) {
|
|
219
|
+
waitStates.set(sessionId, states);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
waitStates.delete(sessionId);
|
|
223
|
+
}
|
|
224
|
+
logger.withContext(sessionId, state.taskId).log(`[SUBAGENT-WAIT] Cleared wait state, reason=${reason}, remaining=${states.length}`);
|
|
225
|
+
return state;
|
|
226
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ynhcj/xiaoyi-channel",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.206-beta",
|
|
4
4
|
"description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"openclaw": ">=2026.
|
|
52
|
+
"openclaw": ">=2026.6.6"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"openclaw": {
|