openclaw-lark-multi-agent 1.0.6 → 1.0.7
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/feishu-bot.js +19 -13
- package/dist/message-store.d.ts +6 -0
- package/dist/message-store.js +57 -1
- package/dist/openclaw-client.d.ts +4 -0
- package/dist/openclaw-client.js +281 -66
- package/package.json +1 -1
package/dist/feishu-bot.js
CHANGED
|
@@ -637,25 +637,27 @@ export class FeishuBot {
|
|
|
637
637
|
}
|
|
638
638
|
async processQueueInner(chatId) {
|
|
639
639
|
while (true) {
|
|
640
|
-
const unsyncedMessages = this.store.getUnsyncedMessages(this.config.name, chatId);
|
|
641
640
|
const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
|
|
642
|
-
const messageById = new Map();
|
|
643
|
-
for (const msg of [...unsyncedMessages, ...pendingMessages]) {
|
|
644
|
-
if (msg.id)
|
|
645
|
-
messageById.set(msg.id, msg);
|
|
646
|
-
}
|
|
647
|
-
const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
648
641
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
|
|
649
642
|
// Only proceed if there are pending human messages that should actively trigger this bot.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
643
|
+
const pendingHumanTriggers = pendingMessages
|
|
644
|
+
.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id))
|
|
645
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
646
|
+
if (pendingHumanTriggers.length === 0) {
|
|
653
647
|
break;
|
|
654
648
|
}
|
|
655
649
|
this.busyChats.set(chatId, Date.now());
|
|
656
|
-
// The last trigger message is the "current" one, everything else is context
|
|
657
|
-
const lastHuman =
|
|
650
|
+
// The last trigger message is the "current" one, everything else is context.
|
|
651
|
+
const lastHuman = pendingHumanTriggers[pendingHumanTriggers.length - 1];
|
|
658
652
|
const triggerId = lastHuman.id || 0;
|
|
653
|
+
const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, triggerId);
|
|
654
|
+
const messageById = new Map();
|
|
655
|
+
for (const msg of [...catchupMessages, ...pendingMessages]) {
|
|
656
|
+
if (msg.id)
|
|
657
|
+
messageById.set(msg.id, msg);
|
|
658
|
+
}
|
|
659
|
+
const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
660
|
+
const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
|
|
659
661
|
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
660
662
|
console.warn(`[${this.config.name}] Duplicate trigger skipped for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
661
663
|
this.store.clearPendingTriggers(this.config.name, chatId, triggerId);
|
|
@@ -716,6 +718,8 @@ export class FeishuBot {
|
|
|
716
718
|
// must remain pending for the next loop.
|
|
717
719
|
const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
|
|
718
720
|
const processedTriggerIds = new Set(humanUnsynced.map((m) => m.id || 0).filter(Boolean));
|
|
721
|
+
const syncBatchId = `${this.config.name}:${chatId}:${triggerId}:${Date.now()}`;
|
|
722
|
+
this.store.markMessagesSynced(this.config.name, chatId, allUnsynced.map((m) => m.id || 0), syncBatchId);
|
|
719
723
|
this.store.markSynced(this.config.name, chatId, maxId);
|
|
720
724
|
this.store.clearPendingTriggers(this.config.name, chatId, maxId);
|
|
721
725
|
const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
|
|
@@ -739,8 +743,10 @@ export class FeishuBot {
|
|
|
739
743
|
// Do not advance sync past a human message that arrived while this
|
|
740
744
|
// run was busy. Otherwise the pending trigger remains in the table
|
|
741
745
|
// but getUnsyncedMessages() can no longer see it.
|
|
742
|
-
if (!hasEarlierPending)
|
|
746
|
+
if (!hasEarlierPending) {
|
|
747
|
+
this.store.markMessagesSynced(this.config.name, chatId, [replyId], `${this.config.name}:${chatId}:reply:${replyId}`);
|
|
743
748
|
this.store.markSynced(this.config.name, chatId, replyId);
|
|
749
|
+
}
|
|
744
750
|
}
|
|
745
751
|
}
|
|
746
752
|
// Wait for all pending tool event messages to be delivered first
|
package/dist/message-store.d.ts
CHANGED
|
@@ -74,6 +74,12 @@ export declare class MessageStore {
|
|
|
74
74
|
* Returns messages ordered by timestamp ascending.
|
|
75
75
|
*/
|
|
76
76
|
getUnsyncedMessages(botName: string, chatId: string, maxCount?: number): ChatMessage[];
|
|
77
|
+
/**
|
|
78
|
+
* Get all messages at or before triggerRowId that have not been synced to a
|
|
79
|
+
* bot's session yet. Ordered by timestamp ascending.
|
|
80
|
+
*/
|
|
81
|
+
getUnsyncedMessagesForBot(botName: string, chatId: string, triggerRowId: number): ChatMessage[];
|
|
82
|
+
markMessagesSynced(botName: string, chatId: string, messageRowIds: number[], syncBatchId?: string): void;
|
|
77
83
|
/**
|
|
78
84
|
* Mark all messages up to (and including) the given id as synced for a bot.
|
|
79
85
|
*/
|
package/dist/message-store.js
CHANGED
|
@@ -23,6 +23,7 @@ export class MessageStore {
|
|
|
23
23
|
timestamp INTEGER NOT NULL
|
|
24
24
|
);
|
|
25
25
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_id, timestamp);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, id);
|
|
26
27
|
|
|
27
28
|
CREATE TABLE IF NOT EXISTS chat_info (
|
|
28
29
|
chat_id TEXT PRIMARY KEY,
|
|
@@ -36,7 +37,8 @@ export class MessageStore {
|
|
|
36
37
|
updated_at INTEGER NOT NULL DEFAULT 0
|
|
37
38
|
);
|
|
38
39
|
|
|
39
|
-
--
|
|
40
|
+
-- Legacy high-water mark for which messages have been synced to each
|
|
41
|
+
-- bot's OpenClaw session. Kept for compatibility and quick inspection.
|
|
40
42
|
CREATE TABLE IF NOT EXISTS sync_state (
|
|
41
43
|
bot_name TEXT NOT NULL,
|
|
42
44
|
chat_id TEXT NOT NULL,
|
|
@@ -44,6 +46,18 @@ export class MessageStore {
|
|
|
44
46
|
PRIMARY KEY (bot_name, chat_id)
|
|
45
47
|
);
|
|
46
48
|
|
|
49
|
+
-- Per-message sync ledger. This lets a bot catch up on all missed group
|
|
50
|
+
-- messages without relying only on a coarse high-water mark.
|
|
51
|
+
CREATE TABLE IF NOT EXISTS message_sync (
|
|
52
|
+
bot_name TEXT NOT NULL,
|
|
53
|
+
chat_id TEXT NOT NULL,
|
|
54
|
+
message_row_id INTEGER NOT NULL,
|
|
55
|
+
synced_at INTEGER NOT NULL,
|
|
56
|
+
sync_batch_id TEXT NOT NULL DEFAULT '',
|
|
57
|
+
PRIMARY KEY (bot_name, chat_id, message_row_id)
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_message_sync_bot_chat ON message_sync(bot_name, chat_id, message_row_id);
|
|
60
|
+
|
|
47
61
|
-- Tracks which messages have been processed by each bot (multi-bot dedup).
|
|
48
62
|
CREATE TABLE IF NOT EXISTS processed_events (
|
|
49
63
|
bot_name TEXT NOT NULL,
|
|
@@ -428,6 +442,48 @@ export class MessageStore {
|
|
|
428
442
|
timestamp: r.timestamp,
|
|
429
443
|
}));
|
|
430
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Get all messages at or before triggerRowId that have not been synced to a
|
|
447
|
+
* bot's session yet. Ordered by timestamp ascending.
|
|
448
|
+
*/
|
|
449
|
+
getUnsyncedMessagesForBot(botName, chatId, triggerRowId) {
|
|
450
|
+
const rows = this.db.prepare(`
|
|
451
|
+
SELECT messages.* FROM messages
|
|
452
|
+
LEFT JOIN recalled_messages ON recalled_messages.message_id = messages.message_id
|
|
453
|
+
LEFT JOIN message_sync ON message_sync.bot_name = ?
|
|
454
|
+
AND message_sync.chat_id = messages.chat_id
|
|
455
|
+
AND message_sync.message_row_id = messages.id
|
|
456
|
+
WHERE messages.chat_id = ?
|
|
457
|
+
AND messages.id <= ?
|
|
458
|
+
AND recalled_messages.message_id IS NULL
|
|
459
|
+
AND message_sync.message_row_id IS NULL
|
|
460
|
+
ORDER BY messages.timestamp ASC
|
|
461
|
+
`).all(botName, chatId, triggerRowId);
|
|
462
|
+
return rows.map((r) => ({
|
|
463
|
+
id: r.id,
|
|
464
|
+
chatId: r.chat_id,
|
|
465
|
+
messageId: r.message_id,
|
|
466
|
+
senderType: r.sender_type,
|
|
467
|
+
senderName: r.sender_name,
|
|
468
|
+
content: r.content,
|
|
469
|
+
timestamp: r.timestamp,
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
markMessagesSynced(botName, chatId, messageRowIds, syncBatchId = '') {
|
|
473
|
+
const ids = Array.from(new Set(messageRowIds.filter((id) => Number.isFinite(id) && id > 0)));
|
|
474
|
+
if (ids.length === 0)
|
|
475
|
+
return;
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const stmt = this.db.prepare(`
|
|
478
|
+
INSERT OR IGNORE INTO message_sync (bot_name, chat_id, message_row_id, synced_at, sync_batch_id)
|
|
479
|
+
VALUES (?, ?, ?, ?, ?)
|
|
480
|
+
`);
|
|
481
|
+
const tx = this.db.transaction((rows) => {
|
|
482
|
+
for (const id of rows)
|
|
483
|
+
stmt.run(botName, chatId, id, now, syncBatchId);
|
|
484
|
+
});
|
|
485
|
+
tx(ids);
|
|
486
|
+
}
|
|
431
487
|
/**
|
|
432
488
|
* Mark all messages up to (and including) the given id as synced for a bot.
|
|
433
489
|
*/
|
|
@@ -39,6 +39,8 @@ export declare class OpenClawClient {
|
|
|
39
39
|
constructor(config: OpenClawConfig);
|
|
40
40
|
connect(): Promise<void>;
|
|
41
41
|
private _doConnect;
|
|
42
|
+
private extractVisibleUserText;
|
|
43
|
+
private extractVisibleAssistantText;
|
|
42
44
|
private handleProactiveSessionMessage;
|
|
43
45
|
private scheduleReconnect;
|
|
44
46
|
private rpc;
|
|
@@ -122,6 +124,8 @@ export declare class OpenClawClient {
|
|
|
122
124
|
timeoutMs?: number;
|
|
123
125
|
emptyFinalAsNoReply?: boolean;
|
|
124
126
|
}): Promise<string>;
|
|
127
|
+
private formatContextLines;
|
|
128
|
+
private writeContextSyncFile;
|
|
125
129
|
private extractImageAttachments;
|
|
126
130
|
disconnect(): Promise<void>;
|
|
127
131
|
/**
|
package/dist/openclaw-client.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { readFileSync } from "fs";
|
|
4
|
-
import { basename, extname } from "path";
|
|
5
|
-
import { getBridgeAttachmentsDir } from "./paths.js";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { basename, extname, join } from "path";
|
|
5
|
+
import { getBridgeAttachmentsDir, getStateDir } from "./paths.js";
|
|
6
6
|
export const GATEWAY_PROTOCOL_MIN = 3;
|
|
7
7
|
export const GATEWAY_PROTOCOL_MAX = 4;
|
|
8
8
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
9
|
+
const CONTEXT_SYNC_DIR = join(getStateDir(), "context-sync");
|
|
10
|
+
const MAX_INLINE_CONTEXT_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_MESSAGES || 1000);
|
|
11
|
+
const MAX_INLINE_CONTEXT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_BYTES || 8 * 1024 * 1024);
|
|
9
12
|
/**
|
|
10
13
|
* OpenClaw Gateway WebSocket client.
|
|
11
14
|
* Full agent pipeline — tools, memory, skills, context management by OpenClaw.
|
|
@@ -164,6 +167,28 @@ export class OpenClawClient {
|
|
|
164
167
|
// Try both raw key and without agent:main: prefix
|
|
165
168
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
166
169
|
const msg = frame.payload.message || frame.payload;
|
|
170
|
+
const visibleUserText = this.extractVisibleUserText(msg);
|
|
171
|
+
if (visibleUserText) {
|
|
172
|
+
if (!this.agentEvents.has(rawKey))
|
|
173
|
+
this.agentEvents.set(rawKey, []);
|
|
174
|
+
this.agentEvents.get(rawKey).push({
|
|
175
|
+
runId: frame.payload.runId,
|
|
176
|
+
sessionKey: rawKey,
|
|
177
|
+
stream: "sessionUser",
|
|
178
|
+
data: { text: visibleUserText },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const visibleAssistantText = this.extractVisibleAssistantText(msg);
|
|
182
|
+
if (visibleAssistantText) {
|
|
183
|
+
if (!this.agentEvents.has(rawKey))
|
|
184
|
+
this.agentEvents.set(rawKey, []);
|
|
185
|
+
this.agentEvents.get(rawKey).push({
|
|
186
|
+
runId: frame.payload.runId,
|
|
187
|
+
sessionKey: rawKey,
|
|
188
|
+
stream: "assistant",
|
|
189
|
+
data: { deltaText: visibleAssistantText, delta: visibleAssistantText, replace: true },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
167
192
|
this.handleProactiveSessionMessage(rawKey, msg);
|
|
168
193
|
// Tool calls in assistant messages — skip, using agent item events instead
|
|
169
194
|
// (session.message toolCall events are batched, not real-time)
|
|
@@ -196,39 +221,50 @@ export class OpenClawClient {
|
|
|
196
221
|
});
|
|
197
222
|
});
|
|
198
223
|
}
|
|
224
|
+
extractVisibleUserText(msg) {
|
|
225
|
+
if (msg?.role !== "user")
|
|
226
|
+
return "";
|
|
227
|
+
const content = msg.content;
|
|
228
|
+
if (typeof content === "string")
|
|
229
|
+
return content.trim();
|
|
230
|
+
if (!Array.isArray(content))
|
|
231
|
+
return "";
|
|
232
|
+
return content
|
|
233
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
234
|
+
.map((part) => part.text)
|
|
235
|
+
.join("\n")
|
|
236
|
+
.trim();
|
|
237
|
+
}
|
|
238
|
+
extractVisibleAssistantText(msg) {
|
|
239
|
+
if (msg?.role !== "assistant")
|
|
240
|
+
return "";
|
|
241
|
+
const content = msg.content;
|
|
242
|
+
// Extract only visible text parts and ignore thinking/tool blocks.
|
|
243
|
+
if (typeof content === "string")
|
|
244
|
+
return content.trim();
|
|
245
|
+
if (!Array.isArray(content))
|
|
246
|
+
return "";
|
|
247
|
+
const hasToolBlock = content.some((part) => {
|
|
248
|
+
const type = String(part?.type || "").toLowerCase();
|
|
249
|
+
return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
|
|
250
|
+
});
|
|
251
|
+
// Do not deliver mixed text+toolCall assistant messages through the
|
|
252
|
+
// final-text path; those are usually intermediate tool-loop status.
|
|
253
|
+
if (hasToolBlock)
|
|
254
|
+
return "";
|
|
255
|
+
return content
|
|
256
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
257
|
+
.map((part) => part.text)
|
|
258
|
+
.join("\n")
|
|
259
|
+
.trim();
|
|
260
|
+
}
|
|
199
261
|
handleProactiveSessionMessage(rawKey, msg) {
|
|
200
262
|
const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
|
|
201
|
-
const role = msg.role;
|
|
202
|
-
const content = msg.content;
|
|
203
263
|
// Proactive assistant text messages. Cron/session-targeted runs often emit
|
|
204
264
|
// structured content arrays rather than a plain string. Extract only visible
|
|
205
265
|
// text parts and ignore thinking/tool blocks so the bridge can deliver final
|
|
206
266
|
// cron results via the bot.
|
|
207
|
-
|
|
208
|
-
if (role === "assistant") {
|
|
209
|
-
if (typeof content === "string") {
|
|
210
|
-
proactiveText = content;
|
|
211
|
-
}
|
|
212
|
-
else if (Array.isArray(content)) {
|
|
213
|
-
const hasToolBlock = content.some((part) => {
|
|
214
|
-
const type = String(part?.type || "").toLowerCase();
|
|
215
|
-
return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
|
|
216
|
-
});
|
|
217
|
-
// Do not deliver mixed text+toolCall assistant messages through
|
|
218
|
-
// the proactive final-text path; those are usually intermediate
|
|
219
|
-
// reasoning/status during a tool loop. Tool calls are still
|
|
220
|
-
// delivered via the verbose channel from agent item events when
|
|
221
|
-
// /verbose is enabled. Cron final messages arrive as text-only
|
|
222
|
-
// (optionally with thinking).
|
|
223
|
-
if (!hasToolBlock) {
|
|
224
|
-
proactiveText = content
|
|
225
|
-
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
226
|
-
.map((part) => part.text)
|
|
227
|
-
.join("\n")
|
|
228
|
-
.trim();
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
267
|
+
const proactiveText = this.extractVisibleAssistantText(msg);
|
|
232
268
|
if (!proactiveText)
|
|
233
269
|
return false;
|
|
234
270
|
if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey)) {
|
|
@@ -293,12 +329,28 @@ export class OpenClawClient {
|
|
|
293
329
|
let text = "";
|
|
294
330
|
let chatDeltaText = "";
|
|
295
331
|
let chatFinalText = "";
|
|
296
|
-
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
|
|
332
|
+
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
|
|
333
|
+
let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
|
|
297
334
|
let chatFinalTimer = null;
|
|
298
335
|
let lifecycleEndTimer = null;
|
|
299
336
|
let replayInvalidTimer = null;
|
|
337
|
+
let pendingRuntimeFailureText = "";
|
|
338
|
+
let lastActivitySummary = "";
|
|
339
|
+
let lastActivityAt = 0;
|
|
300
340
|
const collectStartedAt = Date.now();
|
|
301
341
|
let lifecycleStartedLogged = false;
|
|
342
|
+
const expectedUserText = options?.expectedUserText?.trim() || "";
|
|
343
|
+
let anchorSeen = !expectedUserText;
|
|
344
|
+
const activeRunIds = new Set([runId]);
|
|
345
|
+
let preAnchorEvents = [];
|
|
346
|
+
const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
|
|
347
|
+
const isExpectedUserText = (value) => {
|
|
348
|
+
const actual = normalizeText(value);
|
|
349
|
+
const expected = normalizeText(expectedUserText);
|
|
350
|
+
if (!actual || !expected)
|
|
351
|
+
return false;
|
|
352
|
+
return actual === expected || actual.includes(expected) || expected.includes(actual);
|
|
353
|
+
};
|
|
302
354
|
let idleTimer;
|
|
303
355
|
const resetIdleTimer = () => {
|
|
304
356
|
if (idleTimer)
|
|
@@ -315,9 +367,69 @@ export class OpenClawClient {
|
|
|
315
367
|
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
316
368
|
console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
|
|
317
369
|
});
|
|
318
|
-
|
|
370
|
+
const visibleText = text || chatDeltaText || chatFinalText;
|
|
371
|
+
if (visibleText) {
|
|
372
|
+
resolve(visibleText);
|
|
373
|
+
}
|
|
374
|
+
else if (pendingRuntimeFailureText) {
|
|
375
|
+
resolve(`${pendingRuntimeFailureText}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
|
|
376
|
+
}
|
|
377
|
+
else if (lastActivitySummary) {
|
|
378
|
+
resolve(`⚠️ Agent 长时间没有产生最终回复\n最后活动: ${lastActivitySummary}\n时间: ${new Date(lastActivityAt).toLocaleString()}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
resolve("(timeout: no reply received)");
|
|
382
|
+
}
|
|
319
383
|
}, timeoutMs);
|
|
320
384
|
};
|
|
385
|
+
const summarizeActivity = (ev) => {
|
|
386
|
+
if (ev.stream === "item") {
|
|
387
|
+
const data = ev.data || {};
|
|
388
|
+
if (data.kind === "tool") {
|
|
389
|
+
const phase = data.phase ? ` ${data.phase}` : "";
|
|
390
|
+
const meta = typeof data.meta === "string" && data.meta.trim() ? `: ${data.meta.trim().slice(0, 300)}` : "";
|
|
391
|
+
return `工具${phase}${data.name ? ` ${data.name}` : ""}${meta}`;
|
|
392
|
+
}
|
|
393
|
+
return `运行事件 item${data.kind ? `/${data.kind}` : ""}`;
|
|
394
|
+
}
|
|
395
|
+
if (ev.stream === "assistant" || ev.stream === "chatDelta") {
|
|
396
|
+
const chunk = String(ev.data?.deltaText || ev.data?.delta || "").trim();
|
|
397
|
+
return chunk ? `模型输出片段: ${chunk.slice(0, 300)}` : "模型输出片段";
|
|
398
|
+
}
|
|
399
|
+
if (ev.stream === "chatFinal")
|
|
400
|
+
return "收到 chat final 事件";
|
|
401
|
+
if (ev.stream === "lifecycle") {
|
|
402
|
+
const state = ev.data?.livenessState || ev.data?.status || ev.data?.phase || "unknown";
|
|
403
|
+
const reason = ev.data?.stopReason ? `, reason=${ev.data.stopReason}` : "";
|
|
404
|
+
const replay = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
405
|
+
return `运行状态: ${state}${replay}${reason}`;
|
|
406
|
+
}
|
|
407
|
+
return `事件: ${ev.stream || "unknown"}`;
|
|
408
|
+
};
|
|
409
|
+
const rememberActivity = (ev) => {
|
|
410
|
+
// A replay-invalid lifecycle end is the terminal symptom, not the useful
|
|
411
|
+
// last activity. Keep the previous tool/model activity so user-visible
|
|
412
|
+
// timeout messages explain what the agent was actually doing.
|
|
413
|
+
if (ev.stream === "lifecycle" && ev.data?.phase === "end" && ev.data?.replayInvalid && lastActivitySummary)
|
|
414
|
+
return;
|
|
415
|
+
const summary = summarizeActivity(ev);
|
|
416
|
+
if (summary) {
|
|
417
|
+
lastActivitySummary = summary;
|
|
418
|
+
lastActivityAt = Date.now();
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const buildFailureText = (ev) => {
|
|
422
|
+
const state = ev.data?.livenessState || ev.data?.status || "unknown";
|
|
423
|
+
const reason = ev.data?.stopReason || "";
|
|
424
|
+
const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
425
|
+
const lines = [`⚠️ Agent 未正常完成`, `状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}`];
|
|
426
|
+
if (lastActivitySummary) {
|
|
427
|
+
lines.push(`最后活动: ${lastActivitySummary}`);
|
|
428
|
+
lines.push(`最后活动时间: ${new Date(lastActivityAt).toLocaleString()}`);
|
|
429
|
+
}
|
|
430
|
+
lines.push("请重试,或用 /reset 重置会话");
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
};
|
|
321
433
|
resetIdleTimer();
|
|
322
434
|
const finish = (finalText) => {
|
|
323
435
|
clearTimeout(idleTimer);
|
|
@@ -332,7 +444,7 @@ export class OpenClawClient {
|
|
|
332
444
|
};
|
|
333
445
|
const poller = setInterval(() => {
|
|
334
446
|
const bucketsToScan = sessionKey
|
|
335
|
-
? [sessionKey]
|
|
447
|
+
? Array.from(new Set([sessionKey, shortSessionKey].filter(Boolean)))
|
|
336
448
|
: Array.from(this.agentEvents.keys());
|
|
337
449
|
for (const bucketKey of bucketsToScan) {
|
|
338
450
|
const bucket = this.agentEvents.get(bucketKey);
|
|
@@ -342,18 +454,74 @@ export class OpenClawClient {
|
|
|
342
454
|
while (i < bucket.length) {
|
|
343
455
|
const ev = bucket[i];
|
|
344
456
|
const evRunId = typeof ev.runId === "string" ? ev.runId : "";
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
457
|
+
const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey));
|
|
458
|
+
const matchesRun = evRunId ? activeRunIds.has(evRunId) : false;
|
|
459
|
+
if (!sessionKey && ev.sessionKey) {
|
|
460
|
+
shortSessionKey = String(ev.sessionKey).replace(/^agent:[^:]+:/, "");
|
|
461
|
+
sessionKey = `agent:main:${shortSessionKey}`;
|
|
462
|
+
}
|
|
463
|
+
if (eventSessionMatches && ev.stream === "sessionUser") {
|
|
464
|
+
bucket.splice(i, 1);
|
|
465
|
+
if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
|
|
466
|
+
anchorSeen = true;
|
|
467
|
+
resetIdleTimer();
|
|
468
|
+
for (const pendingEv of preAnchorEvents) {
|
|
469
|
+
const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
|
|
470
|
+
if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
|
|
471
|
+
activeRunIds.add(pendingRunId);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const replay = preAnchorEvents.filter((pendingEv) => {
|
|
475
|
+
const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
|
|
476
|
+
return !pendingRunId || activeRunIds.has(pendingRunId);
|
|
477
|
+
});
|
|
478
|
+
preAnchorEvents = [];
|
|
479
|
+
if (replay.length)
|
|
480
|
+
bucket.splice(i, 0, ...replay);
|
|
481
|
+
}
|
|
482
|
+
else if (!anchorSeen) {
|
|
483
|
+
// A different user/runtime continuation belongs to stale queued work;
|
|
484
|
+
// anything before it should not be attributed to the next real user turn.
|
|
485
|
+
preAnchorEvents = [];
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
|
|
490
|
+
bucket.splice(i, 1);
|
|
491
|
+
preAnchorEvents.push(ev);
|
|
492
|
+
if (preAnchorEvents.length > 200)
|
|
493
|
+
preAnchorEvents.shift();
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
let matchesSession = false;
|
|
497
|
+
if (eventSessionMatches && anchorSeen) {
|
|
498
|
+
if (!expectedUserText) {
|
|
499
|
+
// Backward-compatible mode for tests/internal callers that do not
|
|
500
|
+
// provide an anchor: allow session-key matching as before.
|
|
501
|
+
matchesSession = true;
|
|
502
|
+
}
|
|
503
|
+
else if (!evRunId) {
|
|
504
|
+
matchesSession = true;
|
|
505
|
+
}
|
|
506
|
+
else if (activeRunIds.has(evRunId)) {
|
|
507
|
+
matchesSession = true;
|
|
508
|
+
}
|
|
509
|
+
else if (ev.stream === "lifecycle" && ev.data?.phase === "start") {
|
|
510
|
+
activeRunIds.add(evRunId);
|
|
511
|
+
matchesSession = true;
|
|
512
|
+
}
|
|
354
513
|
}
|
|
355
|
-
|
|
356
|
-
|
|
514
|
+
if (!(matchesRun || matchesSession)) {
|
|
515
|
+
// When collectReply is anchored to a specific user message, discard stale
|
|
516
|
+
// session events that arrive before that anchor (or from unrelated runIds)
|
|
517
|
+
// so replayInvalid/aborted events from older runtime continuations cannot
|
|
518
|
+
// be misattributed to the current user turn.
|
|
519
|
+
if (eventSessionMatches && expectedUserText) {
|
|
520
|
+
bucket.splice(i, 1);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
i++;
|
|
524
|
+
}
|
|
357
525
|
continue;
|
|
358
526
|
}
|
|
359
527
|
bucket.splice(i, 1);
|
|
@@ -361,12 +529,16 @@ export class OpenClawClient {
|
|
|
361
529
|
// means the agent is still alive. Use an idle timeout, not an absolute
|
|
362
530
|
// wall-clock timeout, so long tool-heavy tasks are not killed while active.
|
|
363
531
|
resetIdleTimer();
|
|
532
|
+
rememberActivity(ev);
|
|
364
533
|
// If more events arrive after a replay-invalid lifecycle end, that lifecycle
|
|
365
534
|
// was not terminal for the user-visible run. Keep waiting for the real final.
|
|
366
535
|
if (replayInvalidTimer) {
|
|
367
536
|
clearTimeout(replayInvalidTimer);
|
|
368
537
|
replayInvalidTimer = null;
|
|
369
538
|
}
|
|
539
|
+
if (ev.stream !== "lifecycle") {
|
|
540
|
+
pendingRuntimeFailureText = "";
|
|
541
|
+
}
|
|
370
542
|
if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
|
|
371
543
|
lifecycleStartedLogged = true;
|
|
372
544
|
console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
|
|
@@ -433,13 +605,10 @@ export class OpenClawClient {
|
|
|
433
605
|
return;
|
|
434
606
|
}
|
|
435
607
|
if (!latestFinalText) {
|
|
436
|
-
const
|
|
437
|
-
const reason = ev.data?.stopReason || "";
|
|
438
|
-
const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
|
|
439
|
-
const failureText = `⚠️ Agent 未正常完成\n状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}\n请重试,或用 /reset 重置会话`;
|
|
608
|
+
const failureText = buildFailureText(ev);
|
|
440
609
|
if (ev.data?.replayInvalid) {
|
|
441
|
-
|
|
442
|
-
|
|
610
|
+
pendingRuntimeFailureText = failureText;
|
|
611
|
+
console.warn(`[OpenClaw] replayInvalid lifecycle observed for runId=${evRunId || runId}; waiting for real text or idle timeout`);
|
|
443
612
|
return;
|
|
444
613
|
}
|
|
445
614
|
if (ev.data?.livenessState !== "working") {
|
|
@@ -453,7 +622,7 @@ export class OpenClawClient {
|
|
|
453
622
|
};
|
|
454
623
|
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
455
624
|
// final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
|
|
456
|
-
if (!options?.emptyFinalAsNoReply && !chatFinalText && text.length <= 1) {
|
|
625
|
+
if (!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1) {
|
|
457
626
|
lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
|
|
458
627
|
}
|
|
459
628
|
else {
|
|
@@ -679,7 +848,7 @@ export class OpenClawClient {
|
|
|
679
848
|
idempotencyKey: randomUUID(),
|
|
680
849
|
});
|
|
681
850
|
console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
|
|
682
|
-
return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply });
|
|
851
|
+
return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
|
|
683
852
|
}
|
|
684
853
|
finally {
|
|
685
854
|
// OpenClaw can emit the final assistant session.message a moment after
|
|
@@ -733,20 +902,30 @@ export class OpenClawClient {
|
|
|
733
902
|
emptyFinalAsNoReply: params.emptyFinalAsNoReply,
|
|
734
903
|
});
|
|
735
904
|
}
|
|
736
|
-
// Build context block + actual message in one chat.send
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
905
|
+
// Build context block + actual message in one chat.send, or write the
|
|
906
|
+
// full context to a local transcript file when it is too large.
|
|
907
|
+
const contextLines = this.formatContextLines(params.unsyncedMessages);
|
|
908
|
+
const inlineContext = contextLines.join("\n");
|
|
909
|
+
const inlineBytes = Buffer.byteLength(inlineContext, "utf8");
|
|
910
|
+
const useFileContext = params.unsyncedMessages.length > MAX_INLINE_CONTEXT_MESSAGES || inlineBytes > MAX_INLINE_CONTEXT_BYTES;
|
|
911
|
+
let combined;
|
|
912
|
+
if (useFileContext) {
|
|
913
|
+
const filePath = this.writeContextSyncFile(params.sessionKey, params.unsyncedMessages, contextLines);
|
|
914
|
+
combined =
|
|
915
|
+
`[以下是你不在线期间群里的长历史上下文,因消息数或大小超过直接内联阈值,已写入本地文件]\n` +
|
|
916
|
+
`文件路径:${filePath}\n\n` +
|
|
917
|
+
`你必须先使用 read 工具读取这个文件,理解其中的完整群聊历史,再回答当前消息。\n` +
|
|
918
|
+
`如果无法读取文件,请明确说明,不能直接忽略历史上下文作答。\n` +
|
|
919
|
+
`\n---\n` +
|
|
920
|
+
`[${params.currentSenderName}]: ${params.currentMessage}`;
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
combined =
|
|
924
|
+
`[以下是你不在线期间群里的对话,请了解上下文]\n` +
|
|
925
|
+
inlineContext +
|
|
926
|
+
`\n---\n` +
|
|
927
|
+
`[${params.currentSenderName}]: ${params.currentMessage}`;
|
|
928
|
+
}
|
|
750
929
|
return this.chatSend({
|
|
751
930
|
sessionKey: params.sessionKey,
|
|
752
931
|
message: combined + mediaInstruction + bridgeAttachmentHint,
|
|
@@ -756,6 +935,42 @@ export class OpenClawClient {
|
|
|
756
935
|
emptyFinalAsNoReply: params.emptyFinalAsNoReply,
|
|
757
936
|
});
|
|
758
937
|
}
|
|
938
|
+
formatContextLines(messages) {
|
|
939
|
+
return messages.map((m) => {
|
|
940
|
+
const time = new Date(m.timestamp).toLocaleString("zh-CN", {
|
|
941
|
+
month: "2-digit",
|
|
942
|
+
day: "2-digit",
|
|
943
|
+
hour: "2-digit",
|
|
944
|
+
minute: "2-digit",
|
|
945
|
+
hour12: false,
|
|
946
|
+
});
|
|
947
|
+
const tag = m.senderType === "bot" ? `${m.senderName} (AI)` : m.senderName;
|
|
948
|
+
return `[${tag} ${time}]: ${m.content}`;
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
writeContextSyncFile(sessionKey, messages, contextLines) {
|
|
952
|
+
const safeSessionKey = sessionKey.replace(/[^a-zA-Z0-9_.-]+/g, "_");
|
|
953
|
+
const dir = join(CONTEXT_SYNC_DIR, safeSessionKey);
|
|
954
|
+
mkdirSync(dir, { recursive: true });
|
|
955
|
+
const filePath = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}.md`);
|
|
956
|
+
const first = messages[0];
|
|
957
|
+
const last = messages[messages.length - 1];
|
|
958
|
+
const markdown = [
|
|
959
|
+
`# LMA 群聊历史上下文同步`,
|
|
960
|
+
``,
|
|
961
|
+
`- Session: ${sessionKey}`,
|
|
962
|
+
`- Messages: ${messages.length}`,
|
|
963
|
+
`- Range: ${first?.id ?? "?"} → ${last?.id ?? "?"}`,
|
|
964
|
+
`- Generated: ${new Date().toISOString()}`,
|
|
965
|
+
``,
|
|
966
|
+
`## Messages`,
|
|
967
|
+
``,
|
|
968
|
+
contextLines.join("\n\n"),
|
|
969
|
+
``,
|
|
970
|
+
].join("\n");
|
|
971
|
+
writeFileSync(filePath, markdown, "utf8");
|
|
972
|
+
return filePath;
|
|
973
|
+
}
|
|
759
974
|
extractImageAttachments(contents) {
|
|
760
975
|
const attachments = [];
|
|
761
976
|
const seen = new Set();
|