openclaw-lark-multi-agent 1.0.22 → 1.1.0
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.d.ts +29 -0
- package/dist/feishu-bot.js +264 -21
- package/dist/live-status.d.ts +66 -25
- package/dist/live-status.js +179 -77
- package/dist/message-store.d.ts +6 -0
- package/dist/message-store.js +57 -0
- package/dist/openclaw-client.d.ts +9 -0
- package/dist/openclaw-client.js +33 -1
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export declare class FeishuBot {
|
|
|
40
40
|
private locale;
|
|
41
41
|
private configPath?;
|
|
42
42
|
private static allBots;
|
|
43
|
+
/** Chats where this bot app has actually received an event in this process. */
|
|
44
|
+
private static seenBotChats;
|
|
43
45
|
constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string, configPath?: string);
|
|
44
46
|
private handleMessageRecalled;
|
|
45
47
|
register(): void;
|
|
@@ -75,6 +77,20 @@ export declare class FeishuBot {
|
|
|
75
77
|
private handleMessage;
|
|
76
78
|
private shouldUseLiveStatus;
|
|
77
79
|
private getUnhealthySessionStatus;
|
|
80
|
+
/**
|
|
81
|
+
* Distinguish SESSION death from RUN failure. `sessions.describe` reports the
|
|
82
|
+
* status of the most recent run, not whether the session is usable. A run can
|
|
83
|
+
* end as `aborted`/`error`/`failed`/`timeout` (e.g. an LLM idle timeout) while
|
|
84
|
+
* the session itself stays perfectly usable for the next message — confirmed in
|
|
85
|
+
* the wild: LMA reported "killed" but `/status` immediately showed running.
|
|
86
|
+
*
|
|
87
|
+
* Only treat the session as dead for statuses that mean the session itself is
|
|
88
|
+
* gone and a new run cannot start without /reset. Use word-boundary matching so
|
|
89
|
+
* a run-level "error" substring never trips this. Run-level failures are NOT
|
|
90
|
+
* surfaced here: the work promise will either return the (possibly empty) reply
|
|
91
|
+
* or throw, and that is handled by the normal delivery/error path.
|
|
92
|
+
*/
|
|
93
|
+
private isSessionDeadStatus;
|
|
78
94
|
private warnUnhealthySessionAndContinue;
|
|
79
95
|
private stopForUnhealthySession;
|
|
80
96
|
private withSessionHealthMonitor;
|
|
@@ -107,6 +123,7 @@ export declare class FeishuBot {
|
|
|
107
123
|
private deliverySessionKey;
|
|
108
124
|
private stableHash;
|
|
109
125
|
private deliverySourceId;
|
|
126
|
+
private sendSystemDelivery;
|
|
110
127
|
private enqueueAndDispatchDelivery;
|
|
111
128
|
private dispatchPendingDeliveries;
|
|
112
129
|
private hasFreeModeBot;
|
|
@@ -123,10 +140,20 @@ export declare class FeishuBot {
|
|
|
123
140
|
*/
|
|
124
141
|
private resolveChairmanTargets;
|
|
125
142
|
private isDiscussionCoordinator;
|
|
143
|
+
private static markBotSeenInChat;
|
|
144
|
+
private markCurrentBotSeenInChat;
|
|
145
|
+
private isBotAvailableInChat;
|
|
126
146
|
private getDiscussionParticipants;
|
|
127
147
|
private getChairmanParticipant;
|
|
128
148
|
private asDiscussionParticipant;
|
|
129
149
|
private runDiscussionTurn;
|
|
150
|
+
private buildLiveStatusCard;
|
|
151
|
+
/** Minimal escaping so activity text does not break card markdown. */
|
|
152
|
+
private escapeCardText;
|
|
153
|
+
private sendLiveStatusCard;
|
|
154
|
+
private replyLiveStatusCard;
|
|
155
|
+
private patchLiveStatusCard;
|
|
156
|
+
private sendTextMessage;
|
|
130
157
|
private replyTextMessage;
|
|
131
158
|
private editTextMessage;
|
|
132
159
|
private deleteMessageById;
|
|
@@ -137,6 +164,8 @@ export declare class FeishuBot {
|
|
|
137
164
|
private inferFeishuFileType;
|
|
138
165
|
private isImagePath;
|
|
139
166
|
private errorSummary;
|
|
167
|
+
private isOutOfChatError;
|
|
168
|
+
private markCurrentBotUnavailable;
|
|
140
169
|
private stripReadOnlyDocxFields;
|
|
141
170
|
private createFeishuDocFromMarkdown;
|
|
142
171
|
private sendBridgeAttachment;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -16,6 +16,7 @@ const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MA
|
|
|
16
16
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
17
17
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
18
18
|
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
19
|
+
const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
|
|
19
20
|
class UnhealthySessionError extends Error {
|
|
20
21
|
status;
|
|
21
22
|
constructor(status) {
|
|
@@ -63,6 +64,8 @@ export class FeishuBot {
|
|
|
63
64
|
locale;
|
|
64
65
|
configPath;
|
|
65
66
|
static allBots = new Map();
|
|
67
|
+
/** Chats where this bot app has actually received an event in this process. */
|
|
68
|
+
static seenBotChats = new Map();
|
|
66
69
|
constructor(config, openclawClient, store, adminOpenId, configPath) {
|
|
67
70
|
this.config = config;
|
|
68
71
|
this.openclawClient = openclawClient;
|
|
@@ -192,7 +195,17 @@ export class FeishuBot {
|
|
|
192
195
|
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
|
|
193
196
|
this.cancelDelayedFailure(chatId);
|
|
194
197
|
const activeTarget = this.activeDeliveryTargets.get(chatId);
|
|
195
|
-
|
|
198
|
+
try {
|
|
199
|
+
await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, undefined);
|
|
200
|
+
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
|
|
201
|
+
await activeTarget?.liveStatus?.complete().catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
if (sourceType !== "verbose_transcript")
|
|
206
|
+
await activeTarget?.liveStatus?.fail().catch(() => { });
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
196
209
|
}
|
|
197
210
|
catch (err) {
|
|
198
211
|
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
@@ -330,6 +343,7 @@ export class FeishuBot {
|
|
|
330
343
|
const message = event.message;
|
|
331
344
|
const sender = event.sender;
|
|
332
345
|
const chatId = message.chat_id;
|
|
346
|
+
this.markCurrentBotSeenInChat(chatId);
|
|
333
347
|
const chatType = message.chat_type;
|
|
334
348
|
const messageType = message.message_type;
|
|
335
349
|
const messageId = message.message_id;
|
|
@@ -486,6 +500,7 @@ export class FeishuBot {
|
|
|
486
500
|
const isChairmanCommand = commandName === "/chairman";
|
|
487
501
|
const isLocaleCommand = commandName === "/locale";
|
|
488
502
|
const isModelCommand = commandName === "/model";
|
|
503
|
+
const isHelpCommand = commandName === "/help";
|
|
489
504
|
const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
|
|
490
505
|
if (chatType !== "p2p") {
|
|
491
506
|
const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
|
|
@@ -557,6 +572,13 @@ export class FeishuBot {
|
|
|
557
572
|
if (!this.isDiscussionCoordinator())
|
|
558
573
|
return;
|
|
559
574
|
}
|
|
575
|
+
else if (isHelpCommand && !routing.hasTargetedMention) {
|
|
576
|
+
// Bare or @all /help in a multi-bot group should never be silent or
|
|
577
|
+
// spammy. Let the coordinator answer once instead of requiring users
|
|
578
|
+
// to know they must @ a bot before they can discover the command list.
|
|
579
|
+
if (!this.isDiscussionCoordinator())
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
560
582
|
else if (isDiscussCommand && !routing.hasTargetedMention) {
|
|
561
583
|
// Untargeted or @all /discuss is group-level; one coordinator handles it.
|
|
562
584
|
if (!this.isDiscussionCoordinator())
|
|
@@ -787,25 +809,25 @@ export class FeishuBot {
|
|
|
787
809
|
maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
|
|
788
810
|
participants,
|
|
789
811
|
chairman,
|
|
790
|
-
sendSystemMessage: async (text) => { await this.
|
|
812
|
+
sendSystemMessage: async (text) => { await this.sendSystemDelivery(chatId, text); },
|
|
791
813
|
locale: discussionLocale,
|
|
792
814
|
onComplete: async (event) => {
|
|
793
815
|
if (event.reason === "chairman_final") {
|
|
794
816
|
this.store.setDiscussMode(event.chatId, false);
|
|
795
|
-
await this.
|
|
817
|
+
await this.sendSystemDelivery(event.chatId, this.isEn(event.chatId)
|
|
796
818
|
? `💬 Discuss ended: Chairman ${event.chairmanName || ""} completed the final summary. Discuss mode has been turned off automatically.`
|
|
797
819
|
: `💬 Discuss 已结束:Chairman ${event.chairmanName || ""} 已完成总结,已自动关闭 Discuss 模式。`);
|
|
798
820
|
}
|
|
799
821
|
},
|
|
800
822
|
});
|
|
801
823
|
if (started && preempted) {
|
|
802
|
-
await this.
|
|
824
|
+
await this.sendSystemDelivery(chatId, this.isEn(chatId)
|
|
803
825
|
? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
|
|
804
826
|
: `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
|
|
805
827
|
}
|
|
806
828
|
}
|
|
807
829
|
else if (this.isDiscussionCoordinator()) {
|
|
808
|
-
await this.
|
|
830
|
+
await this.sendSystemDelivery(chatId, this.isEn(chatId)
|
|
809
831
|
? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
|
|
810
832
|
: "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
|
|
811
833
|
}
|
|
@@ -891,9 +913,8 @@ export class FeishuBot {
|
|
|
891
913
|
const status = String(resp?.session?.status || "").toLowerCase();
|
|
892
914
|
if (!status)
|
|
893
915
|
return null;
|
|
894
|
-
if (
|
|
916
|
+
if (this.isSessionDeadStatus(status))
|
|
895
917
|
return status;
|
|
896
|
-
}
|
|
897
918
|
}
|
|
898
919
|
catch {
|
|
899
920
|
// Absence/transient describe failures should not block a new run; chat.send
|
|
@@ -901,6 +922,26 @@ export class FeishuBot {
|
|
|
901
922
|
}
|
|
902
923
|
return null;
|
|
903
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* Distinguish SESSION death from RUN failure. `sessions.describe` reports the
|
|
927
|
+
* status of the most recent run, not whether the session is usable. A run can
|
|
928
|
+
* end as `aborted`/`error`/`failed`/`timeout` (e.g. an LLM idle timeout) while
|
|
929
|
+
* the session itself stays perfectly usable for the next message — confirmed in
|
|
930
|
+
* the wild: LMA reported "killed" but `/status` immediately showed running.
|
|
931
|
+
*
|
|
932
|
+
* Only treat the session as dead for statuses that mean the session itself is
|
|
933
|
+
* gone and a new run cannot start without /reset. Use word-boundary matching so
|
|
934
|
+
* a run-level "error" substring never trips this. Run-level failures are NOT
|
|
935
|
+
* surfaced here: the work promise will either return the (possibly empty) reply
|
|
936
|
+
* or throw, and that is handled by the normal delivery/error path.
|
|
937
|
+
*/
|
|
938
|
+
isSessionDeadStatus(status) {
|
|
939
|
+
const s = status.toLowerCase();
|
|
940
|
+
// Session-level death tokens only. Notably excludes aborted/error/errored/
|
|
941
|
+
// failed/failure/timeout, which are run-level outcomes on a live session.
|
|
942
|
+
const deadTokens = ["killed", "dead", "crashed", "destroyed", "terminated", "gone"];
|
|
943
|
+
return deadTokens.some((tok) => new RegExp(`(^|[^a-z])${tok}([^a-z]|$)`).test(s));
|
|
944
|
+
}
|
|
904
945
|
async warnUnhealthySessionAndContinue(chatId, messageId, status) {
|
|
905
946
|
await this.replyMessage(messageId, this.isEn(chatId)
|
|
906
947
|
? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
|
|
@@ -934,8 +975,18 @@ export class FeishuBot {
|
|
|
934
975
|
break;
|
|
935
976
|
const unhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
936
977
|
if (unhealthy) {
|
|
937
|
-
|
|
938
|
-
|
|
978
|
+
// Session status can briefly report killed/aborted while the final
|
|
979
|
+
// response is still racing back to the bridge. Confirm once before
|
|
980
|
+
// surfacing a user-visible failure, otherwise users can see both a
|
|
981
|
+
// successful reply and a misleading "session became unhealthy" notice.
|
|
982
|
+
await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_CONFIRM_MS));
|
|
983
|
+
if (done)
|
|
984
|
+
break;
|
|
985
|
+
const confirmedUnhealthy = await this.getUnhealthySessionStatus(chatId);
|
|
986
|
+
if (confirmedUnhealthy) {
|
|
987
|
+
await this.stopForUnhealthySession(chatId, messageId, confirmedUnhealthy);
|
|
988
|
+
throw new UnhealthySessionError(confirmedUnhealthy);
|
|
989
|
+
}
|
|
939
990
|
}
|
|
940
991
|
}
|
|
941
992
|
return undefined;
|
|
@@ -1128,18 +1179,19 @@ export class FeishuBot {
|
|
|
1128
1179
|
};
|
|
1129
1180
|
liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
|
|
1130
1181
|
? new LiveStatusController({
|
|
1131
|
-
create: (
|
|
1132
|
-
edit: (messageId,
|
|
1182
|
+
create: (view) => this.sendOrdered(chatId, () => this.replyLiveStatusCard(lastHuman.messageId || "", view, chatId)),
|
|
1183
|
+
edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
|
|
1133
1184
|
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
|
|
1134
1185
|
}, {
|
|
1135
1186
|
botName: this.config.name,
|
|
1187
|
+
model: this.config.model,
|
|
1136
1188
|
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1137
|
-
disableHint: this.isEn(chatId)
|
|
1138
|
-
? `Tip: send /livestatus off to hide this status message`
|
|
1139
|
-
: `提示:调用 /livestatus off 关闭该状态提示`,
|
|
1140
1189
|
})
|
|
1141
1190
|
: undefined;
|
|
1142
1191
|
liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
|
|
1192
|
+
const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
|
|
1193
|
+
if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
|
|
1194
|
+
activeTargetForStatus.liveStatus = liveStatus;
|
|
1143
1195
|
try {
|
|
1144
1196
|
reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
|
|
1145
1197
|
sessionKey,
|
|
@@ -1188,7 +1240,7 @@ export class FeishuBot {
|
|
|
1188
1240
|
// submitted far enough that auto-replaying risks duplicate delivery.
|
|
1189
1241
|
// The user can resend a new message if they want to retry.
|
|
1190
1242
|
markSubmittedBatch("empty-returned");
|
|
1191
|
-
liveStatus?.
|
|
1243
|
+
await liveStatus?.noReply().catch(() => { });
|
|
1192
1244
|
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
|
|
1193
1245
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
1194
1246
|
const remainingAcks = [];
|
|
@@ -1259,6 +1311,7 @@ export class FeishuBot {
|
|
|
1259
1311
|
if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
|
|
1260
1312
|
if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
1261
1313
|
console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
|
|
1314
|
+
await liveStatus?.complete().catch(() => { });
|
|
1262
1315
|
}
|
|
1263
1316
|
else {
|
|
1264
1317
|
try {
|
|
@@ -1283,11 +1336,14 @@ export class FeishuBot {
|
|
|
1283
1336
|
}
|
|
1284
1337
|
}
|
|
1285
1338
|
if (isRuntimeFailure && lastHuman.messageId) {
|
|
1286
|
-
liveStatus?.
|
|
1339
|
+
await liveStatus?.fail().catch(() => { });
|
|
1287
1340
|
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
1288
1341
|
}
|
|
1289
1342
|
else if (!shouldReply && !hasAttachments) {
|
|
1290
|
-
|
|
1343
|
+
// Explicit NO_REPLY (or empty visible text): the model finished without
|
|
1344
|
+
// producing a user-visible reply. Mark the status card done with a
|
|
1345
|
+
// "no content" summary instead of leaving it stuck on "正在执行".
|
|
1346
|
+
await liveStatus?.noReply().catch(() => { });
|
|
1291
1347
|
}
|
|
1292
1348
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
1293
1349
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
@@ -1604,6 +1660,10 @@ export class FeishuBot {
|
|
|
1604
1660
|
deliverySourceId(kind, text) {
|
|
1605
1661
|
return `${kind}:${this.stableHash(text)}`;
|
|
1606
1662
|
}
|
|
1663
|
+
async sendSystemDelivery(chatId, text) {
|
|
1664
|
+
const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
1665
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
|
|
1666
|
+
}
|
|
1607
1667
|
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
|
|
1608
1668
|
if (!text.trim() && attachments.length === 0)
|
|
1609
1669
|
return;
|
|
@@ -1672,6 +1732,8 @@ export class FeishuBot {
|
|
|
1672
1732
|
this.store.markDeliveryDelivered(item.id);
|
|
1673
1733
|
}
|
|
1674
1734
|
catch (err) {
|
|
1735
|
+
if (this.isOutOfChatError(err))
|
|
1736
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
1675
1737
|
if (item.id)
|
|
1676
1738
|
this.store.markDeliveryFailed(item.id);
|
|
1677
1739
|
const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
|
|
@@ -1749,17 +1811,42 @@ export class FeishuBot {
|
|
|
1749
1811
|
return true;
|
|
1750
1812
|
return bots[0] === this;
|
|
1751
1813
|
}
|
|
1814
|
+
static markBotSeenInChat(botName, chatId) {
|
|
1815
|
+
let set = FeishuBot.seenBotChats.get(botName);
|
|
1816
|
+
if (!set) {
|
|
1817
|
+
set = new Set();
|
|
1818
|
+
FeishuBot.seenBotChats.set(botName, set);
|
|
1819
|
+
}
|
|
1820
|
+
set.add(chatId);
|
|
1821
|
+
}
|
|
1822
|
+
markCurrentBotSeenInChat(chatId) {
|
|
1823
|
+
FeishuBot.markBotSeenInChat(this.config.name, chatId);
|
|
1824
|
+
this.store.markBotSeenInChat(this.config.name, chatId);
|
|
1825
|
+
}
|
|
1826
|
+
isBotAvailableInChat(bot, chatId) {
|
|
1827
|
+
// A bot can be globally configured but not actually installed in this Feishu
|
|
1828
|
+
// group. Discuss participants must be restricted to bots that either have
|
|
1829
|
+
// received an event in this chat during this process, or have previously
|
|
1830
|
+
// delivered a message successfully in this chat. Otherwise LMA may generate
|
|
1831
|
+
// "ghost" participants and Feishu will reject delivery with 230002
|
|
1832
|
+
// (Bot/User can NOT be out of the chat).
|
|
1833
|
+
if (this.store.isBotUnavailableInChat(bot.config.name, chatId))
|
|
1834
|
+
return false;
|
|
1835
|
+
return FeishuBot.seenBotChats.get(bot.config.name)?.has(chatId)
|
|
1836
|
+
|| this.store.hasBotSeenInChat(bot.config.name, chatId)
|
|
1837
|
+
|| this.store.hasAnyDeliveredToChat(bot.config.name, chatId);
|
|
1838
|
+
}
|
|
1752
1839
|
getDiscussionParticipants(chatId) {
|
|
1753
1840
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1754
1841
|
return Array.from(FeishuBot.allBots.values())
|
|
1755
|
-
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute")
|
|
1842
|
+
.filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute" && this.isBotAvailableInChat(bot, chatId))
|
|
1756
1843
|
.map((bot) => this.asDiscussionParticipant(bot, chatId));
|
|
1757
1844
|
}
|
|
1758
1845
|
getChairmanParticipant(chatId) {
|
|
1759
1846
|
const chairman = this.store.getChairmanBot(chatId);
|
|
1760
1847
|
if (!chairman)
|
|
1761
1848
|
return undefined;
|
|
1762
|
-
const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman);
|
|
1849
|
+
const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
|
|
1763
1850
|
return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
|
|
1764
1851
|
}
|
|
1765
1852
|
asDiscussionParticipant(bot, chatId) {
|
|
@@ -1771,6 +1858,19 @@ export class FeishuBot {
|
|
|
1771
1858
|
async runDiscussionTurn(chatId, prompt, meta) {
|
|
1772
1859
|
const sessionKey = await this.ensureSession(chatId);
|
|
1773
1860
|
const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
|
|
1861
|
+
const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
|
|
1862
|
+
const liveStatus = liveStatusEnabled
|
|
1863
|
+
? new LiveStatusController({
|
|
1864
|
+
create: (view) => this.sendOrdered(chatId, () => this.sendLiveStatusCard(chatId, view)),
|
|
1865
|
+
edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
|
|
1866
|
+
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
|
|
1867
|
+
}, {
|
|
1868
|
+
botName: this.config.name,
|
|
1869
|
+
model: this.config.model,
|
|
1870
|
+
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1871
|
+
})
|
|
1872
|
+
: undefined;
|
|
1873
|
+
liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
|
|
1774
1874
|
let reply;
|
|
1775
1875
|
try {
|
|
1776
1876
|
reply = await this.openclawClient.chatSendWithContext({
|
|
@@ -1781,8 +1881,13 @@ export class FeishuBot {
|
|
|
1781
1881
|
deliver: false,
|
|
1782
1882
|
timeoutMs: 1_800_000,
|
|
1783
1883
|
emptyFinalAsNoReply: true,
|
|
1884
|
+
onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
|
|
1784
1885
|
});
|
|
1785
1886
|
}
|
|
1887
|
+
catch (err) {
|
|
1888
|
+
await liveStatus?.fail().catch(() => { });
|
|
1889
|
+
throw err;
|
|
1890
|
+
}
|
|
1786
1891
|
finally {
|
|
1787
1892
|
// OpenClaw can emit the final assistant session.message shortly after
|
|
1788
1893
|
// chatSend/collectReply returns. Keep discussion proactive muted briefly;
|
|
@@ -1815,10 +1920,127 @@ export class FeishuBot {
|
|
|
1815
1920
|
content: storedContent,
|
|
1816
1921
|
timestamp: Date.now(),
|
|
1817
1922
|
});
|
|
1818
|
-
|
|
1923
|
+
try {
|
|
1924
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
|
|
1925
|
+
}
|
|
1926
|
+
catch (err) {
|
|
1927
|
+
await liveStatus?.fail().catch(() => { });
|
|
1928
|
+
throw err;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
// Mirror the normal path: a NO_REPLY/empty discussion turn should finish the
|
|
1932
|
+
// status card with a "no content" summary, not a plain done summary.
|
|
1933
|
+
if (!isVisible && parsedReply.attachments.length === 0) {
|
|
1934
|
+
await liveStatus?.noReply().catch(() => { });
|
|
1935
|
+
}
|
|
1936
|
+
else {
|
|
1937
|
+
await liveStatus?.complete().catch(() => { });
|
|
1819
1938
|
}
|
|
1820
1939
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1821
1940
|
}
|
|
1941
|
+
buildLiveStatusCard(view, chatId) {
|
|
1942
|
+
const en = this.isEn(chatId);
|
|
1943
|
+
const elements = [];
|
|
1944
|
+
// Content area. While running: recent activity lines (each prefixed with the
|
|
1945
|
+
// relative time mm:ss since the run started). When finished: a single run
|
|
1946
|
+
// summary line (total tool calls + elapsed) instead of recent messages.
|
|
1947
|
+
if (view.lines.length > 0) {
|
|
1948
|
+
const iconFor = (kind) => kind === "tool_start" ? "▸"
|
|
1949
|
+
: kind === "tool_end" ? "✓"
|
|
1950
|
+
: kind === "lifecycle" ? "⋯"
|
|
1951
|
+
: kind === "summary" ? "📊"
|
|
1952
|
+
: "•";
|
|
1953
|
+
const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
|
|
1954
|
+
const content = view.lines
|
|
1955
|
+
.map((l) => {
|
|
1956
|
+
const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
|
|
1957
|
+
return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
|
|
1958
|
+
})
|
|
1959
|
+
.join("\n");
|
|
1960
|
+
elements.push({ tag: "markdown", content });
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
|
|
1964
|
+
}
|
|
1965
|
+
elements.push({ tag: "hr" });
|
|
1966
|
+
// Footer: elapsed time + model name.
|
|
1967
|
+
const footerBits = [];
|
|
1968
|
+
footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
|
|
1969
|
+
if (view.model)
|
|
1970
|
+
footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
|
|
1971
|
+
elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
|
|
1972
|
+
// Title color reflects state.
|
|
1973
|
+
const template = view.state === "done" ? "green" : view.state === "failed" ? "orange" : "blue";
|
|
1974
|
+
return {
|
|
1975
|
+
schema: "2.0",
|
|
1976
|
+
// update_multi is REQUIRED for im.message.patch to update the card; Feishu
|
|
1977
|
+
// rejects patches on cards that did not declare it before and after.
|
|
1978
|
+
config: { update_multi: true, width_mode: "fill" },
|
|
1979
|
+
header: {
|
|
1980
|
+
title: { tag: "plain_text", content: view.title },
|
|
1981
|
+
template,
|
|
1982
|
+
},
|
|
1983
|
+
body: { elements },
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
/** Minimal escaping so activity text does not break card markdown. */
|
|
1987
|
+
escapeCardText(text) {
|
|
1988
|
+
return String(text || "").replace(/</g, "\uff1c").replace(/>/g, "\uff1e");
|
|
1989
|
+
}
|
|
1990
|
+
async sendLiveStatusCard(chatId, view) {
|
|
1991
|
+
try {
|
|
1992
|
+
const res = await this.client.im.message.create({
|
|
1993
|
+
params: { receive_id_type: "chat_id" },
|
|
1994
|
+
data: {
|
|
1995
|
+
receive_id: chatId,
|
|
1996
|
+
content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
|
|
1997
|
+
msg_type: "interactive",
|
|
1998
|
+
},
|
|
1999
|
+
});
|
|
2000
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2001
|
+
return res?.data?.message_id || res?.message_id;
|
|
2002
|
+
}
|
|
2003
|
+
catch (err) {
|
|
2004
|
+
if (this.isOutOfChatError(err))
|
|
2005
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
2006
|
+
throw err;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
async replyLiveStatusCard(messageId, view, chatId) {
|
|
2010
|
+
const res = await this.client.im.message.reply({
|
|
2011
|
+
path: { message_id: messageId },
|
|
2012
|
+
data: {
|
|
2013
|
+
content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
|
|
2014
|
+
msg_type: "interactive",
|
|
2015
|
+
},
|
|
2016
|
+
});
|
|
2017
|
+
return res?.data?.message_id || res?.message_id;
|
|
2018
|
+
}
|
|
2019
|
+
async patchLiveStatusCard(messageId, view, chatId) {
|
|
2020
|
+
await this.client.im.message.patch({
|
|
2021
|
+
path: { message_id: messageId },
|
|
2022
|
+
data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
async sendTextMessage(chatId, text) {
|
|
2026
|
+
try {
|
|
2027
|
+
const res = await this.client.im.message.create({
|
|
2028
|
+
params: { receive_id_type: "chat_id" },
|
|
2029
|
+
data: {
|
|
2030
|
+
receive_id: chatId,
|
|
2031
|
+
content: JSON.stringify({ text }),
|
|
2032
|
+
msg_type: "text",
|
|
2033
|
+
},
|
|
2034
|
+
});
|
|
2035
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2036
|
+
return res?.data?.message_id || res?.message_id;
|
|
2037
|
+
}
|
|
2038
|
+
catch (err) {
|
|
2039
|
+
if (this.isOutOfChatError(err))
|
|
2040
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
2041
|
+
throw err;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
1822
2044
|
async replyTextMessage(messageId, text) {
|
|
1823
2045
|
const res = await this.client.im.message.reply({
|
|
1824
2046
|
path: { message_id: messageId },
|
|
@@ -1868,7 +2090,12 @@ export class FeishuBot {
|
|
|
1868
2090
|
}
|
|
1869
2091
|
extractBridgeAttachments(reply) {
|
|
1870
2092
|
const attachments = [];
|
|
1871
|
-
|
|
2093
|
+
// Some models occasionally corrupt the attachment marker while preserving
|
|
2094
|
+
// the exact JSON payload: the closing tag can become </parameter>, or the
|
|
2095
|
+
// opening "<LMA" prefix can be truncated leaving "_BRIDGE_ATTACHMENTS>".
|
|
2096
|
+
// Treat these as recoverable shapes; otherwise the marker leaks as
|
|
2097
|
+
// user-visible text and attachments are not sent.
|
|
2098
|
+
const markerPattern = /(?:<LMA_BRIDGE_ATTACHMENTS>|_BRIDGE_ATTACHMENTS>)([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
|
|
1872
2099
|
let text = reply.replace(markerPattern, (_match, jsonText) => {
|
|
1873
2100
|
try {
|
|
1874
2101
|
const parsed = JSON.parse(String(jsonText).trim());
|
|
@@ -1938,6 +2165,16 @@ export class FeishuBot {
|
|
|
1938
2165
|
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
1939
2166
|
return `${code}${msg}`.slice(0, 800);
|
|
1940
2167
|
}
|
|
2168
|
+
isOutOfChatError(err) {
|
|
2169
|
+
const data = err?.response?.data || err?.data;
|
|
2170
|
+
const code = data?.code;
|
|
2171
|
+
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
2172
|
+
return Number(code) === 230002 || /Bot\/User can NOT be out of the chat/i.test(String(msg));
|
|
2173
|
+
}
|
|
2174
|
+
markCurrentBotUnavailable(chatId, err) {
|
|
2175
|
+
this.store.markBotUnavailableInChat(this.config.name, chatId, this.errorSummary(err));
|
|
2176
|
+
console.warn(`[${this.config.name}] Marked unavailable in chat ${chatId.slice(-8)} after Feishu out-of-chat error`);
|
|
2177
|
+
}
|
|
1941
2178
|
stripReadOnlyDocxFields(value) {
|
|
1942
2179
|
if (Array.isArray(value))
|
|
1943
2180
|
return value.map((item) => this.stripReadOnlyDocxFields(item));
|
|
@@ -2052,10 +2289,13 @@ export class FeishuBot {
|
|
|
2052
2289
|
msg_type: "interactive",
|
|
2053
2290
|
},
|
|
2054
2291
|
});
|
|
2292
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2055
2293
|
return res?.data?.message_id || res?.message_id;
|
|
2056
2294
|
}
|
|
2057
2295
|
catch (err) {
|
|
2058
2296
|
console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
|
|
2297
|
+
if (this.isOutOfChatError(err))
|
|
2298
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
2059
2299
|
// Fallback to plain text
|
|
2060
2300
|
try {
|
|
2061
2301
|
const res = await this.client.im.message.create({
|
|
@@ -2066,10 +2306,13 @@ export class FeishuBot {
|
|
|
2066
2306
|
msg_type: "text",
|
|
2067
2307
|
},
|
|
2068
2308
|
});
|
|
2309
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2069
2310
|
return res?.data?.message_id || res?.message_id;
|
|
2070
2311
|
}
|
|
2071
2312
|
catch (fallbackErr) {
|
|
2072
2313
|
console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
|
|
2314
|
+
if (this.isOutOfChatError(fallbackErr))
|
|
2315
|
+
this.markCurrentBotUnavailable(chatId, fallbackErr);
|
|
2073
2316
|
throw fallbackErr;
|
|
2074
2317
|
}
|
|
2075
2318
|
}
|
package/dist/live-status.d.ts
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import type { ProgressEvent } from "./openclaw-client";
|
|
2
|
+
/** One activity line shown in the card content area. */
|
|
3
|
+
export type LiveStatusLine = {
|
|
4
|
+
kind: "tool_start" | "tool_end" | "text" | "lifecycle" | "summary";
|
|
5
|
+
text: string;
|
|
6
|
+
/** Relative time (seconds since run start) when this line was produced. */
|
|
7
|
+
at: number;
|
|
8
|
+
};
|
|
9
|
+
/** Structured snapshot the renderer turns into a Feishu interactive card. */
|
|
10
|
+
export type LiveStatusView = {
|
|
11
|
+
/** Card title, e.g. "Claude 正在执行" / "✅ Claude 已完成". */
|
|
12
|
+
title: string;
|
|
13
|
+
/** Activity lines (oldest first); when finished this holds a summary instead. */
|
|
14
|
+
lines: LiveStatusLine[];
|
|
15
|
+
/** Footer: elapsed time like "0:42". */
|
|
16
|
+
elapsed: string;
|
|
17
|
+
/** Footer: model name, e.g. "phgeek-gw/claude-opus-4.8". */
|
|
18
|
+
model?: string;
|
|
19
|
+
/** Terminal state: running / done / failed (lets renderer pick color). */
|
|
20
|
+
state: "running" | "done" | "failed";
|
|
21
|
+
};
|
|
2
22
|
export type LiveStatusCallbacks = {
|
|
3
|
-
|
|
4
|
-
|
|
23
|
+
/** Create the status card; returns the new message id. */
|
|
24
|
+
create: (view: LiveStatusView) => Promise<string | undefined>;
|
|
25
|
+
/** Patch the existing status card. */
|
|
26
|
+
edit: (messageId: string, view: LiveStatusView) => Promise<void>;
|
|
5
27
|
warn?: (message: string, err?: unknown) => void;
|
|
6
28
|
};
|
|
7
29
|
export type LiveStatusOptions = {
|
|
@@ -9,48 +31,67 @@ export type LiveStatusOptions = {
|
|
|
9
31
|
locale?: "zh" | "en";
|
|
10
32
|
delayMs?: number;
|
|
11
33
|
maxChars?: number;
|
|
12
|
-
|
|
13
|
-
|
|
34
|
+
/** Model name shown in the footer. */
|
|
35
|
+
model?: string;
|
|
36
|
+
/** Number of recent activity lines kept in the content area. */
|
|
37
|
+
historySize?: number;
|
|
38
|
+
/** Footer/auto-refresh cadence in ms. */
|
|
39
|
+
tickMs?: number;
|
|
14
40
|
};
|
|
15
41
|
export declare class LiveStatusController {
|
|
16
42
|
private readonly callbacks;
|
|
17
43
|
private readonly opts;
|
|
18
44
|
private messageId?;
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
private
|
|
45
|
+
private lastSentSignature;
|
|
46
|
+
/** Recent activity lines (most-recent last), capped at historySize. */
|
|
47
|
+
private lines;
|
|
48
|
+
private startedAt;
|
|
23
49
|
private createTimer?;
|
|
50
|
+
private tickTimer?;
|
|
24
51
|
private createPromise?;
|
|
25
52
|
private finalized;
|
|
26
53
|
private disabled;
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
private state;
|
|
55
|
+
/** Total tool calls (tool_start events) seen during the run, for the summary. */
|
|
56
|
+
private toolCallCount;
|
|
57
|
+
/** True when finished via noReply(): show a "no content" summary. */
|
|
58
|
+
private noReplyResult;
|
|
29
59
|
constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
|
|
30
60
|
get id(): string | undefined;
|
|
31
61
|
start(initialDetail?: string): void;
|
|
32
62
|
progress(event: ProgressEvent | string): Promise<void>;
|
|
33
|
-
/**
|
|
34
|
-
* Finish the live status when the real (final) reply is delivered separately.
|
|
35
|
-
* The final answer is sent by the normal interactive-card path, so the status
|
|
36
|
-
* message must NOT be overwritten with the answer (text type does not render
|
|
37
|
-
* Markdown). Feishu renders message deletion as a visible "recalled a message"
|
|
38
|
-
* tombstone, so keep the status message and mark it done instead.
|
|
39
|
-
*/
|
|
40
63
|
complete(): Promise<void>;
|
|
64
|
+
fail(): Promise<void>;
|
|
41
65
|
/**
|
|
42
|
-
* Finish
|
|
43
|
-
*
|
|
44
|
-
*
|
|
66
|
+
* Finish when the model explicitly produced no reply (NO_REPLY) or an empty
|
|
67
|
+
* final. Mark the card done but show a "no content" summary instead of the
|
|
68
|
+
* activity window, so the user understands the run finished without output.
|
|
45
69
|
*/
|
|
46
|
-
|
|
70
|
+
noReply(): Promise<void>;
|
|
47
71
|
dispose(): void;
|
|
72
|
+
/**
|
|
73
|
+
* Intermediate assistant text arrives as incremental fragments (each flush
|
|
74
|
+
* sends only the newly-added slice). Consecutive fragments with no tool event
|
|
75
|
+
* in between belong to the same thought, so merge them into ONE line instead
|
|
76
|
+
* of showing each fragment (or each table row) as a separate message. A tool
|
|
77
|
+
* event resets this, so the next text starts a fresh line.
|
|
78
|
+
*/
|
|
79
|
+
private appendText;
|
|
80
|
+
private normalizeText;
|
|
81
|
+
private clip;
|
|
82
|
+
private pushLine;
|
|
48
83
|
private stopTimers;
|
|
49
84
|
private ensureCreatedNow;
|
|
50
85
|
private ensureCreated;
|
|
51
|
-
|
|
86
|
+
/** Refresh the elapsed-time footer periodically even with no new activity. */
|
|
87
|
+
private startTicker;
|
|
52
88
|
private safeEdit;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
private
|
|
89
|
+
/** Dedupe key includes elapsed so the footer timer ticks even without new
|
|
90
|
+
* activity, while identical content within the same second is still skipped. */
|
|
91
|
+
private signature;
|
|
92
|
+
private isNoReplyText;
|
|
93
|
+
private formatTool;
|
|
94
|
+
private formatElapsed;
|
|
95
|
+
private buildView;
|
|
96
|
+
private buildSummaryLine;
|
|
56
97
|
}
|
package/dist/live-status.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
|
|
2
2
|
const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// How many recent activity lines to keep in the card content area.
|
|
4
|
+
const DEFAULT_HISTORY = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_HISTORY || 6);
|
|
5
|
+
// How often to refresh the elapsed-time footer even when no new activity arrives,
|
|
6
|
+
// so the "已用时间" keeps advancing. Card patch has no 20-edit cap (unlike text
|
|
7
|
+
// edit), so a modest cadence is safe.
|
|
8
|
+
const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS || 3000);
|
|
7
9
|
export class LiveStatusController {
|
|
8
10
|
callbacks;
|
|
9
11
|
opts;
|
|
10
12
|
messageId;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
lastSentSignature = "";
|
|
14
|
+
/** Recent activity lines (most-recent last), capped at historySize. */
|
|
15
|
+
lines = [];
|
|
16
|
+
startedAt = Date.now();
|
|
15
17
|
createTimer;
|
|
18
|
+
tickTimer;
|
|
16
19
|
createPromise;
|
|
17
20
|
finalized = false;
|
|
18
21
|
disabled = false;
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
state = "running";
|
|
23
|
+
/** Total tool calls (tool_start events) seen during the run, for the summary. */
|
|
24
|
+
toolCallCount = 0;
|
|
25
|
+
/** True when finished via noReply(): show a "no content" summary. */
|
|
26
|
+
noReplyResult = false;
|
|
21
27
|
constructor(callbacks, opts) {
|
|
22
28
|
this.callbacks = callbacks;
|
|
23
29
|
this.opts = opts;
|
|
@@ -26,78 +32,138 @@ export class LiveStatusController {
|
|
|
26
32
|
start(initialDetail) {
|
|
27
33
|
if (this.disabled || this.finalized || this.createTimer || this.messageId)
|
|
28
34
|
return;
|
|
29
|
-
this.
|
|
35
|
+
this.startedAt = Date.now();
|
|
36
|
+
if (initialDetail && initialDetail.trim()) {
|
|
37
|
+
this.pushLine("lifecycle", initialDetail.trim());
|
|
38
|
+
}
|
|
30
39
|
this.createTimer = setTimeout(() => {
|
|
31
40
|
this.createTimer = undefined;
|
|
32
41
|
this.createPromise = this.ensureCreated();
|
|
33
42
|
}, this.opts.delayMs ?? DEFAULT_DELAY_MS);
|
|
34
43
|
}
|
|
35
44
|
async progress(event) {
|
|
36
|
-
if (this.disabled || this.finalized
|
|
45
|
+
if (this.disabled || this.finalized)
|
|
37
46
|
return;
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
// Card patch has no 20-edit cap (verified against Feishu im.message.patch),
|
|
48
|
+
// so we can show a small rolling window of recent activity: tool start, tool
|
|
49
|
+
// end, and intermediate assistant text each count as one line. We ignore
|
|
50
|
+
// tool "error" (the error is delivered through the normal message path) and
|
|
51
|
+
// lifecycle ticks after the first.
|
|
52
|
+
if (typeof event === "string") {
|
|
53
|
+
const t = event.trim();
|
|
54
|
+
if (!t || this.isNoReplyText(t))
|
|
55
|
+
return;
|
|
56
|
+
this.appendText(t);
|
|
57
|
+
}
|
|
58
|
+
else if (event.kind === "tool") {
|
|
59
|
+
if (event.phase === "start") {
|
|
60
|
+
this.toolCallCount++;
|
|
61
|
+
this.pushLine("tool_start", this.formatTool(event));
|
|
62
|
+
}
|
|
63
|
+
else if (event.phase === "end")
|
|
64
|
+
this.pushLine("tool_end", this.formatTool(event));
|
|
65
|
+
else
|
|
66
|
+
return; // tool error -> normal error path
|
|
67
|
+
}
|
|
68
|
+
else if (event.kind === "assistant_note") {
|
|
69
|
+
const t = (event.text || "").trim();
|
|
70
|
+
if (!t || this.isNoReplyText(t))
|
|
43
71
|
return;
|
|
44
|
-
this.
|
|
72
|
+
this.appendText(t);
|
|
45
73
|
}
|
|
46
74
|
else {
|
|
47
|
-
//
|
|
48
|
-
// runtime OpenClaw progress is structured and only tool start passes above.
|
|
49
|
-
this.detail = event.trim();
|
|
75
|
+
return; // lifecycle ticks: ignore (footer timer already advances)
|
|
50
76
|
}
|
|
51
|
-
if (!this.detail)
|
|
52
|
-
return;
|
|
53
77
|
await this.ensureCreatedNow();
|
|
54
|
-
await this.
|
|
78
|
+
await this.safeEdit(this.buildView());
|
|
55
79
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Finish the live status when the real (final) reply is delivered separately.
|
|
58
|
-
* The final answer is sent by the normal interactive-card path, so the status
|
|
59
|
-
* message must NOT be overwritten with the answer (text type does not render
|
|
60
|
-
* Markdown). Feishu renders message deletion as a visible "recalled a message"
|
|
61
|
-
* tombstone, so keep the status message and mark it done instead.
|
|
62
|
-
*/
|
|
63
80
|
async complete() {
|
|
81
|
+
this.state = "done";
|
|
64
82
|
this.finalized = true;
|
|
65
83
|
this.stopTimers();
|
|
66
84
|
if (this.createPromise)
|
|
67
85
|
await this.createPromise.catch(() => { });
|
|
68
86
|
if (!this.messageId || this.disabled)
|
|
69
87
|
return;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
await this.safeEdit(this.buildView(), true);
|
|
89
|
+
}
|
|
90
|
+
async fail() {
|
|
91
|
+
this.state = "failed";
|
|
92
|
+
this.finalized = true;
|
|
93
|
+
this.stopTimers();
|
|
94
|
+
if (this.createPromise)
|
|
95
|
+
await this.createPromise.catch(() => { });
|
|
96
|
+
if (!this.messageId || this.disabled)
|
|
97
|
+
return;
|
|
98
|
+
await this.safeEdit(this.buildView(), true);
|
|
74
99
|
}
|
|
75
100
|
/**
|
|
76
|
-
* Finish
|
|
77
|
-
*
|
|
78
|
-
*
|
|
101
|
+
* Finish when the model explicitly produced no reply (NO_REPLY) or an empty
|
|
102
|
+
* final. Mark the card done but show a "no content" summary instead of the
|
|
103
|
+
* activity window, so the user understands the run finished without output.
|
|
79
104
|
*/
|
|
80
|
-
async
|
|
105
|
+
async noReply() {
|
|
106
|
+
this.state = "done";
|
|
107
|
+
this.noReplyResult = true;
|
|
81
108
|
this.finalized = true;
|
|
82
109
|
this.stopTimers();
|
|
83
110
|
if (this.createPromise)
|
|
84
111
|
await this.createPromise.catch(() => { });
|
|
85
112
|
if (!this.messageId || this.disabled)
|
|
86
113
|
return;
|
|
87
|
-
|
|
88
|
-
? `\u26A0\uFE0F ${this.opts.botName} stopped`
|
|
89
|
-
: `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
90
|
-
await this.safeEdit(failedText, true);
|
|
114
|
+
await this.safeEdit(this.buildView(), true);
|
|
91
115
|
}
|
|
92
116
|
dispose() {
|
|
93
117
|
this.finalized = true;
|
|
94
118
|
this.stopTimers();
|
|
95
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Intermediate assistant text arrives as incremental fragments (each flush
|
|
122
|
+
* sends only the newly-added slice). Consecutive fragments with no tool event
|
|
123
|
+
* in between belong to the same thought, so merge them into ONE line instead
|
|
124
|
+
* of showing each fragment (or each table row) as a separate message. A tool
|
|
125
|
+
* event resets this, so the next text starts a fresh line.
|
|
126
|
+
*/
|
|
127
|
+
appendText(fragment) {
|
|
128
|
+
const last = this.lines[this.lines.length - 1];
|
|
129
|
+
if (last && last.kind === "text") {
|
|
130
|
+
const merged = `${last.text} ${this.normalizeText(fragment)}`.replace(/\s+/g, " ").trim();
|
|
131
|
+
last.text = this.clip(merged);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.pushLine("text", fragment);
|
|
135
|
+
}
|
|
136
|
+
normalizeText(text) {
|
|
137
|
+
return text.replace(/\s+/g, " ").trim();
|
|
138
|
+
}
|
|
139
|
+
clip(text) {
|
|
140
|
+
const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
|
|
141
|
+
return text.length > maxChars ? `${text.slice(0, Math.max(0, maxChars - 1))}\u2026` : text;
|
|
142
|
+
}
|
|
143
|
+
pushLine(kind, text) {
|
|
144
|
+
const clean = this.normalizeText(text);
|
|
145
|
+
if (!clean)
|
|
146
|
+
return;
|
|
147
|
+
const clipped = this.clip(clean);
|
|
148
|
+
// Collapse consecutive identical lines (e.g. repeated "等待 OpenClaw").
|
|
149
|
+
const last = this.lines[this.lines.length - 1];
|
|
150
|
+
if (last && last.kind === kind && last.text === clipped)
|
|
151
|
+
return;
|
|
152
|
+
const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
|
|
153
|
+
this.lines.push({ kind, text: clipped, at });
|
|
154
|
+
const limit = this.opts.historySize ?? DEFAULT_HISTORY;
|
|
155
|
+
if (this.lines.length > limit)
|
|
156
|
+
this.lines = this.lines.slice(this.lines.length - limit);
|
|
157
|
+
}
|
|
96
158
|
stopTimers() {
|
|
97
159
|
if (this.createTimer) {
|
|
98
160
|
clearTimeout(this.createTimer);
|
|
99
161
|
this.createTimer = undefined;
|
|
100
162
|
}
|
|
163
|
+
if (this.tickTimer) {
|
|
164
|
+
clearInterval(this.tickTimer);
|
|
165
|
+
this.tickTimer = undefined;
|
|
166
|
+
}
|
|
101
167
|
}
|
|
102
168
|
async ensureCreatedNow() {
|
|
103
169
|
if (this.createTimer) {
|
|
@@ -111,45 +177,45 @@ export class LiveStatusController {
|
|
|
111
177
|
async ensureCreated() {
|
|
112
178
|
if (this.disabled || this.finalized || this.messageId)
|
|
113
179
|
return;
|
|
114
|
-
const
|
|
180
|
+
const view = this.buildView();
|
|
115
181
|
try {
|
|
116
|
-
const id = await this.callbacks.create(
|
|
182
|
+
const id = await this.callbacks.create(view);
|
|
117
183
|
if (!id) {
|
|
118
184
|
this.disabled = true;
|
|
119
185
|
return;
|
|
120
186
|
}
|
|
121
187
|
this.messageId = id;
|
|
122
|
-
this.
|
|
188
|
+
this.lastSentSignature = this.signature(view);
|
|
189
|
+
this.startTicker();
|
|
123
190
|
}
|
|
124
191
|
catch (err) {
|
|
125
192
|
this.disabled = true;
|
|
126
193
|
this.callbacks.warn?.("live status create failed", err);
|
|
127
194
|
}
|
|
128
195
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const maxEdits = this.opts.maxEdits ?? DEFAULT_MAX_EDITS;
|
|
133
|
-
const runningBudget = Math.max(0, maxEdits - 1); // reserve final edit for complete()
|
|
134
|
-
if (this.editCount >= runningBudget)
|
|
196
|
+
/** Refresh the elapsed-time footer periodically even with no new activity. */
|
|
197
|
+
startTicker() {
|
|
198
|
+
if (this.tickTimer || this.disabled || this.finalized)
|
|
135
199
|
return;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
this.
|
|
200
|
+
const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
|
|
201
|
+
this.tickTimer = setInterval(() => {
|
|
202
|
+
if (this.finalized || this.disabled || !this.messageId)
|
|
203
|
+
return;
|
|
204
|
+
void this.safeEdit(this.buildView());
|
|
205
|
+
}, tickMs);
|
|
206
|
+
this.tickTimer.unref?.();
|
|
141
207
|
}
|
|
142
|
-
async safeEdit(
|
|
208
|
+
async safeEdit(view, force = false) {
|
|
143
209
|
if (!this.messageId || this.disabled)
|
|
144
210
|
return false;
|
|
145
211
|
if (!force && this.finalized)
|
|
146
212
|
return false;
|
|
147
|
-
|
|
213
|
+
const sig = this.signature(view);
|
|
214
|
+
if (!force && sig === this.lastSentSignature)
|
|
148
215
|
return true;
|
|
149
216
|
try {
|
|
150
|
-
await this.callbacks.edit(this.messageId,
|
|
151
|
-
this.
|
|
152
|
-
this.editCount++;
|
|
217
|
+
await this.callbacks.edit(this.messageId, view);
|
|
218
|
+
this.lastSentSignature = sig;
|
|
153
219
|
return true;
|
|
154
220
|
}
|
|
155
221
|
catch (err) {
|
|
@@ -159,26 +225,62 @@ export class LiveStatusController {
|
|
|
159
225
|
return false;
|
|
160
226
|
}
|
|
161
227
|
}
|
|
162
|
-
|
|
228
|
+
/** Dedupe key includes elapsed so the footer timer ticks even without new
|
|
229
|
+
* activity, while identical content within the same second is still skipped. */
|
|
230
|
+
signature(view) {
|
|
231
|
+
return `${view.state}|${view.title}|${view.elapsed}|${view.lines.map((l) => `${l.kind}@${l.at}:${l.text}`).join("|")}`;
|
|
232
|
+
}
|
|
233
|
+
isNoReplyText(text) {
|
|
234
|
+
return text.trim().toUpperCase() === "NO_REPLY";
|
|
235
|
+
}
|
|
236
|
+
formatTool(event) {
|
|
163
237
|
const raw = event.text || event.name || "";
|
|
164
|
-
const prefix = `${event.name}
|
|
238
|
+
const prefix = `${event.name} ${event.phase}`;
|
|
165
239
|
let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
|
|
166
240
|
if (detail.startsWith(":"))
|
|
167
241
|
detail = detail.slice(1).trim();
|
|
168
242
|
return detail ? `${event.name}: ${detail}` : event.name;
|
|
169
243
|
}
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
:
|
|
244
|
+
formatElapsed() {
|
|
245
|
+
const totalSec = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
|
|
246
|
+
const m = Math.floor(totalSec / 60);
|
|
247
|
+
const s = totalSec % 60;
|
|
248
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
249
|
+
}
|
|
250
|
+
buildView() {
|
|
251
|
+
const en = this.opts.locale === "en";
|
|
252
|
+
let title;
|
|
253
|
+
if (this.state === "done")
|
|
254
|
+
title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
|
|
255
|
+
else if (this.state === "failed")
|
|
256
|
+
title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
257
|
+
else
|
|
258
|
+
title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
|
|
259
|
+
// When finished, replace the recent-activity window with a run summary
|
|
260
|
+
// (total tool calls + total time) instead of the last few messages.
|
|
261
|
+
const lines = this.state === "running"
|
|
262
|
+
? [...this.lines]
|
|
263
|
+
: [this.buildSummaryLine(en)];
|
|
264
|
+
return {
|
|
265
|
+
title,
|
|
266
|
+
lines,
|
|
267
|
+
elapsed: this.formatElapsed(),
|
|
268
|
+
model: this.opts.model,
|
|
269
|
+
state: this.state,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
buildSummaryLine(en) {
|
|
273
|
+
const elapsed = this.formatElapsed();
|
|
274
|
+
const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
|
|
275
|
+
if (this.noReplyResult) {
|
|
276
|
+
const text = en
|
|
277
|
+
? `Model returned no content \u00b7 ${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
|
|
278
|
+
: `\u6a21\u578b\u6ca1\u6709\u56de\u590d\u5185\u5bb9 \u00b7 \u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
|
|
279
|
+
return { kind: "summary", text, at };
|
|
280
|
+
}
|
|
281
|
+
const text = en
|
|
282
|
+
? `${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
|
|
283
|
+
: `\u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
|
|
284
|
+
return { kind: "summary", text, at };
|
|
183
285
|
}
|
|
184
286
|
}
|
package/dist/message-store.d.ts
CHANGED
|
@@ -70,6 +70,12 @@ export declare class MessageStore {
|
|
|
70
70
|
enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
|
|
71
71
|
getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
|
|
72
72
|
getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
|
|
73
|
+
markBotSeenInChat(botName: string, chatId: string): void;
|
|
74
|
+
hasBotSeenInChat(botName: string, chatId: string): boolean;
|
|
75
|
+
markBotUnavailableInChat(botName: string, chatId: string, reason?: string): void;
|
|
76
|
+
clearBotUnavailableInChat(botName: string, chatId: string): void;
|
|
77
|
+
isBotUnavailableInChat(botName: string, chatId: string): boolean;
|
|
78
|
+
hasAnyDeliveredToChat(botName: string, chatId: string): boolean;
|
|
73
79
|
hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
|
|
74
80
|
hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
|
|
75
81
|
resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
|
package/dist/message-store.js
CHANGED
|
@@ -111,6 +111,27 @@ export class MessageStore {
|
|
|
111
111
|
PRIMARY KEY (bot_name, chat_id)
|
|
112
112
|
);
|
|
113
113
|
|
|
114
|
+
-- Durable positive membership signal. Feishu does not reliably expose bot
|
|
115
|
+
-- members via chat_members, so receiving an event in a chat is the strongest
|
|
116
|
+
-- proof that this bot app is actually installed in that group.
|
|
117
|
+
CREATE TABLE IF NOT EXISTS bot_chat_seen (
|
|
118
|
+
bot_name TEXT NOT NULL,
|
|
119
|
+
chat_id TEXT NOT NULL,
|
|
120
|
+
updated_at INTEGER NOT NULL,
|
|
121
|
+
PRIMARY KEY (bot_name, chat_id)
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
-- Durable negative membership cache. Feishu message create returns 230002
|
|
125
|
+
-- when a bot app is not in a chat; cache that so discussion scheduling and
|
|
126
|
+
-- later proactive deliveries do not keep generating ghost participants.
|
|
127
|
+
CREATE TABLE IF NOT EXISTS bot_chat_unavailable (
|
|
128
|
+
bot_name TEXT NOT NULL,
|
|
129
|
+
chat_id TEXT NOT NULL,
|
|
130
|
+
reason TEXT NOT NULL DEFAULT '',
|
|
131
|
+
updated_at INTEGER NOT NULL,
|
|
132
|
+
PRIMARY KEY (bot_name, chat_id)
|
|
133
|
+
);
|
|
134
|
+
|
|
114
135
|
-- Durable delivery ledger for user-visible assistant outputs. All final
|
|
115
136
|
-- text/attachment outputs should be inserted here first and dispatched
|
|
116
137
|
-- once, regardless of whether they came from chat final, proactive
|
|
@@ -409,6 +430,42 @@ export class MessageStore {
|
|
|
409
430
|
`).all(maxCount);
|
|
410
431
|
return rows.map((r) => this.mapDelivery(r));
|
|
411
432
|
}
|
|
433
|
+
markBotSeenInChat(botName, chatId) {
|
|
434
|
+
this.db.prepare(`
|
|
435
|
+
INSERT INTO bot_chat_seen(bot_name, chat_id, updated_at)
|
|
436
|
+
VALUES (?, ?, ?)
|
|
437
|
+
ON CONFLICT(bot_name, chat_id) DO UPDATE SET updated_at = excluded.updated_at
|
|
438
|
+
`).run(botName, chatId, Date.now());
|
|
439
|
+
// If the bot is receiving events from this chat again, it is no longer out
|
|
440
|
+
// of the chat. This recovers automatically when a bot is re-added to a group.
|
|
441
|
+
this.clearBotUnavailableInChat(botName, chatId);
|
|
442
|
+
}
|
|
443
|
+
hasBotSeenInChat(botName, chatId) {
|
|
444
|
+
const row = this.db.prepare(`SELECT 1 FROM bot_chat_seen WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
|
|
445
|
+
return !!row;
|
|
446
|
+
}
|
|
447
|
+
markBotUnavailableInChat(botName, chatId, reason = '') {
|
|
448
|
+
this.db.prepare(`
|
|
449
|
+
INSERT INTO bot_chat_unavailable(bot_name, chat_id, reason, updated_at)
|
|
450
|
+
VALUES (?, ?, ?, ?)
|
|
451
|
+
ON CONFLICT(bot_name, chat_id) DO UPDATE SET reason = excluded.reason, updated_at = excluded.updated_at
|
|
452
|
+
`).run(botName, chatId, reason, Date.now());
|
|
453
|
+
}
|
|
454
|
+
clearBotUnavailableInChat(botName, chatId) {
|
|
455
|
+
this.db.prepare(`DELETE FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ?`).run(botName, chatId);
|
|
456
|
+
}
|
|
457
|
+
isBotUnavailableInChat(botName, chatId) {
|
|
458
|
+
const row = this.db.prepare(`SELECT 1 FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
|
|
459
|
+
return !!row;
|
|
460
|
+
}
|
|
461
|
+
hasAnyDeliveredToChat(botName, chatId) {
|
|
462
|
+
const row = this.db.prepare(`
|
|
463
|
+
SELECT 1 FROM delivery_outbox
|
|
464
|
+
WHERE bot_name = ? AND chat_id = ? AND status = 'delivered'
|
|
465
|
+
LIMIT 1
|
|
466
|
+
`).get(botName, chatId);
|
|
467
|
+
return !!row;
|
|
468
|
+
}
|
|
412
469
|
hasRecentSimilarDelivery(botName, chatId, contentHash, windowMs, sourceTypes) {
|
|
413
470
|
if (!contentHash)
|
|
414
471
|
return false;
|
|
@@ -90,6 +90,15 @@ export declare class OpenClawClient {
|
|
|
90
90
|
* 30-minute safety net only for catastrophic WS disconnection.
|
|
91
91
|
*/
|
|
92
92
|
private collectReply;
|
|
93
|
+
/**
|
|
94
|
+
* Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
|
|
95
|
+
* (it auto-compacts and keeps running the same run, then emits a real final).
|
|
96
|
+
* For these we must not reject collectReply immediately, or the bridge reports
|
|
97
|
+
* "没有完成回复" while the agent is still working. Currently this covers
|
|
98
|
+
* context-overflow / prompt-too-large, which OpenClaw recovers from via its
|
|
99
|
+
* context-overflow-recovery + auto-compaction path.
|
|
100
|
+
*/
|
|
101
|
+
private isRecoverableAgentError;
|
|
93
102
|
private pickBestCollectedText;
|
|
94
103
|
createSession(params: {
|
|
95
104
|
key: string;
|
package/dist/openclaw-client.js
CHANGED
|
@@ -810,13 +810,29 @@ export class OpenClawClient {
|
|
|
810
810
|
return;
|
|
811
811
|
}
|
|
812
812
|
if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
|
|
813
|
+
const errText = String(ev.data?.error || "unknown");
|
|
814
|
+
// Some agent errors are recoverable: OpenClaw emits a lifecycle
|
|
815
|
+
// error (e.g. "Context overflow: prompt too large") and then
|
|
816
|
+
// auto-compacts and KEEPS RUNNING the same run, eventually emitting
|
|
817
|
+
// a real final. If we reject immediately the user sees a scary
|
|
818
|
+
// "没有完成回复" while the agent is in fact still working. So for
|
|
819
|
+
// recoverable errors we defer: record the failure text and keep
|
|
820
|
+
// waiting. Subsequent delta/item events reset the idle timer, so a
|
|
821
|
+
// real final wins; if the run truly stalls, the idle timeout falls
|
|
822
|
+
// back to this text.
|
|
823
|
+
if (this.isRecoverableAgentError(errText)) {
|
|
824
|
+
pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
|
|
825
|
+
resetIdleTimer();
|
|
826
|
+
console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
813
829
|
clearTimeout(idleTimer);
|
|
814
830
|
clearInterval(poller);
|
|
815
831
|
if (chatFinalTimer)
|
|
816
832
|
clearTimeout(chatFinalTimer);
|
|
817
833
|
if (lifecycleEndTimer)
|
|
818
834
|
clearTimeout(lifecycleEndTimer);
|
|
819
|
-
reject(new Error(`Agent error: ${
|
|
835
|
+
reject(new Error(`Agent error: ${errText}`));
|
|
820
836
|
return;
|
|
821
837
|
}
|
|
822
838
|
}
|
|
@@ -824,6 +840,22 @@ export class OpenClawClient {
|
|
|
824
840
|
}, 50);
|
|
825
841
|
});
|
|
826
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
|
|
845
|
+
* (it auto-compacts and keeps running the same run, then emits a real final).
|
|
846
|
+
* For these we must not reject collectReply immediately, or the bridge reports
|
|
847
|
+
* "没有完成回复" while the agent is still working. Currently this covers
|
|
848
|
+
* context-overflow / prompt-too-large, which OpenClaw recovers from via its
|
|
849
|
+
* context-overflow-recovery + auto-compaction path.
|
|
850
|
+
*/
|
|
851
|
+
isRecoverableAgentError(errText) {
|
|
852
|
+
const t = (errText || "").toLowerCase();
|
|
853
|
+
return t.includes("context overflow")
|
|
854
|
+
|| t.includes("prompt too large")
|
|
855
|
+
|| t.includes("context length")
|
|
856
|
+
|| t.includes("too many tokens")
|
|
857
|
+
|| t.includes("maximum context");
|
|
858
|
+
}
|
|
827
859
|
pickBestCollectedText(chatFinalText, assistantText, chatDeltaText, transcriptAssistantText) {
|
|
828
860
|
if (chatFinalText)
|
|
829
861
|
return chatFinalText;
|