openclaw-lark-multi-agent 1.1.1 → 1.2.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/README.md +67 -2
- package/dist/feishu-bot.d.ts +58 -0
- package/dist/feishu-bot.js +399 -8
- package/dist/live-status.d.ts +4 -0
- package/dist/live-status.js +15 -5
- package/dist/openclaw-client.js +40 -0
- package/dist/session-file-compactor.d.ts +39 -0
- package/dist/session-file-compactor.js +176 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
|
|
|
37
37
|
- Feishu image download and OpenClaw multimodal attachment forwarding
|
|
38
38
|
- Bridge attachment marker protocol for generated files/images/documents
|
|
39
39
|
- Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
|
|
40
|
+
- Live status card: in non-verbose mode each run shows a single self-updating Feishu interactive card (title + recent activity window with per-line timestamps + elapsed/model footer, refreshed once per second), which collapses to a compact one-line summary on a clean finish and retains the recent activity on failure for debugging
|
|
40
41
|
- Bridge-level slash commands and escaped OpenClaw slash commands
|
|
41
42
|
- `/discuss` mode for barrier-style multi-bot group discussion, including per-round markers and no-reply status notices
|
|
42
43
|
- `/chairman` role: a single per-group chairman that answers plain messages when no bot is in Free mode, and acts as host, challenger, and summarizer inside `/discuss`
|
|
@@ -231,10 +232,13 @@ Bridge-level commands use a single slash and are handled by this project:
|
|
|
231
232
|
- `/status` — show bot model, token usage, and session state
|
|
232
233
|
- `/compact` — compact the OpenClaw session
|
|
233
234
|
- `/reset` — reset the OpenClaw session
|
|
235
|
+
- `/stop` — force-stop a stuck run for this bot in this chat and unlock the queue
|
|
234
236
|
- `/verbose` — toggle tool-call messages for this bot in this chat
|
|
237
|
+
- `/livestatus [on|off]` — toggle, or explicitly enable/disable, the self-updating run-status card shown in non-verbose mode (default on)
|
|
235
238
|
- `/free [on|off]` — toggle, or explicitly enable/disable, this bot's Free mode in the current group chat
|
|
236
239
|
- `/mute` — toggle this bot's mute mode in the current group chat
|
|
237
240
|
- `/mode` — show this bot's current mode in the current chat
|
|
241
|
+
- `/model [id]` — show or switch this bot's bound model (persisted)
|
|
238
242
|
- `/discuss on|off|status|stop|rounds N` — control group-level multi-bot discussion mode (requires a chairman to enable; default 10 rounds)
|
|
239
243
|
- `/chairman [@Bot|off]` — set, view, or clear the single chairman for this group
|
|
240
244
|
- `/locale [zh|en]` — set or view this group's language
|
|
@@ -323,7 +327,7 @@ Persistent constraints (such as "do not call Feishu send tools directly" and the
|
|
|
323
327
|
`/discuss` is an explicit group-level multi-agent discussion scheduler. It is separate from Free mode:
|
|
324
328
|
|
|
325
329
|
- `/free` controls whether a single bot may answer plain human messages.
|
|
326
|
-
- `/discuss on` requires a chairman to be set first (`/chairman @Bot`). It lets one coordinator take over plain human messages and run all
|
|
330
|
+
- `/discuss on` requires a chairman to be set first (`/chairman @Bot`). It lets one coordinator take over plain human messages and run all non-muted bots plus the chairman in barrier-style rounds. Free mode is ignored inside discussion: every bot that is not muted participates regardless of its Free setting.
|
|
327
331
|
- Targeted mentions still fall through to normal routing, so `@GPT hello` works even while discuss mode is enabled.
|
|
328
332
|
- Each participant receives the same round prompt and does not see other participants' replies from the current round until the next round.
|
|
329
333
|
- The chairman speaks last each round: it gives its own view, challenges weak points, mediates disagreements, and decides whether to continue or conclude.
|
|
@@ -396,6 +400,23 @@ The bridge subscribes to Feishu `im.message.recalled_v1` events. When a user rec
|
|
|
396
400
|
|
|
397
401
|
Version 1 behavior intentionally does **not** abort an OpenClaw run that has already started processing the recalled message, and it does not recall bot replies that were already sent. The first goal is to make recall reliable for queued/not-yet-processed messages.
|
|
398
402
|
|
|
403
|
+
## Session health and error recovery
|
|
404
|
+
|
|
405
|
+
The bridge distinguishes a failed *run* from a dead *session*:
|
|
406
|
+
|
|
407
|
+
- During an active run, a health monitor polls the OpenClaw session. Only true
|
|
408
|
+
session-death states (`killed` / `dead` / `crashed`, matched on word
|
|
409
|
+
boundaries) are treated as unhealthy; run-level outcomes such as `aborted`,
|
|
410
|
+
`error`, or `timeout` are not, because the session usually remains usable for
|
|
411
|
+
the next message. A short re-check guards against transient blips.
|
|
412
|
+
- When a session is genuinely unhealthy, the bridge warns the user but does not
|
|
413
|
+
force a `/reset`: the current message is still attempted, and only an in-run
|
|
414
|
+
confirmation of death stops the wait. Users decide whether to retry.
|
|
415
|
+
- Recoverable agent errors are not surfaced as failures. When OpenClaw emits a
|
|
416
|
+
recoverable lifecycle error (for example `Context overflow: prompt too large`)
|
|
417
|
+
it auto-compacts and keeps the same run alive; the bridge defers instead of
|
|
418
|
+
rejecting, so a later real final reply wins. Only a genuine stall falls back to
|
|
419
|
+
the deferred error via the idle timeout. Non-recoverable errors still fail fast.
|
|
399
420
|
|
|
400
421
|
## Markdown, tables, and attachments
|
|
401
422
|
|
|
@@ -408,6 +429,50 @@ Assistant replies are sent as Feishu CardKit v2 cards. Markdown is preprocessed
|
|
|
408
429
|
|
|
409
430
|
For generated files/images/documents, agents should use the bridge attachment marker protocol instead of calling Feishu messaging tools directly. The bridge strips the marker from the visible reply, validates the file path under the configured attachment directory, uploads/sends the attachment, and records it in local context. Markdown documents can be converted into Feishu cloud documents through this path.
|
|
410
431
|
|
|
432
|
+
## Live status
|
|
433
|
+
|
|
434
|
+
In non-verbose mode, each run shows a single self-updating Feishu interactive
|
|
435
|
+
card so users can see progress without the noise of per-tool-call messages.
|
|
436
|
+
Verbose mode and live status are mutually exclusive: when `/verbose` is on, the
|
|
437
|
+
bridge emits the existing per-tool messages instead of a live status card.
|
|
438
|
+
|
|
439
|
+
While running, the card shows:
|
|
440
|
+
|
|
441
|
+
- a title (`<bot> is working`);
|
|
442
|
+
- a rolling window of the most recent activity lines (tool start `▸`, tool end
|
|
443
|
+
`✓`, and intermediate assistant text `•`), each prefixed with the relative
|
|
444
|
+
time `mm:ss` since the run started;
|
|
445
|
+
- a footer with elapsed time and the bound model name.
|
|
446
|
+
|
|
447
|
+
The card is created lazily (a fast reply that finishes within the create delay
|
|
448
|
+
never spawns a card, to avoid flicker) and is updated with `im.message.patch`,
|
|
449
|
+
which has no 20-edit cap. A 1-second ticker advances the elapsed timer even when
|
|
450
|
+
no new activity arrives.
|
|
451
|
+
|
|
452
|
+
When the run ends:
|
|
453
|
+
|
|
454
|
+
- a clean finish (normal reply or `NO_REPLY`) collapses the card to a single
|
|
455
|
+
compact grey line: status emoji + total tool calls + total elapsed;
|
|
456
|
+
- a failure (provider error, killed/unhealthy session, delivery error, or idle
|
|
457
|
+
timeout) keeps the recent activity window plus the summary, with an orange
|
|
458
|
+
header, so the steps leading up to the failure stay visible for debugging.
|
|
459
|
+
|
|
460
|
+
The final answer is always delivered separately through the normal interactive-card
|
|
461
|
+
delivery path, so Markdown renders correctly; the live status card is a distinct
|
|
462
|
+
message and never replaces or blocks the final reply.
|
|
463
|
+
|
|
464
|
+
Live status is on by default and can be toggled per bot/chat with
|
|
465
|
+
`/livestatus [on|off]`. It can be disabled globally by setting
|
|
466
|
+
`OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS=0`. Tunable defaults:
|
|
467
|
+
|
|
468
|
+
| Environment variable | Default | Meaning |
|
|
469
|
+
| --- | --- | --- |
|
|
470
|
+
| `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS` | `1` | Master switch; `0` disables live status for all bots |
|
|
471
|
+
| `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS` | `800` | Delay before creating the card, so fast replies do not flicker one |
|
|
472
|
+
| `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS` | `1000` | How often the elapsed-time footer auto-refreshes |
|
|
473
|
+
| `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_HISTORY` | `6` | Number of recent activity lines kept in the card |
|
|
474
|
+
| `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS` | `120` | Per-line character cap before truncation |
|
|
475
|
+
|
|
411
476
|
## Data model
|
|
412
477
|
|
|
413
478
|
SQLite state lives in the configured data directory. Important tables:
|
|
@@ -421,7 +486,7 @@ SQLite state lives in the configured data directory. Important tables:
|
|
|
421
486
|
- `delivery_outbox` — durable user-visible delivery ledger with claim/dedupe state
|
|
422
487
|
- `recalled_messages` — recalled user messages excluded from pending work and future context
|
|
423
488
|
- `processed_events` — Feishu event de-duplication
|
|
424
|
-
- `bot_chat_settings` — per-bot/per-chat settings such as verbose mode
|
|
489
|
+
- `bot_chat_settings` — per-bot/per-chat settings such as verbose mode, mode, and live-status toggle
|
|
425
490
|
|
|
426
491
|
## Development
|
|
427
492
|
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export declare class FeishuBot {
|
|
|
34
34
|
private delayedFailureTimers;
|
|
35
35
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
36
36
|
private lastRealDeliveryAt;
|
|
37
|
+
/** Per-chat: whether we've already sent the high-context alert this cycle. */
|
|
38
|
+
private contextAlerted;
|
|
37
39
|
/** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
|
|
38
40
|
private activeDeliveryTargets;
|
|
39
41
|
private adminOpenId;
|
|
@@ -117,6 +119,51 @@ export declare class FeishuBot {
|
|
|
117
119
|
private isBridgeControlReply;
|
|
118
120
|
private isLegacyBridgeControlMessage;
|
|
119
121
|
private isRuntimeFailureText;
|
|
122
|
+
/**
|
|
123
|
+
* Is this main-run error worth auto-retrying (by resuming the session via the
|
|
124
|
+
* probe loop)? Mainly transient failures — request/response timeouts and
|
|
125
|
+
* aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
|
|
126
|
+
* (auth/permission/quota/billing/model-not-supported), which would just fail
|
|
127
|
+
* again. The error text comes from collectReply's `Agent error: <msg>`.
|
|
128
|
+
*/
|
|
129
|
+
private isRetryableAgentError;
|
|
130
|
+
/**
|
|
131
|
+
* Heuristic: does this reply look truncated / mid-task? Bias toward false
|
|
132
|
+
* positives (“宁可错杀不放过”) — a false positive only costs one invisible
|
|
133
|
+
* confirmation round, and the session self-check is the real arbiter. We gate
|
|
134
|
+
* only on cheap text shape here, never on stopReason (the upstream rarely
|
|
135
|
+
* provides one for normal-looking-but-truncated finals).
|
|
136
|
+
*/
|
|
137
|
+
private looksTruncated;
|
|
138
|
+
/** Current context usage percent for a session (totalTokens / contextTokens),
|
|
139
|
+
* or 0 if it cannot be determined. Same basis as /status. */
|
|
140
|
+
private getContextUsagePercent;
|
|
141
|
+
/**
|
|
142
|
+
* After a reply is delivered, alert the user once if context usage crossed the
|
|
143
|
+
* threshold (default 80%), so they can compact before a session bloats into the
|
|
144
|
+
* timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
|
|
145
|
+
* alerted we stay quiet until usage drops back below the threshold (e.g. after
|
|
146
|
+
* a compact), then re-arm.
|
|
147
|
+
*/
|
|
148
|
+
private maybeAlertHighContext;
|
|
149
|
+
/** True when the agent's reply is exactly a completion phrase (zh or en,
|
|
150
|
+
* tolerant of trailing punctuation/whitespace and case). */
|
|
151
|
+
/**
|
|
152
|
+
* True when the agent's reply means “done”. We accept the exact phrase as well
|
|
153
|
+
* as common wrappers (“已经结束了” / “好的,结束了” / “yes, DONE”), but reject it
|
|
154
|
+
* when a negation/not-finished cue is present (“还没结束” / “not done”), so a
|
|
155
|
+
* “still working” answer never counts as completion.
|
|
156
|
+
*/
|
|
157
|
+
private isDonePhrase;
|
|
158
|
+
/**
|
|
159
|
+
* Auto-retry loop: if `reply` looks truncated, ask the same session whether it
|
|
160
|
+
* finished. The session is the arbiter: if it replies exactly the done phrase,
|
|
161
|
+
* the ORIGINAL reply was the real result and we deliver that. Otherwise the
|
|
162
|
+
* session kept working and its new output becomes the latest reply, which we
|
|
163
|
+
* re-check. Loops until the session confirms completion, a reply no longer
|
|
164
|
+
* looks truncated, the budget is spent, or the session cannot answer.
|
|
165
|
+
*/
|
|
166
|
+
private autoRetryUntilComplete;
|
|
120
167
|
private cancelDelayedFailure;
|
|
121
168
|
private setActiveDeliveryTarget;
|
|
122
169
|
private scheduleDelayedFailure;
|
|
@@ -202,6 +249,17 @@ export declare class FeishuBot {
|
|
|
202
249
|
/**
|
|
203
250
|
* Handle /compact command: compress session context.
|
|
204
251
|
*/
|
|
252
|
+
/**
|
|
253
|
+
* Compact a session with a fallback chain:
|
|
254
|
+
* 1. OpenClaw's native (LLM-summary) compaction — best semantics.
|
|
255
|
+
* 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
|
|
256
|
+
* the transcript file to drop tool calls/results while keeping the
|
|
257
|
+
* conversation. This needs no model, so it works no matter how large the
|
|
258
|
+
* session is (the native path itself fails to fit an oversized session
|
|
259
|
+
* into the model). A backup is always written before any file change.
|
|
260
|
+
* Returns a structured result describing what happened.
|
|
261
|
+
*/
|
|
262
|
+
private compactWithFallback;
|
|
205
263
|
private handleCompactCommand;
|
|
206
264
|
/**
|
|
207
265
|
* Handle /reset command: fire sessions.reset and confirm.
|
package/dist/feishu-bot.js
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, extname, join, resolve } from "path";
|
|
|
8
8
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
9
9
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
10
10
|
import { discussionManager } from "./discussion-manager.js";
|
|
11
|
+
import { resolveSessionFilePath, toolTrimCompactFile } from "./session-file-compactor.js";
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
12
13
|
const LMA_VERSION = require("../package.json").version;
|
|
13
14
|
const MAX_BOT_STREAK = 10;
|
|
@@ -17,6 +18,26 @@ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
|
17
18
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
18
19
|
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
19
20
|
const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
|
|
21
|
+
// Auto-retry: when a reply looks truncated/incomplete, ask the same session
|
|
22
|
+
// whether it finished. If it did not, let it continue; loop until it confirms
|
|
23
|
+
// completion or the retry budget is spent. Off by setting AUTO_RETRY=0.
|
|
24
|
+
// Read at call time (not module load) so tests can toggle it per-case.
|
|
25
|
+
function autoRetryEnabled() { return process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY !== "0"; }
|
|
26
|
+
function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_MAX || 5); }
|
|
27
|
+
// The exact phrase the agent must reply to confirm completion. The probe asks
|
|
28
|
+
// for the locale-appropriate phrase; detection accepts BOTH so either works.
|
|
29
|
+
const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
|
|
30
|
+
const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE_EN || "DONE";
|
|
31
|
+
// When an auto-retry probe errors AND context usage is at/above this percent,
|
|
32
|
+
// auto-compact the session once and keep retrying (instead of giving up). This
|
|
33
|
+
// rescues the “session too heavy → run times out / lock contention” failure mode.
|
|
34
|
+
function autoRetryCompactPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_COMPACT_PCT || 50); }
|
|
35
|
+
// How many of the most-recent tool calls (and their results) tool-trim keeps
|
|
36
|
+
// intact — recent tool calls may still be relevant to the agent's next step.
|
|
37
|
+
function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
|
|
38
|
+
// After a reply is delivered, if context usage reaches this percent, send the
|
|
39
|
+
// user a one-time alert to compact. 0 disables the alert.
|
|
40
|
+
function contextAlertPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_CONTEXT_ALERT_PCT || 80); }
|
|
20
41
|
class UnhealthySessionError extends Error {
|
|
21
42
|
status;
|
|
22
43
|
constructor(status) {
|
|
@@ -58,6 +79,8 @@ export class FeishuBot {
|
|
|
58
79
|
delayedFailureTimers = new Map();
|
|
59
80
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
60
81
|
lastRealDeliveryAt = new Map();
|
|
82
|
+
/** Per-chat: whether we've already sent the high-context alert this cycle. */
|
|
83
|
+
contextAlerted = new Map();
|
|
61
84
|
/** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
|
|
62
85
|
activeDeliveryTargets = new Map();
|
|
63
86
|
adminOpenId;
|
|
@@ -1220,6 +1243,31 @@ export class FeishuBot {
|
|
|
1220
1243
|
onProgress: (event) => liveStatus?.progress(event),
|
|
1221
1244
|
}));
|
|
1222
1245
|
}
|
|
1246
|
+
catch (mainErr) {
|
|
1247
|
+
// The main run failed before returning a reply. If it is a retryable
|
|
1248
|
+
// error (e.g. a request timeout) and not a session-health failure,
|
|
1249
|
+
// reuse the SAME auto-retry mechanism to let the session resume from
|
|
1250
|
+
// where it was interrupted, instead of giving up immediately.
|
|
1251
|
+
if (mainErr instanceof UnhealthySessionError)
|
|
1252
|
+
throw mainErr;
|
|
1253
|
+
const errText = mainErr?.message || String(mainErr);
|
|
1254
|
+
if (autoRetryEnabled() && !isNativeCommandTrigger && this.isRetryableAgentError(errText)) {
|
|
1255
|
+
console.warn(`[${this.config.name}] main run failed (${errText.slice(0, 80)}) for ${chatId.slice(-8)}; entering auto-retry`);
|
|
1256
|
+
reply = await this.autoRetryUntilComplete({
|
|
1257
|
+
chatId,
|
|
1258
|
+
sessionKey,
|
|
1259
|
+
senderName: lastHuman.senderName,
|
|
1260
|
+
triggerId,
|
|
1261
|
+
lastHumanMessageId: lastHuman.messageId || "",
|
|
1262
|
+
isNativeCommandTrigger,
|
|
1263
|
+
initialError: errText,
|
|
1264
|
+
liveStatus,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
throw mainErr;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1223
1271
|
finally {
|
|
1224
1272
|
// OpenClaw may emit the final assistant session.message just after
|
|
1225
1273
|
// collectReply returns. Keep the trigger mapping briefly so proactive
|
|
@@ -1227,6 +1275,19 @@ export class FeishuBot {
|
|
|
1227
1275
|
releaseActiveDeliveryTarget();
|
|
1228
1276
|
}
|
|
1229
1277
|
console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
|
|
1278
|
+
// Auto-retry: if the reply looks truncated/incomplete, confirm with the
|
|
1279
|
+
// session and loop until it reports done (or the budget is spent). This
|
|
1280
|
+
// replaces `reply` with the final, confirmed result before delivery.
|
|
1281
|
+
reply = await this.autoRetryUntilComplete({
|
|
1282
|
+
chatId,
|
|
1283
|
+
sessionKey,
|
|
1284
|
+
senderName: lastHuman.senderName,
|
|
1285
|
+
triggerId,
|
|
1286
|
+
lastHumanMessageId: lastHuman.messageId || "",
|
|
1287
|
+
isNativeCommandTrigger,
|
|
1288
|
+
initialReply: reply,
|
|
1289
|
+
liveStatus,
|
|
1290
|
+
});
|
|
1230
1291
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
1231
1292
|
const visibleReply = parsedReply.text;
|
|
1232
1293
|
const trimmedReply = visibleReply.trim();
|
|
@@ -1325,6 +1386,8 @@ export class FeishuBot {
|
|
|
1325
1386
|
// not re-process any of them.
|
|
1326
1387
|
for (const id of mergedTriggerIds)
|
|
1327
1388
|
this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
|
|
1389
|
+
// After a successful reply, warn the user once if context is high.
|
|
1390
|
+
void this.maybeAlertHighContext(chatId);
|
|
1328
1391
|
}
|
|
1329
1392
|
catch (err) {
|
|
1330
1393
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
@@ -1603,6 +1666,247 @@ export class FeishuBot {
|
|
|
1603
1666
|
isRuntimeFailureText(text) {
|
|
1604
1667
|
return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
|
|
1605
1668
|
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Is this main-run error worth auto-retrying (by resuming the session via the
|
|
1671
|
+
* probe loop)? Mainly transient failures — request/response timeouts and
|
|
1672
|
+
* aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
|
|
1673
|
+
* (auth/permission/quota/billing/model-not-supported), which would just fail
|
|
1674
|
+
* again. The error text comes from collectReply's `Agent error: <msg>`.
|
|
1675
|
+
*/
|
|
1676
|
+
isRetryableAgentError(errText) {
|
|
1677
|
+
const t = (errText || "").toLowerCase();
|
|
1678
|
+
// Terminal errors: never auto-retry.
|
|
1679
|
+
if (/(unauthorized|permission|forbidden|invalid api key|quota|billing|insufficient|model_not_supported|not supported|invalid_request)/.test(t))
|
|
1680
|
+
return false;
|
|
1681
|
+
// Retryable: timeouts and transient interruptions.
|
|
1682
|
+
return /(timed out|timeout|aborted|operation was aborted|no response|request failed|temporarily|connection|econnreset|socket hang|stream)/.test(t);
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Heuristic: does this reply look truncated / mid-task? Bias toward false
|
|
1686
|
+
* positives (“宁可错杀不放过”) — a false positive only costs one invisible
|
|
1687
|
+
* confirmation round, and the session self-check is the real arbiter. We gate
|
|
1688
|
+
* only on cheap text shape here, never on stopReason (the upstream rarely
|
|
1689
|
+
* provides one for normal-looking-but-truncated finals).
|
|
1690
|
+
*/
|
|
1691
|
+
looksTruncated(text) {
|
|
1692
|
+
const t = (text || "").trim();
|
|
1693
|
+
if (!t)
|
|
1694
|
+
return false; // empty/NO_REPLY handled elsewhere
|
|
1695
|
+
// Unbalanced code fence => almost certainly cut off mid-block.
|
|
1696
|
+
const fences = (t.match(/```/g) || []).length;
|
|
1697
|
+
if (fences % 2 === 1)
|
|
1698
|
+
return true;
|
|
1699
|
+
// Ends with an obvious continuation cue (dangling connector / lead-in).
|
|
1700
|
+
if (/[,,::、;;]\s*$/u.test(t))
|
|
1701
|
+
return true;
|
|
1702
|
+
if (/(接下来|然后|首先|现在我|让我|下一步|let me|now i|next,?|first,?)\s*$/iu.test(t))
|
|
1703
|
+
return true;
|
|
1704
|
+
// Core signal: ends without any sentence-ending punctuation / closer
|
|
1705
|
+
// (“连句号都没有就感觉没成功”). Accept terminators across languages,
|
|
1706
|
+
// closing brackets/quotes, code fences, and common emoji endings.
|
|
1707
|
+
const endOk = /[。!?.!?…)\]」』】)"'`”’~~—✨✅👍🙏]\s*$/u.test(t)
|
|
1708
|
+
|| /```\s*$/.test(t)
|
|
1709
|
+
|| /[}\]]\s*$/.test(t);
|
|
1710
|
+
return !endOk;
|
|
1711
|
+
}
|
|
1712
|
+
/** Current context usage percent for a session (totalTokens / contextTokens),
|
|
1713
|
+
* or 0 if it cannot be determined. Same basis as /status. */
|
|
1714
|
+
async getContextUsagePercent(sessionKey) {
|
|
1715
|
+
try {
|
|
1716
|
+
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
1717
|
+
const s = resp?.session;
|
|
1718
|
+
const total = s?.totalTokens || 0;
|
|
1719
|
+
const ctx = s?.contextTokens || 0;
|
|
1720
|
+
return ctx > 0 ? Math.round((total / ctx) * 100) : 0;
|
|
1721
|
+
}
|
|
1722
|
+
catch {
|
|
1723
|
+
return 0;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* After a reply is delivered, alert the user once if context usage crossed the
|
|
1728
|
+
* threshold (default 80%), so they can compact before a session bloats into the
|
|
1729
|
+
* timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
|
|
1730
|
+
* alerted we stay quiet until usage drops back below the threshold (e.g. after
|
|
1731
|
+
* a compact), then re-arm.
|
|
1732
|
+
*/
|
|
1733
|
+
async maybeAlertHighContext(chatId) {
|
|
1734
|
+
const threshold = contextAlertPct();
|
|
1735
|
+
if (threshold <= 0)
|
|
1736
|
+
return;
|
|
1737
|
+
try {
|
|
1738
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
1739
|
+
const pct = await this.getContextUsagePercent(sessionKey);
|
|
1740
|
+
if (pct <= 0)
|
|
1741
|
+
return;
|
|
1742
|
+
const alerted = this.contextAlerted.get(chatId) || false;
|
|
1743
|
+
if (pct >= threshold) {
|
|
1744
|
+
if (alerted)
|
|
1745
|
+
return; // already warned this cycle
|
|
1746
|
+
this.contextAlerted.set(chatId, true);
|
|
1747
|
+
const en = this.isEn(chatId);
|
|
1748
|
+
const text = en
|
|
1749
|
+
? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
|
|
1750
|
+
: `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
|
|
1751
|
+
await this.sendMessage(chatId, text).catch(() => { });
|
|
1752
|
+
}
|
|
1753
|
+
else if (pct < threshold - 5) {
|
|
1754
|
+
// Re-arm once usage clearly dropped (hysteresis to avoid flapping).
|
|
1755
|
+
this.contextAlerted.set(chatId, false);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
// best-effort; never block delivery
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
/** True when the agent's reply is exactly a completion phrase (zh or en,
|
|
1763
|
+
* tolerant of trailing punctuation/whitespace and case). */
|
|
1764
|
+
/**
|
|
1765
|
+
* True when the agent's reply means “done”. We accept the exact phrase as well
|
|
1766
|
+
* as common wrappers (“已经结束了” / “好的,结束了” / “yes, DONE”), but reject it
|
|
1767
|
+
* when a negation/not-finished cue is present (“还没结束” / “not done”), so a
|
|
1768
|
+
* “still working” answer never counts as completion.
|
|
1769
|
+
*/
|
|
1770
|
+
isDonePhrase(text) {
|
|
1771
|
+
const raw = (text || "").trim();
|
|
1772
|
+
if (!raw)
|
|
1773
|
+
return false;
|
|
1774
|
+
// Short replies only — a long continuation is the model working, not confirming.
|
|
1775
|
+
if (raw.length > 24)
|
|
1776
|
+
return false;
|
|
1777
|
+
const stripped = raw.replace(/[。.!!~~\s“”"',,]+/gu, "");
|
|
1778
|
+
const zh = AUTO_RETRY_DONE_PHRASE; // e.g. 结束了
|
|
1779
|
+
const en = AUTO_RETRY_DONE_PHRASE_EN.toUpperCase(); // e.g. DONE
|
|
1780
|
+
const hasDone = stripped.includes(zh) || stripped.toUpperCase().includes(en);
|
|
1781
|
+
if (!hasDone)
|
|
1782
|
+
return false;
|
|
1783
|
+
// Reject if a not-finished / negation cue is present.
|
|
1784
|
+
if (/(没|未|不|还在|继续|not|isn'?t|aren'?t|no[t\s])/iu.test(raw))
|
|
1785
|
+
return false;
|
|
1786
|
+
return true;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Auto-retry loop: if `reply` looks truncated, ask the same session whether it
|
|
1790
|
+
* finished. The session is the arbiter: if it replies exactly the done phrase,
|
|
1791
|
+
* the ORIGINAL reply was the real result and we deliver that. Otherwise the
|
|
1792
|
+
* session kept working and its new output becomes the latest reply, which we
|
|
1793
|
+
* re-check. Loops until the session confirms completion, a reply no longer
|
|
1794
|
+
* looks truncated, the budget is spent, or the session cannot answer.
|
|
1795
|
+
*/
|
|
1796
|
+
async autoRetryUntilComplete(params) {
|
|
1797
|
+
const { chatId, sessionKey, senderName, triggerId, lastHumanMessageId, isNativeCommandTrigger, liveStatus } = params;
|
|
1798
|
+
let reply = params.initialReply ?? "";
|
|
1799
|
+
// Whether we still owe at least one probe regardless of how `reply` looks.
|
|
1800
|
+
// The timeout path starts in this state (no reply to inspect yet).
|
|
1801
|
+
let forceProbe = params.initialError !== undefined && !params.initialReply;
|
|
1802
|
+
if (!autoRetryEnabled() || isNativeCommandTrigger) {
|
|
1803
|
+
if (forceProbe)
|
|
1804
|
+
throw new Error(`Agent error: ${params.initialError}`);
|
|
1805
|
+
return reply;
|
|
1806
|
+
}
|
|
1807
|
+
const maxRetry = autoRetryMax();
|
|
1808
|
+
let compacted = false; // auto-compact at most once per auto-retry round
|
|
1809
|
+
let probedAtLeastOnce = false;
|
|
1810
|
+
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
|
1811
|
+
if (!forceProbe) {
|
|
1812
|
+
const parsed = this.extractBridgeAttachments(reply);
|
|
1813
|
+
const trimmed = parsed.text.trim();
|
|
1814
|
+
// Hard rule: a reply that produced attachments (image/file/doc) is a
|
|
1815
|
+
// finished product — never auto-retry it (and never risk dropping the
|
|
1816
|
+
// bridge attachment marker by replacing `reply` with a probe answer).
|
|
1817
|
+
if (parsed.attachments.length > 0)
|
|
1818
|
+
return reply;
|
|
1819
|
+
// Only engage for plausibly-successful-but-truncated text. Empty/NO_REPLY
|
|
1820
|
+
// and runtime failures have their own handling upstream.
|
|
1821
|
+
if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
|
|
1822
|
+
return reply;
|
|
1823
|
+
if (!this.looksTruncated(trimmed))
|
|
1824
|
+
return reply;
|
|
1825
|
+
}
|
|
1826
|
+
forceProbe = false; // consumed; subsequent rounds inspect the probe reply
|
|
1827
|
+
const en = this.isEn(chatId);
|
|
1828
|
+
await liveStatus?.progress(en
|
|
1829
|
+
? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
|
|
1830
|
+
: `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
|
|
1831
|
+
// One short line, in the user's own voice (we intentionally do not change
|
|
1832
|
+
// senderName). Each locale uses its own done-phrase so the English probe
|
|
1833
|
+
// stays fully English; detection accepts both phrases.
|
|
1834
|
+
const probe = en
|
|
1835
|
+
? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
|
|
1836
|
+
: `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`;
|
|
1837
|
+
let probeReply;
|
|
1838
|
+
try {
|
|
1839
|
+
probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
|
|
1840
|
+
sessionKey,
|
|
1841
|
+
unsyncedMessages: [],
|
|
1842
|
+
currentMessage: probe,
|
|
1843
|
+
currentSenderName: senderName,
|
|
1844
|
+
deliver: false,
|
|
1845
|
+
timeoutMs: 1_800_000,
|
|
1846
|
+
includeContext: false,
|
|
1847
|
+
includeBridgeAttachmentHint: false,
|
|
1848
|
+
onProgress: (event) => liveStatus?.progress(event),
|
|
1849
|
+
}));
|
|
1850
|
+
probedAtLeastOnce = true;
|
|
1851
|
+
}
|
|
1852
|
+
catch (err) {
|
|
1853
|
+
// Confirmation round failed (timeout/unhealthy). If the session is heavy
|
|
1854
|
+
// (≥ threshold) and we have not compacted yet this round, auto-compact and
|
|
1855
|
+
// keep retrying — a bloated session is the usual cause of these timeouts.
|
|
1856
|
+
// Otherwise stop looping.
|
|
1857
|
+
console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
|
|
1858
|
+
if (!compacted) {
|
|
1859
|
+
const pct = await this.getContextUsagePercent(sessionKey);
|
|
1860
|
+
if (pct >= autoRetryCompactPct()) {
|
|
1861
|
+
const en = this.isEn(chatId);
|
|
1862
|
+
await liveStatus?.progress(en
|
|
1863
|
+
? `🧹 Context is large (${pct}%) and the retry failed — auto-compacting…`
|
|
1864
|
+
: `🧹 上下文过大(${pct}%)且重试出错,正在自动压缩…`).catch(() => { });
|
|
1865
|
+
try {
|
|
1866
|
+
const r = await this.compactWithFallback(sessionKey);
|
|
1867
|
+
compacted = true; // mark as attempted regardless, to avoid re-compacting in a loop
|
|
1868
|
+
if (r.compacted) {
|
|
1869
|
+
console.log(`[${this.config.name}] auto-retry: compacted session (${r.method}${r.detail ? " " + r.detail : ""}) at ${pct}% for ${chatId.slice(-8)}; continuing`);
|
|
1870
|
+
forceProbe = !reply; // if we still have no reply (timeout path), keep probing
|
|
1871
|
+
continue; // retry the probe on the now-lighter session
|
|
1872
|
+
}
|
|
1873
|
+
// Compact was a no-op; do not spin.
|
|
1874
|
+
console.warn(`[${this.config.name}] auto-retry: compact was a no-op for ${chatId.slice(-8)} (${r.reason || "no reason"})`);
|
|
1875
|
+
}
|
|
1876
|
+
catch (compactErr) {
|
|
1877
|
+
console.warn(`[${this.config.name}] auto-retry compact failed for ${chatId.slice(-8)}:`, this.errorSummary(compactErr));
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
// No reply to fall back to (timeout path) and retries exhausted/failed:
|
|
1882
|
+
// re-throw so the caller surfaces the original failure.
|
|
1883
|
+
if (!reply)
|
|
1884
|
+
throw (err instanceof Error ? err : new Error(`Agent error: ${params.initialError || "retry failed"}`));
|
|
1885
|
+
return reply;
|
|
1886
|
+
}
|
|
1887
|
+
const probeText = this.extractBridgeAttachments(probeReply).text.trim();
|
|
1888
|
+
if (!probeText || probeText.toUpperCase() === "NO_REPLY") {
|
|
1889
|
+
if (!reply)
|
|
1890
|
+
throw new Error(`Agent error: ${params.initialError || "no reply after retry"}`);
|
|
1891
|
+
return reply; // session could not answer; deliver existing result
|
|
1892
|
+
}
|
|
1893
|
+
if (this.isDonePhrase(probeText)) {
|
|
1894
|
+
console.log(`[${this.config.name}] auto-retry: session confirmed done after ${attempt} check(s) for ${chatId.slice(-8)}`);
|
|
1895
|
+
// On the timeout path we have no original reply; the done confirmation
|
|
1896
|
+
// means the interrupted work actually finished — but we have nothing
|
|
1897
|
+
// user-visible to deliver, so treat it as NO_REPLY rather than fabricate.
|
|
1898
|
+
return reply || "NO_REPLY";
|
|
1899
|
+
}
|
|
1900
|
+
// Not done: the session kept working. Its output is the new latest reply.
|
|
1901
|
+
console.log(`[${this.config.name}] auto-retry: session continued (attempt ${attempt}) for ${chatId.slice(-8)}`);
|
|
1902
|
+
reply = probeReply;
|
|
1903
|
+
}
|
|
1904
|
+
console.warn(`[${this.config.name}] auto-retry budget (${maxRetry}) spent for ${chatId.slice(-8)}; delivering latest result`);
|
|
1905
|
+
void probedAtLeastOnce;
|
|
1906
|
+
if (!reply)
|
|
1907
|
+
throw new Error(`Agent error: ${params.initialError || "retry budget exhausted"}`);
|
|
1908
|
+
return reply;
|
|
1909
|
+
}
|
|
1606
1910
|
cancelDelayedFailure(chatId) {
|
|
1607
1911
|
const timer = this.delayedFailureTimers.get(chatId);
|
|
1608
1912
|
if (timer)
|
|
@@ -1940,10 +2244,24 @@ export class FeishuBot {
|
|
|
1940
2244
|
}
|
|
1941
2245
|
buildLiveStatusCard(view, chatId) {
|
|
1942
2246
|
const en = this.isEn(chatId);
|
|
2247
|
+
// Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
|
|
2248
|
+
// one compact grey line (status emoji + tool-call count + total elapsed) so
|
|
2249
|
+
// the finished card does not take up much screen space.
|
|
2250
|
+
if (view.state === "done") {
|
|
2251
|
+
const statusEmoji = view.noReply ? "💤" : "✅";
|
|
2252
|
+
const summary = en
|
|
2253
|
+
? `${statusEmoji} ${view.toolCalls} tool call${view.toolCalls === 1 ? "" : "s"} · ⏱ ${view.elapsed}`
|
|
2254
|
+
: `${statusEmoji} 累计${view.toolCalls} 次工具调用 · ⏱ 耗时${view.elapsed}`;
|
|
2255
|
+
return {
|
|
2256
|
+
schema: "2.0",
|
|
2257
|
+
config: { update_multi: true, width_mode: "fill" },
|
|
2258
|
+
body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
1943
2261
|
const elements = [];
|
|
1944
|
-
//
|
|
1945
|
-
// relative time mm:ss
|
|
1946
|
-
//
|
|
2262
|
+
// Running OR failed: show the recent activity window (each line prefixed with
|
|
2263
|
+
// the relative time mm:ss). On failure we deliberately KEEP the last lines so
|
|
2264
|
+
// the steps leading up to the error/kill/timeout are visible for debugging.
|
|
1947
2265
|
if (view.lines.length > 0) {
|
|
1948
2266
|
const iconFor = (kind) => kind === "tool_start" ? "▸"
|
|
1949
2267
|
: kind === "tool_end" ? "✓"
|
|
@@ -1969,8 +2287,7 @@ export class FeishuBot {
|
|
|
1969
2287
|
if (view.model)
|
|
1970
2288
|
footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
|
|
1971
2289
|
elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
|
|
1972
|
-
|
|
1973
|
-
const template = view.state === "done" ? "green" : view.state === "failed" ? "orange" : "blue";
|
|
2290
|
+
const template = view.state === "failed" ? "orange" : "blue";
|
|
1974
2291
|
return {
|
|
1975
2292
|
schema: "2.0",
|
|
1976
2293
|
// update_multi is REQUIRED for im.message.patch to update the card; Feishu
|
|
@@ -2587,14 +2904,88 @@ export class FeishuBot {
|
|
|
2587
2904
|
/**
|
|
2588
2905
|
* Handle /compact command: compress session context.
|
|
2589
2906
|
*/
|
|
2907
|
+
/**
|
|
2908
|
+
* Compact a session with a fallback chain:
|
|
2909
|
+
* 1. OpenClaw's native (LLM-summary) compaction — best semantics.
|
|
2910
|
+
* 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
|
|
2911
|
+
* the transcript file to drop tool calls/results while keeping the
|
|
2912
|
+
* conversation. This needs no model, so it works no matter how large the
|
|
2913
|
+
* session is (the native path itself fails to fit an oversized session
|
|
2914
|
+
* into the model). A backup is always written before any file change.
|
|
2915
|
+
* Returns a structured result describing what happened.
|
|
2916
|
+
*/
|
|
2917
|
+
async compactWithFallback(sessionKey) {
|
|
2918
|
+
// 1. Native compaction first.
|
|
2919
|
+
let nativeReason;
|
|
2920
|
+
try {
|
|
2921
|
+
const res = await this.openclawClient.compactSession(sessionKey);
|
|
2922
|
+
if (res?.compacted === true)
|
|
2923
|
+
return { compacted: true, method: "native" };
|
|
2924
|
+
nativeReason = typeof res?.reason === "string" ? res.reason : undefined;
|
|
2925
|
+
}
|
|
2926
|
+
catch (err) {
|
|
2927
|
+
nativeReason = err.message;
|
|
2928
|
+
}
|
|
2929
|
+
// 2. Fall back to tool-trim on the transcript file.
|
|
2930
|
+
let sessionId;
|
|
2931
|
+
try {
|
|
2932
|
+
const info = await this.openclawClient.getSessionInfo(sessionKey);
|
|
2933
|
+
sessionId = info?.session?.sessionId;
|
|
2934
|
+
}
|
|
2935
|
+
catch {
|
|
2936
|
+
// ignore; handled below
|
|
2937
|
+
}
|
|
2938
|
+
if (!sessionId) {
|
|
2939
|
+
return { compacted: false, method: "none", reason: nativeReason || "could not resolve session file" };
|
|
2940
|
+
}
|
|
2941
|
+
const filePath = resolveSessionFilePath(sessionKey, sessionId);
|
|
2942
|
+
if (!filePath) {
|
|
2943
|
+
return { compacted: false, method: "none", reason: nativeReason || "transcript file not found" };
|
|
2944
|
+
}
|
|
2945
|
+
const trim = toolTrimCompactFile(filePath, toolTrimKeepRecent());
|
|
2946
|
+
if (!trim.ok) {
|
|
2947
|
+
return { compacted: false, method: "none", reason: trim.reason || "tool-trim failed" };
|
|
2948
|
+
}
|
|
2949
|
+
const before = trim.bytesBefore || 0;
|
|
2950
|
+
const after = trim.bytesAfter || 0;
|
|
2951
|
+
if (before > 0 && after >= before) {
|
|
2952
|
+
// Nothing meaningful trimmed.
|
|
2953
|
+
return { compacted: false, method: "none", reason: trim.reason || "no tool content to trim" };
|
|
2954
|
+
}
|
|
2955
|
+
const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
|
|
2956
|
+
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
|
|
2957
|
+
return {
|
|
2958
|
+
compacted: true,
|
|
2959
|
+
method: "tool-trim",
|
|
2960
|
+
detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2590
2963
|
async handleCompactCommand(chatId, messageId) {
|
|
2591
2964
|
const sessionKey = this.getSessionKey(chatId);
|
|
2965
|
+
const en = this.isEn(chatId);
|
|
2592
2966
|
try {
|
|
2593
|
-
await this.
|
|
2594
|
-
|
|
2967
|
+
const r = await this.compactWithFallback(sessionKey);
|
|
2968
|
+
if (r.compacted) {
|
|
2969
|
+
if (r.method === "tool-trim") {
|
|
2970
|
+
await this.replyMessage(messageId, en
|
|
2971
|
+
? `✅ Session compacted (tool-trim${r.detail ? " " + r.detail : ""})`
|
|
2972
|
+
: `✅ Session 已压缩(删工具调用${r.detail ? " " + r.detail : ""})`);
|
|
2973
|
+
}
|
|
2974
|
+
else {
|
|
2975
|
+
await this.replyMessage(messageId, en ? `✅ Session compacted` : `✅ Session 已压缩`);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
else {
|
|
2979
|
+
const reason = r.reason || (en ? "nothing to compact" : "无需压缩");
|
|
2980
|
+
await this.replyMessage(messageId, en
|
|
2981
|
+
? `ℹ️ Session not compacted (${reason})`
|
|
2982
|
+
: `ℹ️ Session 未压缩(${reason})`);
|
|
2983
|
+
}
|
|
2595
2984
|
}
|
|
2596
2985
|
catch (err) {
|
|
2597
|
-
await this.replyMessage(messageId,
|
|
2986
|
+
await this.replyMessage(messageId, en
|
|
2987
|
+
? `❌ Compact failed: ${err.message}`
|
|
2988
|
+
: `❌ 压缩失败: ${err.message}`);
|
|
2598
2989
|
}
|
|
2599
2990
|
}
|
|
2600
2991
|
/**
|
package/dist/live-status.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export type LiveStatusView = {
|
|
|
16
16
|
elapsed: string;
|
|
17
17
|
/** Footer: model name, e.g. "phgeek-gw/claude-opus-4.8". */
|
|
18
18
|
model?: string;
|
|
19
|
+
/** Total tool calls in the run (for the compact finished summary). */
|
|
20
|
+
toolCalls: number;
|
|
21
|
+
/** True when finished via noReply(): the model produced no user-visible reply. */
|
|
22
|
+
noReply: boolean;
|
|
19
23
|
/** Terminal state: running / done / failed (lets renderer pick color). */
|
|
20
24
|
state: "running" | "done" | "failed";
|
|
21
25
|
};
|
package/dist/live-status.js
CHANGED
|
@@ -256,16 +256,26 @@ export class LiveStatusController {
|
|
|
256
256
|
title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
257
257
|
else
|
|
258
258
|
title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
|
|
259
|
-
//
|
|
260
|
-
// (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
// On a clean finish (done / NO_REPLY) show only the compact summary. On a
|
|
260
|
+
// failure (error / killed / timeout) keep the recent activity window too, so
|
|
261
|
+
// the last few steps before the failure are visible for debugging.
|
|
262
|
+
let lines;
|
|
263
|
+
if (this.state === "running") {
|
|
264
|
+
lines = [...this.lines];
|
|
265
|
+
}
|
|
266
|
+
else if (this.state === "failed") {
|
|
267
|
+
lines = [...this.lines, this.buildSummaryLine(en)];
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
lines = [this.buildSummaryLine(en)];
|
|
271
|
+
}
|
|
264
272
|
return {
|
|
265
273
|
title,
|
|
266
274
|
lines,
|
|
267
275
|
elapsed: this.formatElapsed(),
|
|
268
276
|
model: this.opts.model,
|
|
277
|
+
toolCalls: this.toolCallCount,
|
|
278
|
+
noReply: this.noReplyResult,
|
|
269
279
|
state: this.state,
|
|
270
280
|
};
|
|
271
281
|
}
|
package/dist/openclaw-client.js
CHANGED
|
@@ -175,6 +175,17 @@ export class OpenClawClient {
|
|
|
175
175
|
data: { text: textParts.join("\n") },
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
|
+
else if (state === "error") {
|
|
179
|
+
// A chat-level error (e.g. "Request timed out before a response")
|
|
180
|
+
// must NOT be silently dropped — otherwise mid-run delta text gets
|
|
181
|
+
// surfaced as if it were a normal reply and the run neither errors
|
|
182
|
+
// nor retries. Record it so the collect loop can surface/handle it.
|
|
183
|
+
this.agentEvents.get(sk).push({
|
|
184
|
+
...frame.payload,
|
|
185
|
+
stream: "chatError",
|
|
186
|
+
data: { error: frame.payload?.errorMessage || frame.payload?.error || "chat error" },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
178
189
|
}
|
|
179
190
|
else {
|
|
180
191
|
this.agentEvents.get(sk).push(frame.payload);
|
|
@@ -835,6 +846,35 @@ export class OpenClawClient {
|
|
|
835
846
|
reject(new Error(`Agent error: ${errText}`));
|
|
836
847
|
return;
|
|
837
848
|
}
|
|
849
|
+
if (ev.stream === "chatError") {
|
|
850
|
+
// Chat-level error (most commonly a request timeout). Treat it like
|
|
851
|
+
// a lifecycle error: recoverable ones defer (agent may auto-recover
|
|
852
|
+
// and still produce a real final); otherwise reject so the bridge
|
|
853
|
+
// surfaces the failure / lets auto-retry kick in — instead of
|
|
854
|
+
// surfacing whatever mid-run delta text happened to be collected.
|
|
855
|
+
const errText = String(ev.data?.error || "chat error");
|
|
856
|
+
if (this.isRecoverableAgentError(errText)) {
|
|
857
|
+
pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
|
|
858
|
+
resetIdleTimer();
|
|
859
|
+
console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
|
|
860
|
+
bucket.splice(i, 1);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
clearTimeout(idleTimer);
|
|
864
|
+
clearInterval(poller);
|
|
865
|
+
if (chatFinalTimer)
|
|
866
|
+
clearTimeout(chatFinalTimer);
|
|
867
|
+
if (lifecycleEndTimer)
|
|
868
|
+
clearTimeout(lifecycleEndTimer);
|
|
869
|
+
// Abort the (possibly still-running) backgrounded run before we
|
|
870
|
+
// reject, so an auto-retry probe does not race a zombie run on the
|
|
871
|
+
// same session. Idempotent/best-effort.
|
|
872
|
+
this.abortChat(targetSessionKey || sessionKey, evRunId || runId).catch((abortErr) => {
|
|
873
|
+
console.warn(`[OpenClaw] abort after chat error failed:`, abortErr.message);
|
|
874
|
+
});
|
|
875
|
+
reject(new Error(`Agent error: ${errText}`));
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
838
878
|
}
|
|
839
879
|
}
|
|
840
880
|
}, 50);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ToolTrimResult = {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
reason?: string;
|
|
4
|
+
/** Bytes before / after, and line counts, for reporting. */
|
|
5
|
+
bytesBefore?: number;
|
|
6
|
+
bytesAfter?: number;
|
|
7
|
+
linesBefore?: number;
|
|
8
|
+
linesAfter?: number;
|
|
9
|
+
removedToolResults?: number;
|
|
10
|
+
strippedToolCalls?: number;
|
|
11
|
+
removedEmptyAssistants?: number;
|
|
12
|
+
keptRecentToolCalls?: number;
|
|
13
|
+
backupPath?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the on-disk OpenClaw transcript file for a session.
|
|
17
|
+
*
|
|
18
|
+
* Session key looks like `agent:<agentId>:<label>` (e.g.
|
|
19
|
+
* `agent:main:lma-claude-oc_xxx`). The transcript lives at
|
|
20
|
+
* `<openclawHome>/agents/<agentId>/sessions/<sessionId>.jsonl`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveSessionFilePath(sessionKey: string, sessionId: string, openclawHome?: string): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* "Tool-trim" compaction: rewrite a session transcript in place, removing the
|
|
25
|
+
* tool-call noise while keeping the conversation intact:
|
|
26
|
+
* - drop every toolResult message entirely;
|
|
27
|
+
* - strip the `toolCall` parts from assistant messages but KEEP their text
|
|
28
|
+
* (the model's "thinking out loud while acting"); if an assistant message
|
|
29
|
+
* had only tool calls and no text, drop the whole line;
|
|
30
|
+
* - fix `stopReason: "toolUse"` -> "stop" on messages that no longer have a
|
|
31
|
+
* tool call, so the loader is not confused.
|
|
32
|
+
*
|
|
33
|
+
* This does NOT call any model, so it works no matter how large the session is
|
|
34
|
+
* (unlike OpenClaw's LLM-summary compaction, which itself fails to fit an
|
|
35
|
+
* oversized session into the model). A backup is always written first.
|
|
36
|
+
*
|
|
37
|
+
* Typical reduction is ~70-90% for tool-heavy sessions.
|
|
38
|
+
*/
|
|
39
|
+
export declare function toolTrimCompactFile(filePath: string, keepRecentToolCalls?: number): ToolTrimResult;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, copyFileSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the on-disk OpenClaw transcript file for a session.
|
|
6
|
+
*
|
|
7
|
+
* Session key looks like `agent:<agentId>:<label>` (e.g.
|
|
8
|
+
* `agent:main:lma-claude-oc_xxx`). The transcript lives at
|
|
9
|
+
* `<openclawHome>/agents/<agentId>/sessions/<sessionId>.jsonl`.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveSessionFilePath(sessionKey, sessionId, openclawHome) {
|
|
12
|
+
if (!sessionId)
|
|
13
|
+
return null;
|
|
14
|
+
const home = openclawHome
|
|
15
|
+
|| process.env.OPENCLAW_HOME
|
|
16
|
+
|| process.env.OPENCLAW_CONFIG_DIR
|
|
17
|
+
|| join(homedir(), ".openclaw");
|
|
18
|
+
// agentId is the second segment of agent:<agentId>:<label>; default to "main".
|
|
19
|
+
const parts = sessionKey.split(":");
|
|
20
|
+
const agentId = parts.length >= 3 && parts[0] === "agent" ? parts[1] : "main";
|
|
21
|
+
const file = join(home, "agents", agentId, "sessions", `${sessionId}.jsonl`);
|
|
22
|
+
return existsSync(file) ? file : null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* "Tool-trim" compaction: rewrite a session transcript in place, removing the
|
|
26
|
+
* tool-call noise while keeping the conversation intact:
|
|
27
|
+
* - drop every toolResult message entirely;
|
|
28
|
+
* - strip the `toolCall` parts from assistant messages but KEEP their text
|
|
29
|
+
* (the model's "thinking out loud while acting"); if an assistant message
|
|
30
|
+
* had only tool calls and no text, drop the whole line;
|
|
31
|
+
* - fix `stopReason: "toolUse"` -> "stop" on messages that no longer have a
|
|
32
|
+
* tool call, so the loader is not confused.
|
|
33
|
+
*
|
|
34
|
+
* This does NOT call any model, so it works no matter how large the session is
|
|
35
|
+
* (unlike OpenClaw's LLM-summary compaction, which itself fails to fit an
|
|
36
|
+
* oversized session into the model). A backup is always written first.
|
|
37
|
+
*
|
|
38
|
+
* Typical reduction is ~70-90% for tool-heavy sessions.
|
|
39
|
+
*/
|
|
40
|
+
export function toolTrimCompactFile(filePath, keepRecentToolCalls = 3) {
|
|
41
|
+
if (!existsSync(filePath))
|
|
42
|
+
return { ok: false, reason: "transcript file not found" };
|
|
43
|
+
const original = readFileSync(filePath, "utf8");
|
|
44
|
+
const lines = original.split("\n");
|
|
45
|
+
const bytesBefore = Buffer.byteLength(original, "utf8");
|
|
46
|
+
// First pass: collect every toolCall id in order, so we can keep the most
|
|
47
|
+
// recent N of them (and their matching toolResults) intact. Recent tool calls
|
|
48
|
+
// are often still relevant to what the agent is doing next.
|
|
49
|
+
const toolCallIds = [];
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (line.trim() === "")
|
|
52
|
+
continue;
|
|
53
|
+
let d;
|
|
54
|
+
try {
|
|
55
|
+
d = JSON.parse(line);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (d?.type !== "message")
|
|
61
|
+
continue;
|
|
62
|
+
const msg = d.message;
|
|
63
|
+
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
|
64
|
+
for (const p of msg.content) {
|
|
65
|
+
if (p && typeof p === "object" && p.type === "toolCall" && p.id)
|
|
66
|
+
toolCallIds.push(String(p.id));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const keepN = Math.max(0, Math.floor(keepRecentToolCalls));
|
|
71
|
+
const keepIds = new Set(keepN > 0 ? toolCallIds.slice(-keepN) : []);
|
|
72
|
+
const out = [];
|
|
73
|
+
let removedToolResults = 0;
|
|
74
|
+
let strippedToolCalls = 0;
|
|
75
|
+
let removedEmptyAssistants = 0;
|
|
76
|
+
let keptRecentToolCalls = 0;
|
|
77
|
+
let linesBefore = 0;
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.trim() === "") {
|
|
80
|
+
continue;
|
|
81
|
+
} // drop blank lines; we re-join with \n
|
|
82
|
+
linesBefore++;
|
|
83
|
+
let d;
|
|
84
|
+
try {
|
|
85
|
+
d = JSON.parse(line);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
out.push(line);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (d?.type !== "message") {
|
|
92
|
+
out.push(line);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const msg = d.message;
|
|
96
|
+
const role = msg?.role;
|
|
97
|
+
if (role === "toolResult") {
|
|
98
|
+
// Keep the toolResult only if its toolCall is in the recent keep-set.
|
|
99
|
+
if (msg?.toolCallId && keepIds.has(String(msg.toolCallId))) {
|
|
100
|
+
out.push(line);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
removedToolResults++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (role === "assistant" && Array.isArray(msg?.content)) {
|
|
107
|
+
const content = msg.content;
|
|
108
|
+
const hasToolCall = content.some((p) => p && typeof p === "object" && p.type === "toolCall");
|
|
109
|
+
if (hasToolCall) {
|
|
110
|
+
// Keep toolCalls that are in the recent keep-set; strip the rest.
|
|
111
|
+
const kept = content.filter((p) => {
|
|
112
|
+
if (!p || typeof p !== "object")
|
|
113
|
+
return false;
|
|
114
|
+
if (p.type !== "toolCall")
|
|
115
|
+
return true;
|
|
116
|
+
return p.id && keepIds.has(String(p.id));
|
|
117
|
+
});
|
|
118
|
+
const keptToolCall = kept.some((p) => p.type === "toolCall");
|
|
119
|
+
const hasText = kept.some((p) => p.type === "text" && typeof p.text === "string" && p.text.trim() !== "");
|
|
120
|
+
if (!keptToolCall && !hasText) {
|
|
121
|
+
removedEmptyAssistants++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (keptToolCall) {
|
|
125
|
+
keptRecentToolCalls++;
|
|
126
|
+
// stopReason stays toolUse since a toolCall remains.
|
|
127
|
+
if (kept.length !== content.length)
|
|
128
|
+
msg.content = kept;
|
|
129
|
+
out.push(JSON.stringify(d));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// All toolCalls stripped: keep text only, fix stopReason.
|
|
133
|
+
msg.content = kept;
|
|
134
|
+
if (msg.stopReason === "toolUse")
|
|
135
|
+
msg.stopReason = "stop";
|
|
136
|
+
strippedToolCalls++;
|
|
137
|
+
out.push(JSON.stringify(d));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
out.push(line);
|
|
142
|
+
}
|
|
143
|
+
// Safety: verify every output line is valid JSON before committing.
|
|
144
|
+
for (const l of out) {
|
|
145
|
+
try {
|
|
146
|
+
JSON.parse(l);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { ok: false, reason: "produced invalid JSON; aborted" };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (removedToolResults === 0 && strippedToolCalls === 0 && removedEmptyAssistants === 0) {
|
|
153
|
+
return { ok: true, reason: "no tool content to trim", bytesBefore, bytesAfter: bytesBefore, linesBefore, linesAfter: out.length, keptRecentToolCalls };
|
|
154
|
+
}
|
|
155
|
+
// Always back up first.
|
|
156
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
157
|
+
const backupPath = `${filePath}.bak-tooltrim-${stamp}`;
|
|
158
|
+
copyFileSync(filePath, backupPath);
|
|
159
|
+
// Atomic write: temp file + rename.
|
|
160
|
+
const body = out.join("\n") + "\n";
|
|
161
|
+
const tmp = `${filePath}.tmp-tooltrim`;
|
|
162
|
+
writeFileSync(tmp, body, "utf8");
|
|
163
|
+
renameSync(tmp, filePath);
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
bytesBefore,
|
|
167
|
+
bytesAfter: Buffer.byteLength(body, "utf8"),
|
|
168
|
+
linesBefore,
|
|
169
|
+
linesAfter: out.length,
|
|
170
|
+
removedToolResults,
|
|
171
|
+
strippedToolCalls,
|
|
172
|
+
removedEmptyAssistants,
|
|
173
|
+
keptRecentToolCalls,
|
|
174
|
+
backupPath,
|
|
175
|
+
};
|
|
176
|
+
}
|