openclaw-lark-multi-agent 1.2.4 → 1.2.6

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.
@@ -123,36 +123,19 @@ export declare class FeishuBot {
123
123
  private isLegacyBridgeControlMessage;
124
124
  private isRuntimeFailureText;
125
125
  /**
126
- * Is this main-run error worth auto-retrying (by resuming the session via the
127
- * probe loop)? Mainly transient failures — request/response timeouts and
128
- * aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
129
- * (auth/permission/quota/billing/model-not-supported), which would just fail
130
- * again. The error text comes from collectReply's `Agent error: <msg>`.
131
- */
132
- private isRetryableAgentError;
133
- /**
134
- * Heuristic: does this reply look truncated / mid-task? Bias toward false
135
- * positives (“宁可错杀不放过”) — a false positive only costs one invisible
136
- * confirmation round, and the session self-check is the real arbiter. We gate
137
- * only on cheap text shape here, never on stopReason (the upstream rarely
138
- * provides one for normal-looking-but-truncated finals).
139
- */
140
- /**
141
- * Heuristic: does this reply look genuinely truncated / cut off mid-task?
126
+ * Heuristic: does this reply look truncated / cut off mid-task? Used as the
127
+ * SOLE auto-retry trigger — if true, we ask the session a one-line “done?” probe.
142
128
  *
143
- * IMPORTANT (per Stephen): only retry when there is CLEAR evidence the agent
144
- * did not finish — do NOT retry just because a reply lacks a trailing period.
145
- * Many complete replies legitimately end without sentence punctuation (a terse
146
- * “好的”, a bullet list, a path, a number, a closing word). Treating those as
147
- * truncated caused needless extra probe rounds, which hurts UX. So we gate on
148
- * strong structural signals only:
129
+ * Signals (any one is enough):
149
130
  * 1. Unbalanced code fence — almost certainly cut off mid code block.
150
- * 2. Ends with a dangling connector / lead-in (“然后”, “接下来”, “let me”…)
151
- * or a trailing clause-joining punctuation (comma/colon/semicolon),
152
- * which means a sentence was left hanging.
153
- * 3. Ends mid-word inside a Latin word right after a connector-less clause
154
- * (very rare; only when the last “word” is unusually long), kept minimal.
155
- * A plain “no sentence-ending punctuation” is deliberately NOT sufficient.
131
+ * 2. Trailing clause-joining punctuation (comma/colon/semicolon/、) — a
132
+ * sentence was left hanging.
133
+ * 3. A dangling connector / lead-in (“然后”, “接下来”, “let me”, “and”…).
134
+ * 4. Ends without ANY sentence-ending punctuation / closer (no terminal
135
+ * punctuation, closing bracket/quote, code fence, or wrap-up emoji).
136
+ * Per Stephen: a reply that ends with no punctuation at all is also
137
+ * treated as likely unfinished. A false positive only costs one cheap,
138
+ * invisible confirmation probe; the session itself is the real arbiter.
156
139
  */
157
140
  private looksTruncated;
158
141
  /** Current context usage percent for a session (totalTokens / contextTokens),
@@ -182,6 +165,9 @@ export declare class FeishuBot {
182
165
  * session kept working and its new output becomes the latest reply, which we
183
166
  * re-check. Loops until the session confirms completion, a reply no longer
184
167
  * looks truncated, the budget is spent, or the session cannot answer.
168
+ *
169
+ * This is the ONLY auto-retry trigger: a reply that LOOKS UNFINISHED. We do not
170
+ * retry on transient run errors, and we do not auto-compact-and-retry.
185
171
  */
186
172
  private autoRetryUntilComplete;
187
173
  private cancelDelayedFailure;
@@ -29,10 +29,6 @@ function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AU
29
29
  // for the locale-appropriate phrase; detection accepts BOTH so either works.
30
30
  const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
31
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
32
  // How many of the most-recent tool calls (and their results) tool-trim keeps
37
33
  // intact — recent tool calls may still be relevant to the agent's next step.
38
34
  function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
@@ -1248,29 +1244,9 @@ export class FeishuBot {
1248
1244
  }));
1249
1245
  }
