openclaw-lark-multi-agent 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,28 +123,19 @@ export declare class FeishuBot {
123
123
  private isLegacyBridgeControlMessage;
124
124
  private isRuntimeFailureText;
125
125
  /**
126
- * Heuristic: does this reply look truncated / mid-task? Bias toward false
127
- * positives (“宁可错杀不放过”) — a false positive only costs one invisible
128
- * confirmation round, and the session self-check is the real arbiter. We gate
129
- * only on cheap text shape here, never on stopReason (the upstream rarely
130
- * provides one for normal-looking-but-truncated finals).
131
- */
132
- /**
133
- * 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.
134
128
  *
135
- * IMPORTANT (per Stephen): only retry when there is CLEAR evidence the agent
136
- * did not finish — do NOT retry just because a reply lacks a trailing period.
137
- * Many complete replies legitimately end without sentence punctuation (a terse
138
- * “好的”, a bullet list, a path, a number, a closing word). Treating those as
139
- * truncated caused needless extra probe rounds, which hurts UX. So we gate on
140
- * strong structural signals only:
129
+ * Signals (any one is enough):
141
130
  * 1. Unbalanced code fence — almost certainly cut off mid code block.
142
- * 2. Ends with a dangling connector / lead-in (“然后”, “接下来”, “let me”…)
143
- * or a trailing clause-joining punctuation (comma/colon/semicolon),
144
- * which means a sentence was left hanging.
145
- * 3. Ends mid-word inside a Latin word right after a connector-less clause
146
- * (very rare; only when the last “word” is unusually long), kept minimal.
147
- * 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.
148
139
  */
149
140
  private looksTruncated;
150
141
  /** Current context usage percent for a session (totalTokens / contextTokens),
@@ -1647,28 +1647,19 @@ export class FeishuBot {
1647
1647
  return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
1648
1648
  }
1649
1649
  /**
1650
- * Heuristic: does this reply look truncated / mid-task? Bias toward false
1651
- * positives (“宁可错杀不放过”) — a false positive only costs one invisible
1652
- * confirmation round, and the session self-check is the real arbiter. We gate
1653
- * only on cheap text shape here, never on stopReason (the upstream rarely
1654
- * provides one for normal-looking-but-truncated finals).
1655
- */
1656
- /**
1657
- * 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.
1658
1652
  *
1659
- * IMPORTANT (per Stephen): only retry when there is CLEAR evidence the agent
1660
- * did not finish — do NOT retry just because a reply lacks a trailing period.
1661
- * Many complete replies legitimately end without sentence punctuation (a terse
1662
- * “好的”, a bullet list, a path, a number, a closing word). Treating those as
1663
- * truncated caused needless extra probe rounds, which hurts UX. So we gate on
1664
- * strong structural signals only:
1653
+ * Signals (any one is enough):
1665
1654
  * 1. Unbalanced code fence — almost certainly cut off mid code block.
1666
- * 2. Ends with a dangling connector / lead-in (“然后”, “接下来”, “let me”…)
1667
- * or a trailing clause-joining punctuation (comma/colon/semicolon),
1668
- * which means a sentence was left hanging.
1669
- * 3. Ends mid-word inside a Latin word right after a connector-less clause
1670
- * (very rare; only when the last “word” is unusually long), kept minimal.
1671
- * 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.
1672
1663
  */
1673
1664
  looksTruncated(text) {
1674
1665
  const t = (text || "").trim();
@@ -1684,6 +1675,16 @@ export class FeishuBot {
1684
1675
  // (2b) Ends on an explicit continuation cue / lead-in.
1685
1676
  if (/(接下来|然后|首先|现在我|让我|下一步|我先|我来|我接着|接着|稍等|let me|now i|next|first|\b(?:and|then|so|to|the|a)\b)\s*$/iu.test(t))
1686
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;
1687
1688
  return false;
1688
1689
  }
1689
1690
  /** Current context usage percent for a session (totalTokens / contextTokens),
@@ -55,6 +55,12 @@ export declare class LiveStatusController {
55
55
  private createPromise?;
56
56
  private finalized;
57
57
  private disabled;
58
+ /** Consecutive safeEdit failures. A single transient Feishu error (e.g. card
59
+ * patch code=2200 "Internal Error") must NOT permanently disable the card:
60
+ * we skip that one tick and keep the ticker running, so the next second's
61
+ * patch recovers it. Only after this many CONSECUTIVE failures do we give up.
62
+ * A success resets the counter. */
63
+ private consecutiveEditFailures;
58
64
  private state;
59
65
  /** Total tool calls (tool_start events) seen during the run, for the summary. */
60
66
  private toolCallCount;
@@ -90,6 +96,13 @@ export declare class LiveStatusController {
90
96
  /** Refresh the elapsed-time footer periodically even with no new activity. */
91
97
  private startTicker;
92
98
  private safeEdit;
99
+ /**
100
+ * Terminal edits (complete/fail/noReply) are one-shot — there is no next tick to
101
+ * recover them — so a transient Feishu error (code=2200 etc.) would leave the
102
+ * card stuck on "running". Retry the final patch a few times with small backoff
103
+ * so the card reliably reaches its terminal state.
104
+ */
105
+ private safeEditFinal;
93
106
  /** Dedupe key includes elapsed so the footer timer ticks even without new
94
107
  * activity, while identical content within the same second is still skipped. */
95
108
  private signature;
@@ -6,6 +6,12 @@ const DEFAULT_HISTORY = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS
6
6
  // so the "已用时间" ticks like a live timer. Interactive-card patch has no 20-edit
7
7
  // cap (unlike text edit), so a 1s cadence is fine.
8
8
  const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS || 1000);
9
+ // How many CONSECUTIVE card-edit failures to tolerate before giving up on the
10
+ // card. Feishu's im.message.patch can intermittently return code=2200
11
+ // "Internal Error" (a transient server-side error); a single failure must not
12
+ // freeze the live-status card for the rest of a long run. We skip the failed
13
+ // tick, keep the ticker alive, and only disable after this many in a row.
14
+ const MAX_CONSECUTIVE_EDIT_FAILURES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_EDIT_FAILURES || 5);
9
15
  export class LiveStatusController {
10
16
  callbacks;
11
17
  opts;
@@ -19,6 +25,12 @@ export class LiveStatusController {
19
25
  createPromise;
20
26
  finalized = false;
21
27
  disabled = false;
28
+ /** Consecutive safeEdit failures. A single transient Feishu error (e.g. card
29
+ * patch code=2200 "Internal Error") must NOT permanently disable the card:
30
+ * we skip that one tick and keep the ticker running, so the next second's
31
+ * patch recovers it. Only after this many CONSECUTIVE failures do we give up.
32
+ * A success resets the counter. */
33
+ consecutiveEditFailures = 0;
22
34
  state = "running";
23
35
  /** Total tool calls (tool_start events) seen during the run, for the summary. */
24
36
  toolCallCount = 0;
@@ -85,7 +97,7 @@ export class LiveStatusController {
85
97
  await this.createPromise.catch(() => { });
86
98
  if (!this.messageId || this.disabled)
87
99
  return;
88
- await this.safeEdit(this.buildView(), true);
100
+ await this.safeEditFinal(this.buildView());
89
101
  }
90
102
  async fail() {
91
103
  this.state = "failed";
@@ -95,7 +107,7 @@ export class LiveStatusController {
95
107
  await this.createPromise.catch(() => { });
96
108
  if (!this.messageId || this.disabled)
97
109
  return;
98
- await this.safeEdit(this.buildView(), true);
110
+ await this.safeEditFinal(this.buildView());
99
111
  }
100
112
  /**
101
113
  * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
@@ -111,7 +123,7 @@ export class LiveStatusController {
111
123
  await this.createPromise.catch(() => { });
112
124
  if (!this.messageId || this.disabled)
113
125
  return;
114
- await this.safeEdit(this.buildView(), true);
126
+ await this.safeEditFinal(this.buildView());
115
127
  }
116
128
  dispose() {
117
129
  this.finalized = true;
@@ -216,15 +228,50 @@ export class LiveStatusController {
216
228
  try {
217
229
  await this.callbacks.edit(this.messageId, view);
218
230
  this.lastSentSignature = sig;
231
+ this.consecutiveEditFailures = 0; // recovered
219
232
  return true;
220
233
  }
221
234
  catch (err) {
222
- this.disabled = true;
223
- this.stopTimers();
224
- this.callbacks.warn?.("live status edit failed", err);
235
+ // A single transient Feishu error (e.g. card patch code=2200 "Internal
236
+ // Error", rate limit, network blip) must NOT permanently freeze the card.
237
+ // Skip this tick and keep the ticker running so the next patch recovers it.
238
+ // Only give up after several CONSECUTIVE failures (a persistent problem,
239
+ // e.g. the message was deleted or we truly lost access).
240
+ this.consecutiveEditFailures++;
241
+ const giveUp = this.consecutiveEditFailures >= MAX_CONSECUTIVE_EDIT_FAILURES;
242
+ this.callbacks.warn?.(`live status edit failed (${this.consecutiveEditFailures}/${MAX_CONSECUTIVE_EDIT_FAILURES}${giveUp ? ", disabling" : ", will retry next tick"})`, err);
243
+ if (giveUp) {
244
+ this.disabled = true;
245
+ this.stopTimers();
246
+ }
225
247
  return false;
226
248
  }
227
249
  }
250
+ /**
251
+ * Terminal edits (complete/fail/noReply) are one-shot — there is no next tick to
252
+ * recover them — so a transient Feishu error (code=2200 etc.) would leave the
253
+ * card stuck on "running". Retry the final patch a few times with small backoff
254
+ * so the card reliably reaches its terminal state.
255
+ */
256
+ async safeEditFinal(view) {
257
+ if (!this.messageId || this.disabled)
258
+ return;
259
+ const attempts = Math.max(1, MAX_CONSECUTIVE_EDIT_FAILURES);
260
+ for (let i = 0; i < attempts; i++) {
261
+ try {
262
+ await this.callbacks.edit(this.messageId, view);
263
+ this.lastSentSignature = this.signature(view);
264
+ return;
265
+ }
266
+ catch (err) {
267
+ const last = i === attempts - 1;
268
+ this.callbacks.warn?.(`live status final edit failed (${i + 1}/${attempts}${last ? ", giving up" : ", retrying"})`, err);
269
+ if (last)
270
+ return;
271
+ await new Promise((r) => setTimeout(r, 300 * (i + 1)));
272
+ }
273
+ }
274
+ }
228
275
  /** Dedupe key includes elapsed so the footer timer ticks even without new
229
276
  * activity, while identical content within the same second is still skipped. */
230
277
  signature(view) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
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": {