openclaw-lark-multi-agent 1.2.1 → 1.2.2

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.
@@ -135,6 +135,18 @@ export declare class FeishuBot {
135
135
  * provides one for normal-looking-but-truncated finals).
136
136
  */
137
137
  private looksTruncated;
138
+ /**
139
+ * Did the model type a tool call as plain text instead of emitting a real
140
+ * function call? When that happens the visible reply ends with a dangling
141
+ * invoke / parameter / function_calls tag (with or without the `antml:`
142
+ * prefix), because the tool-call markup leaked into the text channel and the
143
+ * tool never actually ran. We detect such a tag anchored at the END of the
144
+ * message — that dangling tag is the tell-tale Stephen flagged ("如果监测到
145
+ * 这个结尾,说明 tool call 格式出了问题"). On detection the auto-retry loop
146
+ * sends an explicit "invoke 格式错了,请重试" probe so the agent re-issues the
147
+ * tool call correctly rather than the generic done-check.
148
+ */
149
+ private looksLikeBrokenToolCall;
138
150
  /** Current context usage percent for a session (totalTokens / contextTokens),
139
151
  * or 0 if it cannot be determined. Same basis as /status. */
140
152
  private getContextUsagePercent;
@@ -214,6 +226,34 @@ export declare class FeishuBot {
214
226
  private deleteMessageById;
215
227
  private replyMessage;
216
228
  private extractBridgeAttachments;
229
+ /**
230
+ * Locate one bridge-attachment marker in `text` and recover its JSON payload,
231
+ * tolerating arbitrary corruption of the OPENING tag (lost prefix, or no
232
+ * opening tag at all). Strategy:
233
+ * 1. Find a closing tag — the full `</LMA_BRIDGE_ATTACHMENTS>`, or a partial
234
+ * suffix of it (e.g. `_BRIDGE_ATTACHMENTS>`, `RIDGE_ATTACHMENTS>`), or the
235
+ * `</parameter>` the marker sometimes degrades into.
236
+ * 2. From just before that closer, brace-match BACKWARDS to the start of the
237
+ * JSON value (`{...}` or `[...]`), so the opening marker is irrelevant.
238
+ * 3. Return the span to strip [start,end) plus the extracted JSON string.
239
+ * `start` extends left to swallow any leftover opening-marker remnant (so no
240
+ * `RIDGE_ATTACHMENTS>` text leaks into the visible reply).
241
+ */
242
+ private findBridgeAttachmentMarker;
243
+ /**
244
+ * Regex matching a trailing remnant of the opening marker `<LMA_BRIDGE_ATTACHMENTS>`
245
+ * at the END of a string. Built from every suffix of the full marker so that
246
+ * however many leading characters the model dropped (`_BRIDGE_ATTACHMENTS>`,
247
+ * `RIDGE_ATTACHMENTS>`, `>` …), the leftover is fully stripped from the visible
248
+ * text. Trailing `>` and surrounding whitespace are also consumed.
249
+ */
250
+ private openMarkerRemnant;
251
+ /**
252
+ * Given the index of a closing tag, walk backwards to extract the balanced
253
+ * JSON value (`{...}` or `[...]`) that ends just before it. Returns the JSON
254
+ * string and its start offset, or null if no balanced value is found.
255
+ */
256
+ private extractJsonBeforeIndex;
217
257
  private pushBridgeAttachment;
218
258
  private validateBridgeAttachmentPath;
219
259
  private inferFeishuFileType;
@@ -29,6 +29,14 @@ 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 the previous reply ended with a dangling tool-call tag (the model typed
33
+ // an invoke/tool call as plain text and it never ran), the auto-retry probe
34
+ // explicitly tells the agent its invoke format was broken and to retry, instead
35
+ // of the generic done-check. It still accepts the done phrase if actually done.
36
+ const AUTO_RETRY_BROKEN_TOOL_PROBE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_BROKEN_TOOL_PROBE
37
+ || `刚才的任务是不是没结束,invoke 格式错了,invoke 格式错了,请重试。如果结束了,回复“${AUTO_RETRY_DONE_PHRASE}”。`;
38
+ const AUTO_RETRY_BROKEN_TOOL_PROBE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_BROKEN_TOOL_PROBE_EN
39
+ || `Your last task did not finish — the invoke format was broken, the invoke format was broken, please retry. If it is actually done, reply "${AUTO_RETRY_DONE_PHRASE_EN}".`;
32
40
  // When an auto-retry probe errors AND context usage is at/above this percent,
33
41
  // auto-compact the session once and keep retrying (instead of giving up). This
34
42
  // rescues the “session too heavy → run times out / lock contention” failure mode.
@@ -1710,6 +1718,23 @@ export class FeishuBot {
1710
1718
  || /[}\]]\s*$/.test(t);
1711
1719
  return !endOk;
1712
1720
  }
