openclaw-lark-multi-agent 1.1.2 → 1.2.1
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/compact-progress.d.ts +75 -0
- package/dist/compact-progress.js +177 -0
- package/dist/feishu-bot.d.ts +66 -0
- package/dist/feishu-bot.js +487 -14
- package/dist/live-status.js +13 -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/dist/feishu-bot.js
CHANGED
|
@@ -3,11 +3,13 @@ import { createRequire } from "module";
|
|
|
3
3
|
import { persistBotModel } from "./config.js";
|
|
4
4
|
import { getI18n, normalizeLocale } from "./i18n.js";
|
|
5
5
|
import { LiveStatusController } from "./live-status.js";
|
|
6
|
+
import { CompactProgressController } from "./compact-progress.js";
|
|
6
7
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
7
8
|
import { basename, extname, join, resolve } from "path";
|
|
8
9
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
9
10
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
10
11
|
import { discussionManager } from "./discussion-manager.js";
|
|
12
|
+
import { resolveSessionFilePath, toolTrimCompactFile } from "./session-file-compactor.js";
|
|
11
13
|
const require = createRequire(import.meta.url);
|
|
12
14
|
const LMA_VERSION = require("../package.json").version;
|
|
13
15
|
const MAX_BOT_STREAK = 10;
|
|
@@ -17,6 +19,26 @@ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
|
17
19
|
const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
|
|
18
20
|
const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
|
|
19
21
|
const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
|
|
22
|
+
// Auto-retry: when a reply looks truncated/incomplete, ask the same session
|
|
23
|
+
// whether it finished. If it did not, let it continue; loop until it confirms
|
|
24
|
+
// completion or the retry budget is spent. Off by setting AUTO_RETRY=0.
|
|
25
|
+
// Read at call time (not module load) so tests can toggle it per-case.
|
|
26
|
+
function autoRetryEnabled() { return process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY !== "0"; }
|
|
27
|
+
function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_MAX || 5); }
|
|
28
|
+
// The exact phrase the agent must reply to confirm completion. The probe asks
|
|
29
|
+
// for the locale-appropriate phrase; detection accepts BOTH so either works.
|
|
30
|
+
const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
|
|
31
|
+
const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE_EN || "DONE";
|
|
32
|
+
// When an auto-retry probe errors AND context usage is at/above this percent,
|
|
33
|
+
// auto-compact the session once and keep retrying (instead of giving up). This
|
|
34
|
+
// rescues the “session too heavy → run times out / lock contention” failure mode.
|
|
35
|
+
function autoRetryCompactPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_COMPACT_PCT || 50); }
|
|
36
|
+
// How many of the most-recent tool calls (and their results) tool-trim keeps
|
|
37
|
+
// intact — recent tool calls may still be relevant to the agent's next step.
|
|
38
|
+
function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
|
|
39
|
+
// After a reply is delivered, if context usage reaches this percent, send the
|
|
40
|
+
// user a one-time alert to compact. 0 disables the alert.
|
|
41
|
+
function contextAlertPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_CONTEXT_ALERT_PCT || 80); }
|
|
20
42
|
class UnhealthySessionError extends Error {
|
|
21
43
|
status;
|
|
22
44
|
constructor(status) {
|
|
@@ -58,6 +80,8 @@ export class FeishuBot {
|
|
|
58
80
|
delayedFailureTimers = new Map();
|
|
59
81
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
60
82
|
lastRealDeliveryAt = new Map();
|
|
83
|
+
/** Per-chat: whether we've already sent the high-context alert this cycle. */
|
|
84
|
+
contextAlerted = new Map();
|
|
61
85
|
/** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
|
|
62
86
|
activeDeliveryTargets = new Map();
|
|
63
87
|
adminOpenId;
|
|
@@ -1220,6 +1244,31 @@ export class FeishuBot {
|
|
|
1220
1244
|
onProgress: (event) => liveStatus?.progress(event),
|
|
1221
1245
|
}));
|
|
1222
1246
|
}
|
|
1247
|
+
catch (mainErr) {
|
|
1248
|
+
// The main run failed before returning a reply. If it is a retryable
|
|
1249
|
+
// error (e.g. a request timeout) and not a session-health failure,
|
|
1250
|
+
// reuse the SAME auto-retry mechanism to let the session resume from
|
|
1251
|
+
// where it was interrupted, instead of giving up immediately.
|
|
1252
|
+
if (mainErr instanceof UnhealthySessionError)
|
|
1253
|
+
throw mainErr;
|
|
1254
|
+
const errText = mainErr?.message || String(mainErr);
|
|
1255
|
+
if (autoRetryEnabled() && !isNativeCommandTrigger && this.isRetryableAgentError(errText)) {
|
|
1256
|
+
console.warn(`[${this.config.name}] main run failed (${errText.slice(0, 80)}) for ${chatId.slice(-8)}; entering auto-retry`);
|
|
1257
|
+
reply = await this.autoRetryUntilComplete({
|
|
1258
|
+
chatId,
|
|
1259
|
+
sessionKey,
|
|
1260
|
+
senderName: lastHuman.senderName,
|
|
1261
|
+
triggerId,
|
|
1262
|
+
lastHumanMessageId: lastHuman.messageId || "",
|
|
1263
|
+
isNativeCommandTrigger,
|
|
1264
|
+
initialError: errText,
|
|
1265
|
+
liveStatus,
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
throw mainErr;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1223
1272
|
finally {
|
|
1224
1273
|
// OpenClaw may emit the final assistant session.message just after
|
|
1225
1274
|
// collectReply returns. Keep the trigger mapping briefly so proactive
|
|
@@ -1227,6 +1276,19 @@ export class FeishuBot {
|
|
|
1227
1276
|
releaseActiveDeliveryTarget();
|
|
1228
1277
|
}
|
|
1229
1278
|
console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
|
|
1279
|
+
// Auto-retry: if the reply looks truncated/incomplete, confirm with the
|
|
1280
|
+
// session and loop until it reports done (or the budget is spent). This
|
|
1281
|
+
// replaces `reply` with the final, confirmed result before delivery.
|
|
1282
|
+
reply = await this.autoRetryUntilComplete({
|
|
1283
|
+
chatId,
|
|
1284
|
+
sessionKey,
|
|
1285
|
+
senderName: lastHuman.senderName,
|
|
1286
|
+
triggerId,
|
|
1287
|
+
lastHumanMessageId: lastHuman.messageId || "",
|
|
1288
|
+
isNativeCommandTrigger,
|
|
1289
|
+
initialReply: reply,
|
|
1290
|
+
liveStatus,
|
|
1291
|
+
});
|
|
1230
1292
|
const parsedReply = this.extractBridgeAttachments(reply);
|
|
1231
1293
|
const visibleReply = parsedReply.text;
|
|
1232
1294
|
const trimmedReply = visibleReply.trim();
|
|
@@ -1325,6 +1387,8 @@ export class FeishuBot {
|
|
|
1325
1387
|
// not re-process any of them.
|
|
1326
1388
|
for (const id of mergedTriggerIds)
|
|
1327
1389
|
this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
|
|
1390
|
+
// After a successful reply, warn the user once if context is high.
|
|
1391
|
+
void this.maybeAlertHighContext(chatId);
|
|
1328
1392
|
}
|
|
1329
1393
|
catch (err) {
|
|
1330
1394
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
@@ -1603,6 +1667,247 @@ export class FeishuBot {
|
|
|
1603
1667
|
isRuntimeFailureText(text) {
|
|
1604
1668
|
return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
|
|
1605
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Is this main-run error worth auto-retrying (by resuming the session via the
|
|
1672
|
+
* probe loop)? Mainly transient failures — request/response timeouts and
|
|
1673
|
+
* aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
|
|
1674
|
+
* (auth/permission/quota/billing/model-not-supported), which would just fail
|
|
1675
|
+
* again. The error text comes from collectReply's `Agent error: <msg>`.
|
|
1676
|
+
*/
|
|
1677
|
+
isRetryableAgentError(errText) {
|
|
1678
|
+
const t = (errText || "").toLowerCase();
|
|
1679
|
+
// Terminal errors: never auto-retry.
|
|
1680
|
+
if (/(unauthorized|permission|forbidden|invalid api key|quota|billing|insufficient|model_not_supported|not supported|invalid_request)/.test(t))
|
|
1681
|
+
return false;
|
|
1682
|
+
// Retryable: timeouts and transient interruptions.
|
|
1683
|
+
return /(timed out|timeout|aborted|operation was aborted|no response|request failed|temporarily|connection|econnreset|socket hang|stream)/.test(t);
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Heuristic: does this reply look truncated / mid-task? Bias toward false
|
|
1687
|
+
* positives (“宁可错杀不放过”) — a false positive only costs one invisible
|
|
1688
|
+
* confirmation round, and the session self-check is the real arbiter. We gate
|
|
1689
|
+
* only on cheap text shape here, never on stopReason (the upstream rarely
|
|
1690
|
+
* provides one for normal-looking-but-truncated finals).
|
|
1691
|
+
*/
|
|
1692
|
+
looksTruncated(text) {
|
|
1693
|
+
const t = (text || "").trim();
|
|
1694
|
+
if (!t)
|
|
1695
|
+
return false; // empty/NO_REPLY handled elsewhere
|
|
1696
|
+
// Unbalanced code fence => almost certainly cut off mid-block.
|
|
1697
|
+
const fences = (t.match(/```/g) || []).length;
|
|
1698
|
+
if (fences % 2 === 1)
|
|
1699
|
+
return true;
|
|
1700
|
+
// Ends with an obvious continuation cue (dangling connector / lead-in).
|
|
1701
|
+
if (/[,,::、;;]\s*$/u.test(t))
|
|
1702
|
+
return true;
|
|
1703
|
+
if (/(接下来|然后|首先|现在我|让我|下一步|let me|now i|next,?|first,?)\s*$/iu.test(t))
|
|
1704
|
+
return true;
|
|
1705
|
+
// Core signal: ends without any sentence-ending punctuation / closer
|
|
1706
|
+
// (“连句号都没有就感觉没成功”). Accept terminators across languages,
|
|
1707
|
+
// closing brackets/quotes, code fences, and common emoji endings.
|
|
1708
|
+
const endOk = /[。!?.!?…)\]」』】)"'`”’~~—✨✅👍🙏]\s*$/u.test(t)
|
|
1709
|
+
|| /```\s*$/.test(t)
|
|
1710
|
+
|| /[}\]]\s*$/.test(t);
|
|
1711
|
+
return !endOk;
|
|
1712
|
+
}
|
|
1713
|
+
/** Current context usage percent for a session (totalTokens / contextTokens),
|
|
1714
|
+
* or 0 if it cannot be determined. Same basis as /status. */
|
|
1715
|
+
async getContextUsagePercent(sessionKey) {
|
|
1716
|
+
try {
|
|
1717
|
+
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
1718
|
+
const s = resp?.session;
|
|
1719
|
+
const total = s?.totalTokens || 0;
|
|
1720
|
+
const ctx = s?.contextTokens || 0;
|
|
1721
|
+
return ctx > 0 ? Math.round((total / ctx) * 100) : 0;
|
|
1722
|
+
}
|
|
1723
|
+
catch {
|
|
1724
|
+
return 0;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* After a reply is delivered, alert the user once if context usage crossed the
|
|
1729
|
+
* threshold (default 80%), so they can compact before a session bloats into the
|
|
1730
|
+
* timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
|
|
1731
|
+
* alerted we stay quiet until usage drops back below the threshold (e.g. after
|
|
1732
|
+
* a compact), then re-arm.
|
|
1733
|
+
*/
|
|
1734
|
+
async maybeAlertHighContext(chatId) {
|
|
1735
|
+
const threshold = contextAlertPct();
|
|
1736
|
+
if (threshold <= 0)
|
|
1737
|
+
return;
|
|
1738
|
+
try {
|
|
1739
|
+
const sessionKey = this.getSessionKey(chatId);
|
|
1740
|
+
const pct = await this.getContextUsagePercent(sessionKey);
|
|
1741
|
+
if (pct <= 0)
|
|
1742
|
+
return;
|
|
1743
|
+
const alerted = this.contextAlerted.get(chatId) || false;
|
|
1744
|
+
if (pct >= threshold) {
|
|
1745
|
+
if (alerted)
|
|
1746
|
+
return; // already warned this cycle
|
|
1747
|
+
this.contextAlerted.set(chatId, true);
|
|
1748
|
+
const en = this.isEn(chatId);
|
|
1749
|
+
const text = en
|
|
1750
|
+
? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
|
|
1751
|
+
: `\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`;
|
|
1752
|
+
await this.sendMessage(chatId, text).catch(() => { });
|
|
1753
|
+
}
|
|
1754
|
+
else if (pct < threshold - 5) {
|
|
1755
|
+
// Re-arm once usage clearly dropped (hysteresis to avoid flapping).
|
|
1756
|
+
this.contextAlerted.set(chatId, false);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
catch {
|
|
1760
|
+
// best-effort; never block delivery
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
/** True when the agent's reply is exactly a completion phrase (zh or en,
|
|
1764
|
+
* tolerant of trailing punctuation/whitespace and case). */
|
|
1765
|
+
/**
|
|
1766
|
+
* True when the agent's reply means “done”. We accept the exact phrase as well
|
|
1767
|
+
* as common wrappers (“已经结束了” / “好的,结束了” / “yes, DONE”), but reject it
|
|
1768
|
+
* when a negation/not-finished cue is present (“还没结束” / “not done”), so a
|
|
1769
|
+
* “still working” answer never counts as completion.
|
|
1770
|
+
*/
|
|
1771
|
+
isDonePhrase(text) {
|
|
1772
|
+
const raw = (text || "").trim();
|
|
1773
|
+
if (!raw)
|
|
1774
|
+
return false;
|
|
1775
|
+
// Short replies only — a long continuation is the model working, not confirming.
|
|
1776
|
+
if (raw.length > 24)
|
|
1777
|
+
return false;
|
|
1778
|
+
const stripped = raw.replace(/[。.!!~~\s“”"',,]+/gu, "");
|
|
1779
|
+
const zh = AUTO_RETRY_DONE_PHRASE; // e.g. 结束了
|
|
1780
|
+
const en = AUTO_RETRY_DONE_PHRASE_EN.toUpperCase(); // e.g. DONE
|
|
1781
|
+
const hasDone = stripped.includes(zh) || stripped.toUpperCase().includes(en);
|
|
1782
|
+
if (!hasDone)
|
|
1783
|
+
return false;
|
|
1784
|
+
// Reject if a not-finished / negation cue is present.
|
|
1785
|
+
if (/(没|未|不|还在|继续|not|isn'?t|aren'?t|no[t\s])/iu.test(raw))
|
|
1786
|
+
return false;
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Auto-retry loop: if `reply` looks truncated, ask the same session whether it
|
|
1791
|
+
* finished. The session is the arbiter: if it replies exactly the done phrase,
|
|
1792
|
+
* the ORIGINAL reply was the real result and we deliver that. Otherwise the
|
|
1793
|
+
* session kept working and its new output becomes the latest reply, which we
|
|
1794
|
+
* re-check. Loops until the session confirms completion, a reply no longer
|
|
1795
|
+
* looks truncated, the budget is spent, or the session cannot answer.
|
|
1796
|
+
*/
|
|
1797
|
+
async autoRetryUntilComplete(params) {
|
|
1798
|
+
const { chatId, sessionKey, senderName, triggerId, lastHumanMessageId, isNativeCommandTrigger, liveStatus } = params;
|
|
1799
|
+
let reply = params.initialReply ?? "";
|
|
1800
|
+
// Whether we still owe at least one probe regardless of how `reply` looks.
|
|
1801
|
+
// The timeout path starts in this state (no reply to inspect yet).
|
|
1802
|
+
let forceProbe = params.initialError !== undefined && !params.initialReply;
|
|
1803
|
+
if (!autoRetryEnabled() || isNativeCommandTrigger) {
|
|
1804
|
+
if (forceProbe)
|
|
1805
|
+
throw new Error(`Agent error: ${params.initialError}`);
|
|
1806
|
+
return reply;
|
|
1807
|
+
}
|
|
1808
|
+
const maxRetry = autoRetryMax();
|
|
1809
|
+
let compacted = false; // auto-compact at most once per auto-retry round
|
|
1810
|
+
let probedAtLeastOnce = false;
|
|
1811
|
+
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
|
1812
|
+
if (!forceProbe) {
|
|
1813
|
+
const parsed = this.extractBridgeAttachments(reply);
|
|
1814
|
+
const trimmed = parsed.text.trim();
|
|
1815
|
+
// Hard rule: a reply that produced attachments (image/file/doc) is a
|
|
1816
|
+
// finished product — never auto-retry it (and never risk dropping the
|
|
1817
|
+
// bridge attachment marker by replacing `reply` with a probe answer).
|
|
1818
|
+
if (parsed.attachments.length > 0)
|
|
1819
|
+
return reply;
|
|
1820
|
+
// Only engage for plausibly-successful-but-truncated text. Empty/NO_REPLY
|
|
1821
|
+
// and runtime failures have their own handling upstream.
|
|
1822
|
+
if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
|
|
1823
|
+
return reply;
|
|
1824
|
+
if (!this.looksTruncated(trimmed))
|
|
1825
|
+
return reply;
|
|
1826
|
+
}
|
|
1827
|
+
forceProbe = false; // consumed; subsequent rounds inspect the probe reply
|
|
1828
|
+
const en = this.isEn(chatId);
|
|
1829
|
+
await liveStatus?.progress(en
|
|
1830
|
+
? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
|
|
1831
|
+
: `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
|
|
1832
|
+
// One short line, in the user's own voice (we intentionally do not change
|
|
1833
|
+
// senderName). Each locale uses its own done-phrase so the English probe
|
|
1834
|
+
// stays fully English; detection accepts both phrases.
|
|
1835
|
+
const probe = en
|
|
1836
|
+
? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
|
|
1837
|
+
: `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`;
|
|
1838
|
+
let probeReply;
|
|
1839
|
+
try {
|
|
1840
|
+
probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
|
|
1841
|
+
sessionKey,
|
|
1842
|
+
unsyncedMessages: [],
|
|
1843
|
+
currentMessage: probe,
|
|
1844
|
+
currentSenderName: senderName,
|
|
1845
|
+
deliver: false,
|
|
1846
|
+
timeoutMs: 1_800_000,
|
|
1847
|
+
includeContext: false,
|
|
1848
|
+
includeBridgeAttachmentHint: false,
|
|
1849
|
+
onProgress: (event) => liveStatus?.progress(event),
|
|
1850
|
+
}));
|
|
1851
|
+
probedAtLeastOnce = true;
|
|
1852
|
+
}
|
|
1853
|
+
catch (err) {
|
|
1854
|
+
// Confirmation round failed (timeout/unhealthy). If the session is heavy
|
|
1855
|
+
// (≥ threshold) and we have not compacted yet this round, auto-compact and
|
|
1856
|
+
// keep retrying — a bloated session is the usual cause of these timeouts.
|
|
1857
|
+
// Otherwise stop looping.
|
|
1858
|
+
console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
|
|
1859
|
+
if (!compacted) {
|
|
1860
|
+
const pct = await this.getContextUsagePercent(sessionKey);
|
|
1861
|
+
if (pct >= autoRetryCompactPct()) {
|
|
1862
|
+
const en = this.isEn(chatId);
|
|
1863
|
+
await liveStatus?.progress(en
|
|
1864
|
+
? `🧹 Context is large (${pct}%) and the retry failed — auto-compacting…`
|
|
1865
|
+
: `🧹 上下文过大(${pct}%)且重试出错,正在自动压缩…`).catch(() => { });
|
|
1866
|
+
try {
|
|
1867
|
+
const r = await this.compactWithFallback(sessionKey);
|
|
1868
|
+
compacted = true; // mark as attempted regardless, to avoid re-compacting in a loop
|
|
1869
|
+
if (r.compacted) {
|
|
1870
|
+
console.log(`[${this.config.name}] auto-retry: compacted session (${r.method}${r.detail ? " " + r.detail : ""}) at ${pct}% for ${chatId.slice(-8)}; continuing`);
|
|
1871
|
+
forceProbe = !reply; // if we still have no reply (timeout path), keep probing
|
|
1872
|
+
continue; // retry the probe on the now-lighter session
|
|
1873
|
+
}
|
|
1874
|
+
// Compact was a no-op; do not spin.
|
|
1875
|
+
console.warn(`[${this.config.name}] auto-retry: compact was a no-op for ${chatId.slice(-8)} (${r.reason || "no reason"})`);
|
|
1876
|
+
}
|
|
1877
|
+
catch (compactErr) {
|
|
1878
|
+
console.warn(`[${this.config.name}] auto-retry compact failed for ${chatId.slice(-8)}:`, this.errorSummary(compactErr));
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
// No reply to fall back to (timeout path) and retries exhausted/failed:
|
|
1883
|
+
// re-throw so the caller surfaces the original failure.
|
|
1884
|
+
if (!reply)
|
|
1885
|
+
throw (err instanceof Error ? err : new Error(`Agent error: ${params.initialError || "retry failed"}`));
|
|
1886
|
+
return reply;
|
|
1887
|
+
}
|
|
1888
|
+
const probeText = this.extractBridgeAttachments(probeReply).text.trim();
|
|
1889
|
+
if (!probeText || probeText.toUpperCase() === "NO_REPLY") {
|
|
1890
|
+
if (!reply)
|
|
1891
|
+
throw new Error(`Agent error: ${params.initialError || "no reply after retry"}`);
|
|
1892
|
+
return reply; // session could not answer; deliver existing result
|
|
1893
|
+
}
|
|
1894
|
+
if (this.isDonePhrase(probeText)) {
|
|
1895
|
+
console.log(`[${this.config.name}] auto-retry: session confirmed done after ${attempt} check(s) for ${chatId.slice(-8)}`);
|
|
1896
|
+
// On the timeout path we have no original reply; the done confirmation
|
|
1897
|
+
// means the interrupted work actually finished — but we have nothing
|
|
1898
|
+
// user-visible to deliver, so treat it as NO_REPLY rather than fabricate.
|
|
1899
|
+
return reply || "NO_REPLY";
|
|
1900
|
+
}
|
|
1901
|
+
// Not done: the session kept working. Its output is the new latest reply.
|
|
1902
|
+
console.log(`[${this.config.name}] auto-retry: session continued (attempt ${attempt}) for ${chatId.slice(-8)}`);
|
|
1903
|
+
reply = probeReply;
|
|
1904
|
+
}
|
|
1905
|
+
console.warn(`[${this.config.name}] auto-retry budget (${maxRetry}) spent for ${chatId.slice(-8)}; delivering latest result`);
|
|
1906
|
+
void probedAtLeastOnce;
|
|
1907
|
+
if (!reply)
|
|
1908
|
+
throw new Error(`Agent error: ${params.initialError || "retry budget exhausted"}`);
|
|
1909
|
+
return reply;
|
|
1910
|
+
}
|
|
1606
1911
|
cancelDelayedFailure(chatId) {
|
|
1607
1912
|
const timer = this.delayedFailureTimers.get(chatId);
|
|
1608
1913
|
if (timer)
|
|
@@ -1940,25 +2245,24 @@ export class FeishuBot {
|
|
|
1940
2245
|
}
|
|
1941
2246
|
buildLiveStatusCard(view, chatId) {
|
|
1942
2247
|
const en = this.isEn(chatId);
|
|
1943
|
-
//
|
|
1944
|
-
// compact
|
|
1945
|
-
//
|
|
1946
|
-
if (view.state
|
|
1947
|
-
const statusEmoji = view.
|
|
2248
|
+
// Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
|
|
2249
|
+
// one compact grey line (status emoji + tool-call count + total elapsed) so
|
|
2250
|
+
// the finished card does not take up much screen space.
|
|
2251
|
+
if (view.state === "done") {
|
|
2252
|
+
const statusEmoji = view.noReply ? "💤" : "✅";
|
|
1948
2253
|
const summary = en
|
|
1949
2254
|
? `${statusEmoji} ${view.toolCalls} tool call${view.toolCalls === 1 ? "" : "s"} · ⏱ ${view.elapsed}`
|
|
1950
2255
|
: `${statusEmoji} 累计${view.toolCalls} 次工具调用 · ⏱ 耗时${view.elapsed}`;
|
|
1951
|
-
// Grey + small like the footer, so the finished card stays unobtrusive.
|
|
1952
|
-
const compact = `<font color='grey'>${summary}</font>`;
|
|
1953
2256
|
return {
|
|
1954
2257
|
schema: "2.0",
|
|
1955
2258
|
config: { update_multi: true, width_mode: "fill" },
|
|
1956
|
-
body: { elements: [{ tag: "markdown", content:
|
|
2259
|
+
body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
|
|
1957
2260
|
};
|
|
1958
2261
|
}
|
|
1959
2262
|
const elements = [];
|
|
1960
|
-
// Running: recent activity
|
|
1961
|
-
//
|
|
2263
|
+
// Running OR failed: show the recent activity window (each line prefixed with
|
|
2264
|
+
// the relative time mm:ss). On failure we deliberately KEEP the last lines so
|
|
2265
|
+
// the steps leading up to the error/kill/timeout are visible for debugging.
|
|
1962
2266
|
if (view.lines.length > 0) {
|
|
1963
2267
|
const iconFor = (kind) => kind === "tool_start" ? "▸"
|
|
1964
2268
|
: kind === "tool_end" ? "✓"
|
|
@@ -1984,6 +2288,7 @@ export class FeishuBot {
|
|
|
1984
2288
|
if (view.model)
|
|
1985
2289
|
footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
|
|
1986
2290
|
elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
|
|
2291
|
+
const template = view.state === "failed" ? "orange" : "blue";
|
|
1987
2292
|
return {
|
|
1988
2293
|
schema: "2.0",
|
|
1989
2294
|
// update_multi is REQUIRED for im.message.patch to update the card; Feishu
|
|
@@ -1991,7 +2296,7 @@ export class FeishuBot {
|
|
|
1991
2296
|
config: { update_multi: true, width_mode: "fill" },
|
|
1992
2297
|
header: {
|
|
1993
2298
|
title: { tag: "plain_text", content: view.title },
|
|
1994
|
-
template
|
|
2299
|
+
template,
|
|
1995
2300
|
},
|
|
1996
2301
|
body: { elements },
|
|
1997
2302
|
};
|
|
@@ -2035,6 +2340,73 @@ export class FeishuBot {
|
|
|
2035
2340
|
data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
|
|
2036
2341
|
});
|
|
2037
2342
|
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Render the /compact progress card. While running it shows a phase line plus a
|
|
2345
|
+
* ticking elapsed footer so the user can see compaction is in progress; when
|
|
2346
|
+
* terminal it collapses to a single compact line (success/skip/fail).
|
|
2347
|
+
*/
|
|
2348
|
+
buildCompactProgressCard(view, chatId) {
|
|
2349
|
+
const en = this.isEn(chatId);
|
|
2350
|
+
if (view.state !== "running") {
|
|
2351
|
+
let line;
|
|
2352
|
+
if (view.state === "done") {
|
|
2353
|
+
line = en
|
|
2354
|
+
? `✅ Session compacted${view.detail ? ` (${view.detail})` : ""} · ⏱ ${view.elapsed}`
|
|
2355
|
+
: `✅ Session 已压缩${view.detail ? `(${view.detail})` : ""} · ⏱ 耗时 ${view.elapsed}`;
|
|
2356
|
+
}
|
|
2357
|
+
else if (view.state === "noop") {
|
|
2358
|
+
line = en
|
|
2359
|
+
? `ℹ️ Session not compacted${view.detail ? ` (${view.detail})` : ""}`
|
|
2360
|
+
: `ℹ️ Session 未压缩${view.detail ? `(${view.detail})` : ""}`;
|
|
2361
|
+
}
|
|
2362
|
+
else {
|
|
2363
|
+
line = en
|
|
2364
|
+
? `❌ Compact failed${view.detail ? `: ${view.detail}` : ""}`
|
|
2365
|
+
: `❌ 压缩失败${view.detail ? `:${view.detail}` : ""}`;
|
|
2366
|
+
}
|
|
2367
|
+
return {
|
|
2368
|
+
schema: "2.0",
|
|
2369
|
+
config: { update_multi: true, width_mode: "fill" },
|
|
2370
|
+
body: { elements: [{ tag: "markdown", content: `<font color='grey'>${this.escapeCardText(line)}</font>` }] },
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
// Running: phase line + ticking elapsed footer.
|
|
2374
|
+
const phaseLine = view.phase === "tool-trim"
|
|
2375
|
+
? (en ? "🧹 Native compaction is slow — switching to fast trim…" : "🧹 原生压缩较慢,已切换快速压缩…")
|
|
2376
|
+
: (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
|
|
2377
|
+
const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
|
|
2378
|
+
return {
|
|
2379
|
+
schema: "2.0",
|
|
2380
|
+
config: { update_multi: true, width_mode: "fill" },
|
|
2381
|
+
header: {
|
|
2382
|
+
title: { tag: "plain_text", content: en ? "Compacting" : "正在压缩" },
|
|
2383
|
+
template: "blue",
|
|
2384
|
+
},
|
|
2385
|
+
body: {
|
|
2386
|
+
elements: [
|
|
2387
|
+
{ tag: "markdown", content: this.escapeCardText(phaseLine) },
|
|
2388
|
+
{ tag: "hr" },
|
|
2389
|
+
{ tag: "markdown", content: `<font color='grey'>${footer}</font>` },
|
|
2390
|
+
],
|
|
2391
|
+
},
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
async replyCompactCard(messageId, view, chatId) {
|
|
2395
|
+
const res = await this.client.im.message.reply({
|
|
2396
|
+
path: { message_id: messageId },
|
|
2397
|
+
data: {
|
|
2398
|
+
content: JSON.stringify(this.buildCompactProgressCard(view, chatId)),
|
|
2399
|
+
msg_type: "interactive",
|
|
2400
|
+
},
|
|
2401
|
+
});
|
|
2402
|
+
return res?.data?.message_id || res?.message_id;
|
|
2403
|
+
}
|
|
2404
|
+
async patchCompactCard(messageId, view, chatId) {
|
|
2405
|
+
await this.client.im.message.patch({
|
|
2406
|
+
path: { message_id: messageId },
|
|
2407
|
+
data: { content: JSON.stringify(this.buildCompactProgressCard(view, chatId)) },
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2038
2410
|
async sendTextMessage(chatId, text) {
|
|
2039
2411
|
try {
|
|
2040
2412
|
const res = await this.client.im.message.create({
|
|
@@ -2600,14 +2972,115 @@ export class FeishuBot {
|
|
|
2600
2972
|
/**
|
|
2601
2973
|
* Handle /compact command: compress session context.
|
|
2602
2974
|
*/
|
|
2975
|
+
/**
|
|
2976
|
+
* Compact a session with a fallback chain:
|
|
2977
|
+
* 1. OpenClaw's native (LLM-summary) compaction — best semantics.
|
|
2978
|
+
* 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
|
|
2979
|
+
* the transcript file to drop tool calls/results while keeping the
|
|
2980
|
+
* conversation. This needs no model, so it works no matter how large the
|
|
2981
|
+
* session is (the native path itself fails to fit an oversized session
|
|
2982
|
+
* into the model). A backup is always written before any file change.
|
|
2983
|
+
* Returns a structured result describing what happened.
|
|
2984
|
+
*/
|
|
2985
|
+
async compactWithFallback(sessionKey, onPhase) {
|
|
2986
|
+
// 1. Native compaction first.
|
|
2987
|
+
let nativeReason;
|
|
2988
|
+
try {
|
|
2989
|
+
const res = await this.openclawClient.compactSession(sessionKey);
|
|
2990
|
+
if (res?.compacted === true)
|
|
2991
|
+
return { compacted: true, method: "native" };
|
|
2992
|
+
nativeReason = typeof res?.reason === "string" ? res.reason : undefined;
|
|
2993
|
+
}
|
|
2994
|
+
catch (err) {
|
|
2995
|
+
nativeReason = err.message;
|
|
2996
|
+
}
|
|
2997
|
+
// 2. Fall back to tool-trim on the transcript file. Tell the caller so a
|
|
2998
|
+
// progress card can flip from "compacting" to "fast-trim" (this is the
|
|
2999
|
+
// slow→still-working transition the user most needs to see).
|
|
3000
|
+
onPhase?.("tool-trim");
|
|
3001
|
+
let sessionId;
|
|
3002
|
+
try {
|
|
3003
|
+
const info = await this.openclawClient.getSessionInfo(sessionKey);
|
|
3004
|
+
sessionId = info?.session?.sessionId;
|
|
3005
|
+
}
|
|
3006
|
+
catch {
|
|
3007
|
+
// ignore; handled below
|
|
3008
|
+
}
|
|
3009
|
+
if (!sessionId) {
|
|
3010
|
+
return { compacted: false, method: "none", reason: nativeReason || "could not resolve session file" };
|
|
3011
|
+
}
|
|
3012
|
+
const filePath = resolveSessionFilePath(sessionKey, sessionId);
|
|
3013
|
+
if (!filePath) {
|
|
3014
|
+
return { compacted: false, method: "none", reason: nativeReason || "transcript file not found" };
|
|
3015
|
+
}
|
|
3016
|
+
const trim = toolTrimCompactFile(filePath, toolTrimKeepRecent());
|
|
3017
|
+
if (!trim.ok) {
|
|
3018
|
+
return { compacted: false, method: "none", reason: trim.reason || "tool-trim failed" };
|
|
3019
|
+
}
|
|
3020
|
+
const before = trim.bytesBefore || 0;
|
|
3021
|
+
const after = trim.bytesAfter || 0;
|
|
3022
|
+
if (before > 0 && after >= before) {
|
|
3023
|
+
// Nothing meaningful trimmed.
|
|
3024
|
+
return { compacted: false, method: "none", reason: trim.reason || "no tool content to trim" };
|
|
3025
|
+
}
|
|
3026
|
+
const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
|
|
3027
|
+
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
|
|
3028
|
+
return {
|
|
3029
|
+
compacted: true,
|
|
3030
|
+
method: "tool-trim",
|
|
3031
|
+
detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
2603
3034
|
async handleCompactCommand(chatId, messageId) {
|
|
2604
3035
|
const sessionKey = this.getSessionKey(chatId);
|
|
3036
|
+
const en = this.isEn(chatId);
|
|
3037
|
+
// Large sessions can take tens of seconds to compact (native compaction may
|
|
3038
|
+
// even time out before the tool-trim fallback kicks in). Show a ticking
|
|
3039
|
+
// "compacting…" card so the user can SEE it is working instead of staring at
|
|
3040
|
+
// nothing and assuming nothing is happening. The card is created lazily, so
|
|
3041
|
+
// a fast compaction that finishes quickly never flashes a card at all.
|
|
3042
|
+
const progress = new CompactProgressController({
|
|
3043
|
+
create: (view) => this.sendOrdered(chatId, () => this.replyCompactCard(messageId, view, chatId)),
|
|
3044
|
+
edit: (id, view) => this.sendOrdered(chatId, () => this.patchCompactCard(id, view, chatId)),
|
|
3045
|
+
warn: (msg, err) => console.warn(`[${this.config.name}] ${msg}`, err instanceof Error ? err.message : err),
|
|
3046
|
+
}, { locale: en ? "en" : "zh" });
|
|
3047
|
+
progress.start();
|
|
2605
3048
|
try {
|
|
2606
|
-
await this.
|
|
2607
|
-
|
|
3049
|
+
const r = await this.compactWithFallback(sessionKey, (phase) => {
|
|
3050
|
+
if (phase === "tool-trim")
|
|
3051
|
+
void progress.toToolTrim().catch(() => { });
|
|
3052
|
+
});
|
|
3053
|
+
if (r.compacted) {
|
|
3054
|
+
// If a card was shown, patch it in place; otherwise (fast finish, no card)
|
|
3055
|
+
// fall back to a normal reply so the user still gets confirmation.
|
|
3056
|
+
const detail = r.method === "tool-trim" && r.detail
|
|
3057
|
+
? (en ? `tool-trim ${r.detail}` : `删工具调用 ${r.detail}`)
|
|
3058
|
+
: (en ? "native" : "原生压缩");
|
|
3059
|
+
await progress.done(detail);
|
|
3060
|
+
if (!progress.id) {
|
|
3061
|
+
await this.replyMessage(messageId, r.method === "tool-trim"
|
|
3062
|
+
? (en ? `✅ Session compacted (tool-trim${r.detail ? " " + r.detail : ""})` : `✅ Session 已压缩(删工具调用${r.detail ? " " + r.detail : ""})`)
|
|
3063
|
+
: (en ? `✅ Session compacted` : `✅ Session 已压缩`));
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
else {
|
|
3067
|
+
const reason = r.reason || (en ? "nothing to compact" : "无需压缩");
|
|
3068
|
+
await progress.noop(reason);
|
|
3069
|
+
if (!progress.id) {
|
|
3070
|
+
await this.replyMessage(messageId, en
|
|
3071
|
+
? `ℹ️ Session not compacted (${reason})`
|
|
3072
|
+
: `ℹ️ Session 未压缩(${reason})`);
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
2608
3075
|
}
|
|
2609
3076
|
catch (err) {
|
|
2610
|
-
|
|
3077
|
+
const reason = err.message;
|
|
3078
|
+
await progress.fail(reason);
|
|
3079
|
+
if (!progress.id) {
|
|
3080
|
+
await this.replyMessage(messageId, en
|
|
3081
|
+
? `❌ Compact failed: ${reason}`
|
|
3082
|
+
: `❌ 压缩失败: ${reason}`);
|
|
3083
|
+
}
|
|
2611
3084
|
}
|
|
2612
3085
|
}
|
|
2613
3086
|
/**
|
package/dist/live-status.js
CHANGED
|
@@ -256,11 +256,19 @@ 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,
|