1250
1246
  catch (mainErr) {
1251
- // The main run failed before returning a reply. If it is a retryable
1252
- // error (e.g. a request timeout) and not a session-health failure,
1253
- // reuse the SAME auto-retry mechanism to let the session resume from
1254
- // where it was interrupted, instead of giving up immediately.
1255
- if (mainErr instanceof UnhealthySessionError)
1256
- throw mainErr;
1257
- const errText = mainErr?.message || String(mainErr);
1258
- if (autoRetryEnabled() && !isNativeCommandTrigger && this.isRetryableAgentError(errText)) {
1259
- console.warn(`[${this.config.name}] main run failed (${errText.slice(0, 80)}) for ${chatId.slice(-8)}; entering auto-retry`);
1260
- reply = await this.autoRetryUntilComplete({
1261
- chatId,
1262
- sessionKey,
1263
- senderName: lastHuman.senderName,
1264
- triggerId,
1265
- lastHumanMessageId: lastHuman.messageId || "",
1266
- isNativeCommandTrigger,
1267
- initialError: errText,
1268
- liveStatus,
1269
- });
1270
- }
1271
- else {
1272
- throw mainErr;
1273
- }
1247
+ // The main run failed before returning a reply. Surface the error; we
1248
+ // only auto-retry replies that look truncated, not transient run errors.
1249
+ throw mainErr;
1274
1250
  }