1721
+ /**
1722
+ * Did the model type a tool call as plain text instead of emitting a real
1723
+ * function call? When that happens the visible reply ends with a dangling
1724
+ * invoke / parameter / function_calls tag (with or without the `antml:`
1725
+ * prefix), because the tool-call markup leaked into the text channel and the
1726
+ * tool never actually ran. We detect such a tag anchored at the END of the
1727
+ * message — that dangling tag is the tell-tale Stephen flagged ("如果监测到
1728
+ * 这个结尾,说明 tool call 格式出了问题"). On detection the auto-retry loop
1729
+ * sends an explicit "invoke 格式错了,请重试" probe so the agent re-issues the
1730
+ * tool call correctly rather than the generic done-check.
1731
+ */
1732
+ looksLikeBrokenToolCall(text) {
1733
+ const t = (text || "").trim();
1734
+ if (!t)
1735
+ return false;
1736
+ return /<\/?\s*(?:antml:)?(?:invoke|parameter|function_calls)\b[^>]*>\s*$/i.test(t);
1737
+ }
1713
1738
  /** Current context usage percent for a session (totalTokens / contextTokens),
1714
1739
  * or 0 if it cannot be determined. Same basis as /status. */
1715
1740
  async getContextUsagePercent(sessionKey) {
@@ -1808,7 +1833,12 @@ export class FeishuBot {
1808
1833
  const maxRetry = autoRetryMax();
1809
1834
  let compacted = false; // auto-compact at most once per auto-retry round
1810
1835
  let probedAtLeastOnce = false;
1836
+ // Set per-iteration when the reply under inspection ended with a dangling
1837
+ // tool-call tag (model typed an invoke as plain text); selects the explicit
1838
+ // "invoke format was broken, retry" probe instead of the generic done-check.
1839
+ let brokenToolCall = false;
1811
1840
  for (let attempt = 1; attempt <= maxRetry; attempt++) {
1841
+ brokenToolCall = false;
1812
1842
  if (!forceProbe) {
1813
1843
  const parsed = this.extractBridgeAttachments(reply);
1814
1844
  const trimmed = parsed.text.trim();
@@ -1821,20 +1851,32 @@ export class FeishuBot {
1821
1851
  // and runtime failures have their own handling upstream.
1822
1852
  if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
1823
1853
  return reply;
1824
- if (!this.looksTruncated(trimmed))
1854
+ // A dangling invoke/tool-call tag is a strong "tool call leaked as text"
1855
+ // signal — engage even if the generic truncation heuristic would miss it.
1856
+ brokenToolCall = this.looksLikeBrokenToolCall(trimmed);
1857
+ if (!this.looksTruncated(trimmed) && !brokenToolCall)
1825
1858
  return reply;
1826
1859
  }
1827
1860
  forceProbe = false; // consumed; subsequent rounds inspect the probe reply
1828
1861
  const en = this.isEn(chatId);
1829
1862
  await liveStatus?.progress(en
1830
- ? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
1831
- : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
1863
+ ? (brokenToolCall
1864
+ ? `⚠️ Tool-call format looked broken — asking it to retry (${attempt}/${maxRetry})…`
1865
+ : `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`)
1866
+ : (brokenToolCall
1867
+ ? `⚠️ 工具调用格式疑似出错,正在让它重试(${attempt}/${maxRetry})…`
1868
+ : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`)).catch(() => { });
1832
1869
  // One short line, in the user's own voice (we intentionally do not change
1833
1870
  // 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}”,没结束就接着做。`;
1871
+ // stays fully English; detection accepts both phrases. When the previous
1872
+ // reply ended with a dangling tool-call tag, send the explicit "invoke
1873
+ // format was broken, retry" probe instead of the generic done-check so the
1874
+ // agent re-issues the tool call correctly.
1875
+ const probe = brokenToolCall
1876
+ ? (en ? AUTO_RETRY_BROKEN_TOOL_PROBE_EN : AUTO_RETRY_BROKEN_TOOL_PROBE)
1877
+ : (en
1878
+ ? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
1879
+ : `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`);
1838
1880
  let probeReply;
1839
1881
  try {
1840
1882
  probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
@@ -2475,24 +2517,47 @@ export class FeishuBot {
2475
2517
  }
2476
2518
  extractBridgeAttachments(reply) {
2477
2519
  const attachments = [];
2478
- // Some models occasionally corrupt the attachment marker while preserving
2479
- // the exact JSON payload: the closing tag can become </parameter>, or the
2480
- // opening "<LMA" prefix can be truncated leaving "_BRIDGE_ATTACHMENTS>".
2481
- // Treat these as recoverable shapes; otherwise the marker leaks as
2482
- // user-visible text and attachments are not sent.
2483
- const markerPattern = /(?:<LMA_BRIDGE_ATTACHMENTS>|_BRIDGE_ATTACHMENTS>)([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
2484
- let text = reply.replace(markerPattern, (_match, jsonText) => {
2520
+ // Models occasionally corrupt the attachment marker. The opening tag is the
2521
+ // least reliable part: we have seen it lose the "<LMA" prefix ("_BRIDGE_…"),
2522
+ // lose even more ("RIDGE_ATTACHMENTS>"), or drop entirely. The closing tag is
2523
+ // far more reliably emitted. So instead of enumerating opening-tag shapes,
2524
+ // anchor on the closer and recover the JSON payload by brace-balancing
2525
+ // backwards from it — robust no matter how many leading chars are dropped.
2526
+ let text = reply;
2527
+ let guard = 0;
2528
+ for (;;) {
2529
+ if (guard++ > 50)
2530
+ break; // safety: never loop unbounded on pathological input
2531
+ const found = this.findBridgeAttachmentMarker(text);
2532
+ if (!found)
2533
+ break;
2534
+ const { start, end, json } = found;
2535
+ let consumed = false;
2485
2536
  try {
2486
- const parsed = JSON.parse(String(jsonText).trim());
2487
- const rawAttachments = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
2537
+ const parsed = JSON.parse(json);
2538
+ const rawAttachments = Array.isArray(parsed)
2539
+ ? parsed
2540
+ : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
2541
+ const before = attachments.length;
2488
2542
  for (const item of rawAttachments)
2489
2543
  this.pushBridgeAttachment(attachments, item);
2544
+ // Only strip the span if it actually yielded at least one attachment;
2545
+ // otherwise leave the text alone rather than eat unrelated content.
2546
+ consumed = attachments.length > before;
2490
2547
  }
2491
2548
  catch (err) {
2492
2549
  console.warn(`[${this.config.name}] Failed to parse bridge attachment marker:`, err.message);
2493
2550
  }
2494
- return "";
2495
- });
2551
+ if (consumed) {
2552
+ text = (text.slice(0, start) + text.slice(end)).replace(/[^\S\n]+\n/g, "\n");
2553
+ }
2554
+ else {
2555
+ // Could not use this span; blank it out so we do not re-find it forever,
2556
+ // but keep scanning in case another valid marker exists earlier.
2557
+ text = text.slice(0, start) + text.slice(end);
2558
+ }
2559
+ }
2560
+ text = text.trim();
2496
2561
  // Compatibility fallback for agents that use OpenClaw's channel directive
2497
2562
  // syntax instead of the LMA marker. In Feishu/LMA, leaving MEDIA:<path> in
2498
2563
  // plain text only shows the path, so parse it into bridge attachments.
@@ -2507,6 +2572,111 @@ export class FeishuBot {
2507
2572
  }).trim();
2508
2573
  return { text, attachments };
2509
2574
  }
2575
+ /**
2576
+ * Locate one bridge-attachment marker in `text` and recover its JSON payload,
2577
+ * tolerating arbitrary corruption of the OPENING tag (lost prefix, or no
2578
+ * opening tag at all). Strategy:
2579
+ * 1. Find a closing tag — the full `</LMA_BRIDGE_ATTACHMENTS>`, or a partial
2580
+ * suffix of it (e.g. `_BRIDGE_ATTACHMENTS>`, `RIDGE_ATTACHMENTS>`), or the
2581
+ * `</parameter>` the marker sometimes degrades into.
2582
+ * 2. From just before that closer, brace-match BACKWARDS to the start of the
2583
+ * JSON value (`{...}` or `[...]`), so the opening marker is irrelevant.
2584
+ * 3. Return the span to strip [start,end) plus the extracted JSON string.
2585
+ * `start` extends left to swallow any leftover opening-marker remnant (so no
2586
+ * `RIDGE_ATTACHMENTS>` text leaks into the visible reply).
2587
+ */
2588
+ findBridgeAttachmentMarker(text) {
2589
+ // Closing-tag candidates, longest/most-specific first. We accept partial
2590
+ // suffixes of the real closing tag because the opening corruption pattern
2591
+ // (dropped leading chars) also happens to the closer.
2592
+ const closeRe = /<\/LMA_BRIDGE_ATTACHMENTS>|[A-Z_]*BRIDGE_ATTACHMENTS>\s*<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>/g;
2593
+ // The JSON sits between an (often corrupted) opener and the closer. Find the
2594
+ // closer first, then walk back to the JSON. Prefer the canonical closer.
2595
+ const closers = [];
2596
+ const canonical = /<\/LMA_BRIDGE_ATTACHMENTS>/g;
2597
+ let m;
2598
+ while ((m = canonical.exec(text)))
2599
+ closers.push({ idx: m.index, len: m[0].length });
2600
+ if (closers.length === 0) {
2601
+ // Degraded closer: `</parameter>` immediately after the JSON payload.
2602
+ const param = /<\/parameter>/g;
2603
+ while ((m = param.exec(text)))
2604
+ closers.push({ idx: m.index, len: m[0].length });
2605
+ }
2606
+ void closeRe; // (kept for documentation of accepted shapes)
2607
+ for (const c of closers) {
2608
+ const json = this.extractJsonBeforeIndex(text, c.idx);
2609
+ if (!json)
2610
+ continue;
2611
+ // Extend `start` left over any opening-marker remnant + surrounding space,
2612
+ // so the visible text does not keep a dangling `RIDGE_ATTACHMENTS>` etc.
2613
+ // The remnant is always some trailing SUFFIX of `<LMA_BRIDGE_ATTACHMENTS>`
2614
+ // (corruption drops leading chars), so match any such suffix.
2615
+ let start = json.start;
2616
+ const lead = text.slice(Math.max(0, start - 48), start);
2617
+ const leadMarker = this.openMarkerRemnant().exec(lead);
2618
+ if (leadMarker)
2619
+ start = Math.max(0, start - (lead.length - leadMarker.index));
2620
+ return { start, end: c.idx + c.len, json: json.text };
2621
+ }
2622
+ return null;
2623
+ }
2624
+ /**
2625
+ * Regex matching a trailing remnant of the opening marker `<LMA_BRIDGE_ATTACHMENTS>`
2626
+ * at the END of a string. Built from every suffix of the full marker so that
2627
+ * however many leading characters the model dropped (`_BRIDGE_ATTACHMENTS>`,
2628
+ * `RIDGE_ATTACHMENTS>`, `>` …), the leftover is fully stripped from the visible
2629
+ * text. Trailing `>` and surrounding whitespace are also consumed.
2630
+ */
2631
+ openMarkerRemnant() {
2632
+ const full = "<LMA_BRIDGE_ATTACHMENTS>";
2633
+ const suffixes = [];
2634
+ for (let i = 0; i < full.length; i++)
2635
+ suffixes.push(full.slice(i).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
2636
+ // Longest suffixes first so the regex prefers the most complete remnant.
2637
+ return new RegExp(`(?:${suffixes.join("|")})\\s*$`);
2638
+ }
2639
+ /**
2640
+ * Given the index of a closing tag, walk backwards to extract the balanced
2641
+ * JSON value (`{...}` or `[...]`) that ends just before it. Returns the JSON
2642
+ * string and its start offset, or null if no balanced value is found.
2643
+ */
2644
+ extractJsonBeforeIndex(text, closeIdx) {
2645
+ // Skip whitespace immediately before the closer.
2646
+ let end = closeIdx;
2647
+ while (end > 0 && /\s/.test(text[end - 1]))
2648
+ end--;
2649
+ if (end === 0)
2650
+ return null;
2651
+ const lastChar = text[end - 1];
2652
+ const open = lastChar === "}" ? "{" : lastChar === "]" ? "[" : null;
2653
+ if (!open)
2654
+ return null;
2655
+ const close = lastChar;
2656
+ // Brace-balance backwards, ignoring braces inside strings.
2657
+ let depth = 0;
2658
+ let inStr = false;
2659
+ for (let i = end - 1; i >= 0; i--) {
2660
+ const ch = text[i];
2661
+ if (inStr) {
2662
+ if (ch === '"' && text[i - 1] !== "\\")
2663
+ inStr = false;
2664
+ continue;
2665
+ }
2666
+ if (ch === '"') {
2667
+ inStr = true;
2668
+ continue;
2669
+ }
2670
+ if (ch === close)
2671
+ depth++;
2672
+ else if (ch === open) {
2673
+ depth--;
2674
+ if (depth === 0)
2675
+ return { text: text.slice(i, end), start: i };
2676
+ }
2677
+ }
2678
+ return null;
2679
+ }
2510
2680
  pushBridgeAttachment(attachments, item) {
2511
2681
  if (!item || typeof item.path !== "string")
2512
2682
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
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": {