1275
1251
  finally {
1276
1252
  // OpenClaw may emit the final assistant session.message just after
@@ -1671,43 +1647,19 @@ export class FeishuBot {
1671
1647
  return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
1672
1648
  }
1673
1649
  /**
1674
- * Is this main-run error worth auto-retrying (by resuming the session via the
1675
- * probe loop)? Mainly transient failures — request/response timeouts and
1676
- * aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
1677
- * (auth/permission/quota/billing/model-not-supported), which would just fail
1678
- * again. The error text comes from collectReply's `Agent error: <msg>`.
1679
- */
1680
- isRetryableAgentError(errText) {
1681
- const t = (errText || "").toLowerCase();
1682
- // Terminal errors: never auto-retry.
1683
- if (/(unauthorized|permission|forbidden|invalid api key|quota|billing|insufficient|model_not_supported|not supported|invalid_request)/.test(t))
1684
- return false;
1685
- // Retryable: timeouts and transient interruptions.
1686
- return /(timed out|timeout|aborted|operation was aborted|no response|request failed|temporarily|connection|econnreset|socket hang|stream)/.test(t);
1687
- }
1688
- /**
1689
- * Heuristic: does this reply look truncated / mid-task? Bias toward false
1690
- * positives (“宁可错杀不放过”) — a false positive only costs one invisible
1691
- * confirmation round, and the session self-check is the real arbiter. We gate
1692
- * only on cheap text shape here, never on stopReason (the upstream rarely
1693
- * provides one for normal-looking-but-truncated finals).
1694
- */
1695
- /**
1696
- * Heuristic: does this reply look genuinely truncated / cut off mid-task?
1650
+ * Heuristic: does this reply look truncated / cut off mid-task? Used as the
1651
+ * SOLE auto-retry trigger — if true, we ask the session a one-line “done?” probe.
1697
1652
  *
1698
- * IMPORTANT (per Stephen): only retry when there is CLEAR evidence the agent
1699
- * did not finish — do NOT retry just because a reply lacks a trailing period.
1700
- * Many complete replies legitimately end without sentence punctuation (a terse
1701
- * “好的”, a bullet list, a path, a number, a closing word). Treating those as
1702
- * truncated caused needless extra probe rounds, which hurts UX. So we gate on
1703
- * strong structural signals only:
1653
+ * Signals (any one is enough):
1704
1654
  * 1. Unbalanced code fence — almost certainly cut off mid code block.
1705
- * 2. Ends with a dangling connector / lead-in (“然后”, “接下来”, “let me”…)
1706
- * or a trailing clause-joining punctuation (comma/colon/semicolon),
1707
- * which means a sentence was left hanging.
1708
- * 3. Ends mid-word inside a Latin word right after a connector-less clause
1709
- * (very rare; only when the last “word” is unusually long), kept minimal.
1710
- * A plain “no sentence-ending punctuation” is deliberately NOT sufficient.
1655
+ * 2. Trailing clause-joining punctuation (comma/colon/semicolon/、) — a
1656
+ * sentence was left hanging.
1657
+ * 3. A dangling connector / lead-in (“然后”, “接下来”, “let me”, “and”…).
1658
+ * 4. Ends without ANY sentence-ending punctuation / closer (no terminal
1659
+ * punctuation, closing bracket/quote, code fence, or wrap-up emoji).
1660
+ * Per Stephen: a reply that ends with no punctuation at all is also
1661
+ * treated as likely unfinished. A false positive only costs one cheap,
1662
+ * invisible confirmation probe; the session itself is the real arbiter.
1711
1663
  */
1712
1664
  looksTruncated(text) {
1713
1665
  const t = (text || "").trim();
@@ -1723,6 +1675,16 @@ export class FeishuBot {
1723
1675
  // (2b) Ends on an explicit continuation cue / lead-in.
1724
1676
  if (/(接下来|然后|首先|现在我|让我|下一步|我先|我来|我接着|接着|稍等|let me|now i|next|first|\b(?:and|then|so|to|the|a)\b)\s*$/iu.test(t))
1725
1677
  return true;
1678
+ // (2c) Ends without ANY sentence-ending punctuation / closer. A finished
1679
+ // reply normally ends with terminal punctuation, a closing bracket/
1680
+ // quote, a code fence, or a wrap-up emoji; if none of those are present
1681
+ // the reply was most likely cut off mid-sentence. (Stephen: “结尾没有
1682
+ // 任何标点符号也算疑似未结束”.)
1683
+ const endOk = /[。!?.!?…)\]」』】)"'`”’~~—✨✅👍🙏🎉🙌]\s*$/u.test(t)
1684
+ || /```\s*$/.test(t)
1685
+ || /[}\]]\s*$/.test(t);
1686
+ if (!endOk)
1687
+ return true;
1726
1688
  return false;
1727
1689
  }
1728
1690
  /** Current context usage percent for a session (totalTokens / contextTokens),
@@ -1808,21 +1770,16 @@ export class FeishuBot {
1808
1770
  * session kept working and its new output becomes the latest reply, which we
1809
1771
  * re-check. Loops until the session confirms completion, a reply no longer
1810
1772
  * looks truncated, the budget is spent, or the session cannot answer.
1773
+ *
1774
+ * This is the ONLY auto-retry trigger: a reply that LOOKS UNFINISHED. We do not
1775
+ * retry on transient run errors, and we do not auto-compact-and-retry.
1811
1776
  */
1812
1777
  async autoRetryUntilComplete(params) {
1813
1778
  const { chatId, sessionKey, senderName, triggerId, lastHumanMessageId, isNativeCommandTrigger, liveStatus } = params;
1814
1779
  let reply = params.initialReply ?? "";
1815
- // Whether we still owe at least one probe regardless of how `reply` looks.
1816
- // The timeout path starts in this state (no reply to inspect yet).
1817
- let forceProbe = params.initialError !== undefined && !params.initialReply;
1818
- if (!autoRetryEnabled() || isNativeCommandTrigger) {
1819
- if (forceProbe)
1820
- throw new Error(`Agent error: ${params.initialError}`);
1780
+ if (!autoRetryEnabled() || isNativeCommandTrigger)
1821
1781
  return reply;
1822
- }
1823
1782
  const maxRetry = autoRetryMax();
1824
- let compacted = false; // auto-compact at most once per auto-retry round
1825
- let probedAtLeastOnce = false;
1826
1783
  // Snapshot the stop generation at entry. If the user runs /stop while this
1827
1784
  // loop is in flight, handleStopCommand bumps the epoch; we detect the change
1828
1785
  // and stop retrying immediately instead of issuing more probes.
@@ -1832,26 +1789,21 @@ export class FeishuBot {
1832
1789
  // /stop while we were looping: abandon retries and deliver what we have.
1833
1790
  if (wasStopped()) {
1834
1791
  console.log(`[${this.config.name}] auto-retry: aborted by /stop for ${chatId.slice(-8)}`);
1835
- if (!reply)
1836
- throw new Error(`Agent error: ${params.initialError || "stopped by user"}`);
1837
1792
  return reply;
1838
1793
  }
1839
- if (!forceProbe) {
1840
- const parsed = this.extractBridgeAttachments(reply);
1841
- const trimmed = parsed.text.trim();
1842
- // Hard rule: a reply that produced attachments (image/file/doc) is a
1843
- // finished product — never auto-retry it (and never risk dropping the
1844
- // bridge attachment marker by replacing `reply` with a probe answer).
1845
- if (parsed.attachments.length > 0)
1846
- return reply;
1847
- // Only engage for plausibly-successful-but-truncated text. Empty/NO_REPLY
1848
- // and runtime failures have their own handling upstream.
1849
- if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
1850
- return reply;
1851
- if (!this.looksTruncated(trimmed))
1852
- return reply;
1853
- }
1854
- forceProbe = false; // consumed; subsequent rounds inspect the probe reply
1794
+ const parsed = this.extractBridgeAttachments(reply);
1795
+ const trimmed = parsed.text.trim();
1796
+ // Hard rule: a reply that produced attachments (image/file/doc) is a
1797
+ // finished product — never auto-retry it (and never risk dropping the
1798
+ // bridge attachment marker by replacing `reply` with a probe answer).
1799
+ if (parsed.attachments.length > 0)
1800
+ return reply;
1801
+ // Only engage for plausibly-successful-but-truncated text. Empty/NO_REPLY
1802
+ // and runtime failures have their own handling upstream.
1803
+ if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
1804
+ return reply;
1805
+ if (!this.looksTruncated(trimmed))
1806
+ return reply;
1855
1807
  const en = this.isEn(chatId);
1856
1808
  await liveStatus?.progress(en
1857
1809
  ? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
@@ -1875,41 +1827,11 @@ export class FeishuBot {
1875
1827
  includeBridgeAttachmentHint: false,
1876
1828
  onProgress: (event) => liveStatus?.progress(event),
1877
1829
  }));
1878
- probedAtLeastOnce = true;
1879
1830
  }
1880
1831
  catch (err) {
1881
- // Confirmation round failed (timeout/unhealthy). If the session is heavy
1882
- // (≥ threshold) and we have not compacted yet this round, auto-compact and
1883
- // keep retrying — a bloated session is the usual cause of these timeouts.
1884
- // Otherwise stop looping.
1832
+ // The confirmation round itself failed (timeout/unhealthy). Do not spin:
1833
+ // deliver the existing (truncated-looking) reply as-is.
1885
1834
  console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
1886
- if (!compacted) {
1887
- const pct = await this.getContextUsagePercent(sessionKey);
1888
- if (pct >= autoRetryCompactPct()) {
1889
- const en = this.isEn(chatId);
1890
- await liveStatus?.progress(en
1891
- ? `🧹 Context is large (${pct}%) and the retry failed — auto-compacting…`
1892
- : `🧹 上下文过大(${pct}%)且重试出错,正在自动压缩…`).catch(() => { });
1893
- try {
1894
- const r = await this.compactWithFallback(sessionKey);
1895
- compacted = true; // mark as attempted regardless, to avoid re-compacting in a loop
1896
- if (r.compacted) {
1897
- console.log(`[${this.config.name}] auto-retry: compacted session (${r.method}${r.detail ? " " + r.detail : ""}) at ${pct}% for ${chatId.slice(-8)}; continuing`);
1898
- forceProbe = !reply; // if we still have no reply (timeout path), keep probing
1899
- continue; // retry the probe on the now-lighter session
1900
- }
1901
- // Compact was a no-op; do not spin.
1902
- console.warn(`[${this.config.name}] auto-retry: compact was a no-op for ${chatId.slice(-8)} (${r.reason || "no reason"})`);
1903
- }
1904
- catch (compactErr) {
1905
- console.warn(`[${this.config.name}] auto-retry compact failed for ${chatId.slice(-8)}:`, this.errorSummary(compactErr));
1906
- }
1907
- }
1908
- }
1909
- // No reply to fall back to (timeout path) and retries exhausted/failed:
1910
- // re-throw so the caller surfaces the original failure.
1911
- if (!reply)
1912
- throw (err instanceof Error ? err : new Error(`Agent error: ${params.initialError || "retry failed"}`));
1913
1835
  return reply;
1914
1836
  }
1915
1837
  const probeText = this.extractBridgeAttachments(probeReply).text.trim();
@@ -1917,31 +1839,20 @@ export class FeishuBot {
1917
1839
  // again. Deliver the latest meaningful output we have.
1918
1840
  if (wasStopped()) {
1919
1841
  console.log(`[${this.config.name}] auto-retry: aborted by /stop mid-probe for ${chatId.slice(-8)}`);
1920
- const latest = probeText && probeText.toUpperCase() !== "NO_REPLY" ? probeReply : reply;
1921
- if (!latest)
1922
- throw new Error(`Agent error: ${params.initialError || "stopped by user"}`);
1923
- return latest;
1842
+ return probeText && probeText.toUpperCase() !== "NO_REPLY" ? probeReply : reply;
1924
1843
  }
1925
1844
  if (!probeText || probeText.toUpperCase() === "NO_REPLY") {
1926
- if (!reply)
1927
- throw new Error(`Agent error: ${params.initialError || "no reply after retry"}`);
1928
1845
  return reply; // session could not answer; deliver existing result
1929
1846
  }
1930
1847
  if (this.isDonePhrase(probeText)) {
1931
1848
  console.log(`[${this.config.name}] auto-retry: session confirmed done after ${attempt} check(s) for ${chatId.slice(-8)}`);
1932
- // On the timeout path we have no original reply; the done confirmation
1933
- // means the interrupted work actually finished — but we have nothing
1934
- // user-visible to deliver, so treat it as NO_REPLY rather than fabricate.
1935
- return reply || "NO_REPLY";
1849
+ return reply;
1936
1850
  }
1937
1851
  // Not done: the session kept working. Its output is the new latest reply.
1938
1852
  console.log(`[${this.config.name}] auto-retry: session continued (attempt ${attempt}) for ${chatId.slice(-8)}`);
1939
1853
  reply = probeReply;
1940
1854
  }
1941
1855
  console.warn(`[${this.config.name}] auto-retry budget (${maxRetry}) spent for ${chatId.slice(-8)}; delivering latest result`);
1942
- void probedAtLeastOnce;
1943
- if (!reply)
1944
- throw new Error(`Agent error: ${params.initialError || "retry budget exhausted"}`);
1945
1856
  return reply;
1946
1857
  }
1947
1858
  cancelDelayedFailure(chatId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {