openclaw-lark-multi-agent 1.2.1 → 1.2.3
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/dist/feishu-bot.d.ts +88 -0
- package/dist/feishu-bot.js +290 -28
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export declare class FeishuBot {
|
|
|
21
21
|
private initializedSessions;
|
|
22
22
|
/** Per-chat busy lock: timestamp when became busy (0 = not busy) */
|
|
23
23
|
private busyChats;
|
|
24
|
+
/** Per-chat "stop" generation counter. Bumped by /stop so any in-flight
|
|
25
|
+
* auto-retry loop can detect it was cancelled and stop retrying immediately. */
|
|
26
|
+
private stopEpoch;
|
|
24
27
|
/** Per-chat pending reply message IDs (to ack with DONE when their trigger is processed) */
|
|
25
28
|
private pendingAckMessages;
|
|
26
29
|
/** Per-chat pending tool message sends (to await before final reply) */
|
|
@@ -134,7 +137,64 @@ export declare class FeishuBot {
|
|
|
134
137
|
* only on cheap text shape here, never on stopReason (the upstream rarely
|
|
135
138
|
* provides one for normal-looking-but-truncated finals).
|
|
136
139
|
*/
|
|
140
|
+
/**
|
|
141
|
+
* Heuristic: does this reply look genuinely truncated / cut off mid-task?
|
|
142
|
+
*
|
|
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:
|
|
149
|
+
* 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.
|
|
156
|
+
*/
|
|
137
157
|
private looksTruncated;
|
|
158
|
+
/**
|
|
159
|
+
* Did the model type a tool call as plain text instead of emitting a real
|
|
160
|
+
* function call? When that happens the visible reply ends with a dangling
|
|
161
|
+
* invoke / parameter / function_calls tag (with or without the `antml:`
|
|
162
|
+
* prefix), because the tool-call markup leaked into the text channel and the
|
|
163
|
+
* tool never actually ran. We detect such a tag anchored at the END of the
|
|
164
|
+
* message — that dangling tag is the tell-tale Stephen flagged ("如果监测到
|
|
165
|
+
* 这个结尾,说明 tool call 格式出了问题"). On detection the auto-retry loop
|
|
166
|
+
* sends an explicit "invoke 格式错了,请重试" probe so the agent re-issues the
|
|
167
|
+
* tool call correctly rather than the generic done-check.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* Did the model serialize a tool call as plain text instead of emitting a real
|
|
171
|
+
* function call? When that happens, the structured tool-call XML leaks into the
|
|
172
|
+
* visible text channel and the tool never runs. We must distinguish this from a
|
|
173
|
+
* reply that merely *mentions* a tag (teaching, quoting, showing an example),
|
|
174
|
+
* which must NOT trigger a retry. So we require strong STRUCTURAL evidence of a
|
|
175
|
+
* leaked call, not just any tag at the end:
|
|
176
|
+
*
|
|
177
|
+
* 1. The reply must END with a closing `</invoke>` (optionally `antml:`).
|
|
178
|
+
* A dangling OPEN tag or a bare `</parameter>`/`<function_calls>` alone is
|
|
179
|
+
* not enough — too easy to hit in normal prose.
|
|
180
|
+
* 2. Somewhere before it there must be a matching `<invoke name="...">` OPEN
|
|
181
|
+
* tag — i.e. a whole call block (`<invoke…>…</invoke>`) materialized as
|
|
182
|
+
* text. A lone `</invoke>` (e.g. “闭合标签是 </invoke>”) is a mention,
|
|
183
|
+
* not a leaked call.
|
|
184
|
+
* 3. The trailing `</invoke>` must NOT be wrapped in inline code/backticks or
|
|
185
|
+
* a fenced code block — those are deliberate examples, not a leak.
|
|
186
|
+
*
|
|
187
|
+
* Anchored at the very end because a genuinely leaked call is the LAST thing in
|
|
188
|
+
* the message (the model stopped right after “emitting” it). This keeps the
|
|
189
|
+
* trigger tight so we do not retry on normal replies that talk about tags.
|
|
190
|
+
*/
|
|
191
|
+
private looksLikeBrokenToolCall;
|
|
192
|
+
/**
|
|
193
|
+
* Is the tag at `tagIndex` inside an inline-code span (`` `…` ``) or a fenced
|
|
194
|
+
* code block (```` ``` ````)? Such tags are deliberate examples, not a leaked
|
|
195
|
+
* tool call, so the broken-tool-call detector must ignore them.
|
|
196
|
+
*/
|
|
197
|
+
private isTrailingTagInCode;
|
|
138
198
|
/** Current context usage percent for a session (totalTokens / contextTokens),
|
|
139
199
|
* or 0 if it cannot be determined. Same basis as /status. */
|
|
140
200
|
private getContextUsagePercent;
|
|
@@ -214,6 +274,34 @@ export declare class FeishuBot {
|
|
|
214
274
|
private deleteMessageById;
|
|
215
275
|
private replyMessage;
|
|
216
276
|
private extractBridgeAttachments;
|
|
277
|
+
/**
|
|
278
|
+
* Locate one bridge-attachment marker in `text` and recover its JSON payload,
|
|
279
|
+
* tolerating arbitrary corruption of the OPENING tag (lost prefix, or no
|
|
280
|
+
* opening tag at all). Strategy:
|
|
281
|
+
* 1. Find a closing tag — the full `</LMA_BRIDGE_ATTACHMENTS>`, or a partial
|
|
282
|
+
* suffix of it (e.g. `_BRIDGE_ATTACHMENTS>`, `RIDGE_ATTACHMENTS>`), or the
|
|
283
|
+
* `</parameter>` the marker sometimes degrades into.
|
|
284
|
+
* 2. From just before that closer, brace-match BACKWARDS to the start of the
|
|
285
|
+
* JSON value (`{...}` or `[...]`), so the opening marker is irrelevant.
|
|
286
|
+
* 3. Return the span to strip [start,end) plus the extracted JSON string.
|
|
287
|
+
* `start` extends left to swallow any leftover opening-marker remnant (so no
|
|
288
|
+
* `RIDGE_ATTACHMENTS>` text leaks into the visible reply).
|
|
289
|
+
*/
|
|
290
|
+
private findBridgeAttachmentMarker;
|
|
291
|
+
/**
|
|
292
|
+
* Regex matching a trailing remnant of the opening marker `<LMA_BRIDGE_ATTACHMENTS>`
|
|
293
|
+
* at the END of a string. Built from every suffix of the full marker so that
|
|
294
|
+
* however many leading characters the model dropped (`_BRIDGE_ATTACHMENTS>`,
|
|
295
|
+
* `RIDGE_ATTACHMENTS>`, `>` …), the leftover is fully stripped from the visible
|
|
296
|
+
* text. Trailing `>` and surrounding whitespace are also consumed.
|
|
297
|
+
*/
|
|
298
|
+
private openMarkerRemnant;
|
|
299
|
+
/**
|
|
300
|
+
* Given the index of a closing tag, walk backwards to extract the balanced
|
|
301
|
+
* JSON value (`{...}` or `[...]`) that ends just before it. Returns the JSON
|
|
302
|
+
* string and its start offset, or null if no balanced value is found.
|
|
303
|
+
*/
|
|
304
|
+
private extractJsonBeforeIndex;
|
|
217
305
|
private pushBridgeAttachment;
|
|
218
306
|
private validateBridgeAttachmentPath;
|
|
219
307
|
private inferFeishuFileType;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -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.
|
|
@@ -67,6 +75,9 @@ export class FeishuBot {
|
|
|
67
75
|
initializedSessions = new Set();
|
|
68
76
|
/** Per-chat busy lock: timestamp when became busy (0 = not busy) */
|
|
69
77
|
busyChats = new Map();
|
|
78
|
+
/** Per-chat "stop" generation counter. Bumped by /stop so any in-flight
|
|
79
|
+
* auto-retry loop can detect it was cancelled and stop retrying immediately. */
|
|
80
|
+
stopEpoch = new Map();
|
|
70
81
|
/** Per-chat pending reply message IDs (to ack with DONE when their trigger is processed) */
|
|
71
82
|
pendingAckMessages = new Map();
|
|
72
83
|
/** Per-chat pending tool message sends (to await before final reply) */
|
|
@@ -1689,26 +1700,108 @@ export class FeishuBot {
|
|
|
1689
1700
|
* only on cheap text shape here, never on stopReason (the upstream rarely
|
|
1690
1701
|
* provides one for normal-looking-but-truncated finals).
|
|
1691
1702
|
*/
|
|
1703
|
+
/**
|
|
1704
|
+
* Heuristic: does this reply look genuinely truncated / cut off mid-task?
|
|
1705
|
+
*
|
|
1706
|
+
* IMPORTANT (per Stephen): only retry when there is CLEAR evidence the agent
|
|
1707
|
+
* did not finish — do NOT retry just because a reply lacks a trailing period.
|
|
1708
|
+
* Many complete replies legitimately end without sentence punctuation (a terse
|
|
1709
|
+
* “好的”, a bullet list, a path, a number, a closing word). Treating those as
|
|
1710
|
+
* truncated caused needless extra probe rounds, which hurts UX. So we gate on
|
|
1711
|
+
* strong structural signals only:
|
|
1712
|
+
* 1. Unbalanced code fence — almost certainly cut off mid code block.
|
|
1713
|
+
* 2. Ends with a dangling connector / lead-in (“然后”, “接下来”, “let me”…)
|
|
1714
|
+
* or a trailing clause-joining punctuation (comma/colon/semicolon),
|
|
1715
|
+
* which means a sentence was left hanging.
|
|
1716
|
+
* 3. Ends mid-word inside a Latin word right after a connector-less clause
|
|
1717
|
+
* (very rare; only when the last “word” is unusually long), kept minimal.
|
|
1718
|
+
* A plain “no sentence-ending punctuation” is deliberately NOT sufficient.
|
|
1719
|
+
*/
|
|
1692
1720
|
looksTruncated(text) {
|
|
1693
1721
|
const t = (text || "").trim();
|
|
1694
1722
|
if (!t)
|
|
1695
1723
|
return false; // empty/NO_REPLY handled elsewhere
|
|
1696
|
-
// Unbalanced code fence => almost certainly cut off mid-block.
|
|
1724
|
+
// (1) Unbalanced code fence => almost certainly cut off mid-block.
|
|
1697
1725
|
const fences = (t.match(/```/g) || []).length;
|
|
1698
1726
|
if (fences % 2 === 1)
|
|
1699
1727
|
return true;
|
|
1700
|
-
//
|
|
1728
|
+
// (2a) Trailing clause-joining punctuation: a sentence was left hanging.
|
|
1701
1729
|
if (/[,,::、;;]\s*$/u.test(t))
|
|
1702
1730
|
return true;
|
|
1703
|
-
|
|
1731
|
+
// (2b) Ends on an explicit continuation cue / lead-in.
|
|
1732
|
+
if (/(接下来|然后|首先|现在我|让我|下一步|我先|我来|我接着|接着|稍等|let me|now i|next|first|\b(?:and|then|so|to|the|a)\b)\s*$/iu.test(t))
|
|
1704
1733
|
return true;
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Did the model type a tool call as plain text instead of emitting a real
|
|
1738
|
+
* function call? When that happens the visible reply ends with a dangling
|
|
1739
|
+
* invoke / parameter / function_calls tag (with or without the `antml:`
|
|
1740
|
+
* prefix), because the tool-call markup leaked into the text channel and the
|
|
1741
|
+
* tool never actually ran. We detect such a tag anchored at the END of the
|
|
1742
|
+
* message — that dangling tag is the tell-tale Stephen flagged ("如果监测到
|
|
1743
|
+
* 这个结尾,说明 tool call 格式出了问题"). On detection the auto-retry loop
|
|
1744
|
+
* sends an explicit "invoke 格式错了,请重试" probe so the agent re-issues the
|
|
1745
|
+
* tool call correctly rather than the generic done-check.
|
|
1746
|
+
*/
|
|
1747
|
+
/**
|
|
1748
|
+
* Did the model serialize a tool call as plain text instead of emitting a real
|
|
1749
|
+
* function call? When that happens, the structured tool-call XML leaks into the
|
|
1750
|
+
* visible text channel and the tool never runs. We must distinguish this from a
|
|
1751
|
+
* reply that merely *mentions* a tag (teaching, quoting, showing an example),
|
|
1752
|
+
* which must NOT trigger a retry. So we require strong STRUCTURAL evidence of a
|
|
1753
|
+
* leaked call, not just any tag at the end:
|
|
1754
|
+
*
|
|
1755
|
+
* 1. The reply must END with a closing `</invoke>` (optionally `antml:`).
|
|
1756
|
+
* A dangling OPEN tag or a bare `</parameter>`/`<function_calls>` alone is
|
|
1757
|
+
* not enough — too easy to hit in normal prose.
|
|
1758
|
+
* 2. Somewhere before it there must be a matching `<invoke name="...">` OPEN
|
|
1759
|
+
* tag — i.e. a whole call block (`<invoke…>…</invoke>`) materialized as
|
|
1760
|
+
* text. A lone `</invoke>` (e.g. “闭合标签是 </invoke>”) is a mention,
|
|
1761
|
+
* not a leaked call.
|
|
1762
|
+
* 3. The trailing `</invoke>` must NOT be wrapped in inline code/backticks or
|
|
1763
|
+
* a fenced code block — those are deliberate examples, not a leak.
|
|
1764
|
+
*
|
|
1765
|
+
* Anchored at the very end because a genuinely leaked call is the LAST thing in
|
|
1766
|
+
* the message (the model stopped right after “emitting” it). This keeps the
|
|
1767
|
+
* trigger tight so we do not retry on normal replies that talk about tags.
|
|
1768
|
+
*/
|
|
1769
|
+
looksLikeBrokenToolCall(text) {
|
|
1770
|
+
const t = (text || "").trim();
|
|
1771
|
+
if (!t)
|
|
1772
|
+
return false;
|
|
1773
|
+
// (1) Must end with a closing invoke tag (the serialized call's terminator).
|
|
1774
|
+
const closeMatch = /<\/\s*(?:antml:)?invoke\s*>\s*$/i.exec(t);
|
|
1775
|
+
if (!closeMatch)
|
|
1776
|
+
return false;
|
|
1777
|
+
// (3) Reject when that closing tag is fenced or inline-code wrapped (example).
|
|
1778
|
+
if (this.isTrailingTagInCode(t, closeMatch.index))
|
|
1779
|
+
return false;
|
|
1780
|
+
// (2) Require a matching opening `<invoke ...>` before the closer — proof that
|
|
1781
|
+
// a whole call block leaked, not just a mention of the closing tag.
|
|
1782
|
+
const head = t.slice(0, closeMatch.index);
|
|
1783
|
+
if (!/<\s*(?:antml:)?invoke\b[^>]*>/i.test(head))
|
|
1784
|
+
return false;
|
|
1785
|
+
return true;
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Is the tag at `tagIndex` inside an inline-code span (`` `…` ``) or a fenced
|
|
1789
|
+
* code block (```` ``` ````)? Such tags are deliberate examples, not a leaked
|
|
1790
|
+
* tool call, so the broken-tool-call detector must ignore them.
|
|
1791
|
+
*/
|
|
1792
|
+
isTrailingTagInCode(text, tagIndex) {
|
|
1793
|
+
const before = text.slice(0, tagIndex);
|
|
1794
|
+
// Fenced block: an odd number of ``` before the tag means we are inside one.
|
|
1795
|
+
const fences = (before.match(/```/g) || []).length;
|
|
1796
|
+
if (fences % 2 === 1)
|
|
1797
|
+
return true;
|
|
1798
|
+
// Inline code: an odd number of unescaped backticks on the tag's own line.
|
|
1799
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
1800
|
+
const lineBefore = before.slice(lineStart);
|
|
1801
|
+
const ticks = (lineBefore.match(/`/g) || []).length;
|
|
1802
|
+
if (ticks % 2 === 1)
|
|
1803
|
+
return true;
|
|
1804
|
+
return false;
|
|
1712
1805
|
}
|
|
1713
1806
|
/** Current context usage percent for a session (totalTokens / contextTokens),
|
|
1714
1807
|
* or 0 if it cannot be determined. Same basis as /status. */
|
|
@@ -1808,7 +1901,24 @@ export class FeishuBot {
|
|
|
1808
1901
|
const maxRetry = autoRetryMax();
|
|
1809
1902
|
let compacted = false; // auto-compact at most once per auto-retry round
|
|
1810
1903
|
let probedAtLeastOnce = false;
|
|
1904
|
+
// Snapshot the stop generation at entry. If the user runs /stop while this
|
|
1905
|
+
// loop is in flight, handleStopCommand bumps the epoch; we detect the change
|
|
1906
|
+
// and stop retrying immediately instead of issuing more probes.
|
|
1907
|
+
const startStopEpoch = this.stopEpoch.get(chatId) || 0;
|
|
1908
|
+
const wasStopped = () => (this.stopEpoch.get(chatId) || 0) !== startStopEpoch;
|
|
1909
|
+
// Set per-iteration when the reply under inspection ended with a dangling
|
|
1910
|
+
// tool-call tag (model typed an invoke as plain text); selects the explicit
|
|
1911
|
+
// "invoke format was broken, retry" probe instead of the generic done-check.
|
|
1912
|
+
let brokenToolCall = false;
|
|
1811
1913
|
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
|
1914
|
+
// /stop while we were looping: abandon retries and deliver what we have.
|
|
1915
|
+
if (wasStopped()) {
|
|
1916
|
+
console.log(`[${this.config.name}] auto-retry: aborted by /stop for ${chatId.slice(-8)}`);
|
|
1917
|
+
if (!reply)
|
|
1918
|
+
throw new Error(`Agent error: ${params.initialError || "stopped by user"}`);
|
|
1919
|
+
return reply;
|
|
1920
|
+
}
|
|
1921
|
+
brokenToolCall = false;
|
|
1812
1922
|
if (!forceProbe) {
|
|
1813
1923
|
const parsed = this.extractBridgeAttachments(reply);
|
|
1814
1924
|
const trimmed = parsed.text.trim();
|
|
@@ -1821,20 +1931,32 @@ export class FeishuBot {
|
|
|
1821
1931
|
// and runtime failures have their own handling upstream.
|
|
1822
1932
|
if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
|
|
1823
1933
|
return reply;
|
|
1824
|
-
|
|
1934
|
+
// A dangling invoke/tool-call tag is a strong "tool call leaked as text"
|
|
1935
|
+
// signal — engage even if the generic truncation heuristic would miss it.
|
|
1936
|
+
brokenToolCall = this.looksLikeBrokenToolCall(trimmed);
|
|
1937
|
+
if (!this.looksTruncated(trimmed) && !brokenToolCall)
|
|
1825
1938
|
return reply;
|
|
1826
1939
|
}
|
|
1827
1940
|
forceProbe = false; // consumed; subsequent rounds inspect the probe reply
|
|
1828
1941
|
const en = this.isEn(chatId);
|
|
1829
1942
|
await liveStatus?.progress(en
|
|
1830
|
-
?
|
|
1831
|
-
|
|
1943
|
+
? (brokenToolCall
|
|
1944
|
+
? `⚠️ Tool-call format looked broken — asking it to retry (${attempt}/${maxRetry})…`
|
|
1945
|
+
: `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`)
|
|
1946
|
+
: (brokenToolCall
|
|
1947
|
+
? `⚠️ 工具调用格式疑似出错,正在让它重试(${attempt}/${maxRetry})…`
|
|
1948
|
+
: `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`)).catch(() => { });
|
|
1832
1949
|
// One short line, in the user's own voice (we intentionally do not change
|
|
1833
1950
|
// senderName). Each locale uses its own done-phrase so the English probe
|
|
1834
|
-
// stays fully English; detection accepts both phrases.
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1951
|
+
// stays fully English; detection accepts both phrases. When the previous
|
|
1952
|
+
// reply ended with a dangling tool-call tag, send the explicit "invoke
|
|
1953
|
+
// format was broken, retry" probe instead of the generic done-check so the
|
|
1954
|
+
// agent re-issues the tool call correctly.
|
|
1955
|
+
const probe = brokenToolCall
|
|
1956
|
+
? (en ? AUTO_RETRY_BROKEN_TOOL_PROBE_EN : AUTO_RETRY_BROKEN_TOOL_PROBE)
|
|
1957
|
+
: (en
|
|
1958
|
+
? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
|
|
1959
|
+
: `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`);
|
|
1838
1960
|
let probeReply;
|
|
1839
1961
|
try {
|
|
1840
1962
|
probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
|
|
@@ -1886,6 +2008,15 @@ export class FeishuBot {
|
|
|
1886
2008
|
return reply;
|
|
1887
2009
|
}
|
|
1888
2010
|
const probeText = this.extractBridgeAttachments(probeReply).text.trim();
|
|
2011
|
+
// /stop landed while the probe was in flight: stop here rather than loop
|
|
2012
|
+
// again. Deliver the latest meaningful output we have.
|
|
2013
|
+
if (wasStopped()) {
|
|
2014
|
+
console.log(`[${this.config.name}] auto-retry: aborted by /stop mid-probe for ${chatId.slice(-8)}`);
|
|
2015
|
+
const latest = probeText && probeText.toUpperCase() !== "NO_REPLY" ? probeReply : reply;
|
|
2016
|
+
if (!latest)
|
|
2017
|
+
throw new Error(`Agent error: ${params.initialError || "stopped by user"}`);
|
|
2018
|
+
return latest;
|
|
2019
|
+
}
|
|
1889
2020
|
if (!probeText || probeText.toUpperCase() === "NO_REPLY") {
|
|
1890
2021
|
if (!reply)
|
|
1891
2022
|
throw new Error(`Agent error: ${params.initialError || "no reply after retry"}`);
|
|
@@ -2475,24 +2606,47 @@ export class FeishuBot {
|
|
|
2475
2606
|
}
|
|
2476
2607
|
extractBridgeAttachments(reply) {
|
|
2477
2608
|
const attachments = [];
|
|
2478
|
-
//
|
|
2479
|
-
//
|
|
2480
|
-
//
|
|
2481
|
-
//
|
|
2482
|
-
//
|
|
2483
|
-
|
|
2484
|
-
let text = reply
|
|
2609
|
+
// Models occasionally corrupt the attachment marker. The opening tag is the
|
|
2610
|
+
// least reliable part: we have seen it lose the "<LMA" prefix ("_BRIDGE_…"),
|
|
2611
|
+
// lose even more ("RIDGE_ATTACHMENTS>"), or drop entirely. The closing tag is
|
|
2612
|
+
// far more reliably emitted. So instead of enumerating opening-tag shapes,
|
|
2613
|
+
// anchor on the closer and recover the JSON payload by brace-balancing
|
|
2614
|
+
// backwards from it — robust no matter how many leading chars are dropped.
|
|
2615
|
+
let text = reply;
|
|
2616
|
+
let guard = 0;
|
|
2617
|
+
for (;;) {
|
|
2618
|
+
if (guard++ > 50)
|
|
2619
|
+
break; // safety: never loop unbounded on pathological input
|
|
2620
|
+
const found = this.findBridgeAttachmentMarker(text);
|
|
2621
|
+
if (!found)
|
|
2622
|
+
break;
|
|
2623
|
+
const { start, end, json } = found;
|
|
2624
|
+
let consumed = false;
|
|
2485
2625
|
try {
|
|
2486
|
-
const parsed = JSON.parse(
|
|
2487
|
-
const rawAttachments = Array.isArray(parsed)
|
|
2626
|
+
const parsed = JSON.parse(json);
|
|
2627
|
+
const rawAttachments = Array.isArray(parsed)
|
|
2628
|
+
? parsed
|
|
2629
|
+
: Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
|
|
2630
|
+
const before = attachments.length;
|
|
2488
2631
|
for (const item of rawAttachments)
|
|
2489
2632
|
this.pushBridgeAttachment(attachments, item);
|
|
2633
|
+
// Only strip the span if it actually yielded at least one attachment;
|
|
2634
|
+
// otherwise leave the text alone rather than eat unrelated content.
|
|
2635
|
+
consumed = attachments.length > before;
|
|
2490
2636
|
}
|
|
2491
2637
|
catch (err) {
|
|
2492
2638
|
console.warn(`[${this.config.name}] Failed to parse bridge attachment marker:`, err.message);
|
|
2493
2639
|
}
|
|
2494
|
-
|
|
2495
|
-
|
|
2640
|
+
if (consumed) {
|
|
2641
|
+
text = (text.slice(0, start) + text.slice(end)).replace(/[^\S\n]+\n/g, "\n");
|
|
2642
|
+
}
|
|
2643
|
+
else {
|
|
2644
|
+
// Could not use this span; blank it out so we do not re-find it forever,
|
|
2645
|
+
// but keep scanning in case another valid marker exists earlier.
|
|
2646
|
+
text = text.slice(0, start) + text.slice(end);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
text = text.trim();
|
|
2496
2650
|
// Compatibility fallback for agents that use OpenClaw's channel directive
|
|
2497
2651
|
// syntax instead of the LMA marker. In Feishu/LMA, leaving MEDIA:<path> in
|
|
2498
2652
|
// plain text only shows the path, so parse it into bridge attachments.
|
|
@@ -2507,6 +2661,111 @@ export class FeishuBot {
|
|
|
2507
2661
|
}).trim();
|
|
2508
2662
|
return { text, attachments };
|
|
2509
2663
|
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Locate one bridge-attachment marker in `text` and recover its JSON payload,
|
|
2666
|
+
* tolerating arbitrary corruption of the OPENING tag (lost prefix, or no
|
|
2667
|
+
* opening tag at all). Strategy:
|
|
2668
|
+
* 1. Find a closing tag — the full `</LMA_BRIDGE_ATTACHMENTS>`, or a partial
|
|
2669
|
+
* suffix of it (e.g. `_BRIDGE_ATTACHMENTS>`, `RIDGE_ATTACHMENTS>`), or the
|
|
2670
|
+
* `</parameter>` the marker sometimes degrades into.
|
|
2671
|
+
* 2. From just before that closer, brace-match BACKWARDS to the start of the
|
|
2672
|
+
* JSON value (`{...}` or `[...]`), so the opening marker is irrelevant.
|
|
2673
|
+
* 3. Return the span to strip [start,end) plus the extracted JSON string.
|
|
2674
|
+
* `start` extends left to swallow any leftover opening-marker remnant (so no
|
|
2675
|
+
* `RIDGE_ATTACHMENTS>` text leaks into the visible reply).
|
|
2676
|
+
*/
|
|
2677
|
+
findBridgeAttachmentMarker(text) {
|
|
2678
|
+
// Closing-tag candidates, longest/most-specific first. We accept partial
|
|
2679
|
+
// suffixes of the real closing tag because the opening corruption pattern
|
|
2680
|
+
// (dropped leading chars) also happens to the closer.
|
|
2681
|
+
const closeRe = /<\/LMA_BRIDGE_ATTACHMENTS>|[A-Z_]*BRIDGE_ATTACHMENTS>\s*<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>/g;
|
|
2682
|
+
// The JSON sits between an (often corrupted) opener and the closer. Find the
|
|
2683
|
+
// closer first, then walk back to the JSON. Prefer the canonical closer.
|
|
2684
|
+
const closers = [];
|
|
2685
|
+
const canonical = /<\/LMA_BRIDGE_ATTACHMENTS>/g;
|
|
2686
|
+
let m;
|
|
2687
|
+
while ((m = canonical.exec(text)))
|
|
2688
|
+
closers.push({ idx: m.index, len: m[0].length });
|
|
2689
|
+
if (closers.length === 0) {
|
|
2690
|
+
// Degraded closer: `</parameter>` immediately after the JSON payload.
|
|
2691
|
+
const param = /<\/parameter>/g;
|
|
2692
|
+
while ((m = param.exec(text)))
|
|
2693
|
+
closers.push({ idx: m.index, len: m[0].length });
|
|
2694
|
+
}
|
|
2695
|
+
void closeRe; // (kept for documentation of accepted shapes)
|
|
2696
|
+
for (const c of closers) {
|
|
2697
|
+
const json = this.extractJsonBeforeIndex(text, c.idx);
|
|
2698
|
+
if (!json)
|
|
2699
|
+
continue;
|
|
2700
|
+
// Extend `start` left over any opening-marker remnant + surrounding space,
|
|
2701
|
+
// so the visible text does not keep a dangling `RIDGE_ATTACHMENTS>` etc.
|
|
2702
|
+
// The remnant is always some trailing SUFFIX of `<LMA_BRIDGE_ATTACHMENTS>`
|
|
2703
|
+
// (corruption drops leading chars), so match any such suffix.
|
|
2704
|
+
let start = json.start;
|
|
2705
|
+
const lead = text.slice(Math.max(0, start - 48), start);
|
|
2706
|
+
const leadMarker = this.openMarkerRemnant().exec(lead);
|
|
2707
|
+
if (leadMarker)
|
|
2708
|
+
start = Math.max(0, start - (lead.length - leadMarker.index));
|
|
2709
|
+
return { start, end: c.idx + c.len, json: json.text };
|
|
2710
|
+
}
|
|
2711
|
+
return null;
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* Regex matching a trailing remnant of the opening marker `<LMA_BRIDGE_ATTACHMENTS>`
|
|
2715
|
+
* at the END of a string. Built from every suffix of the full marker so that
|
|
2716
|
+
* however many leading characters the model dropped (`_BRIDGE_ATTACHMENTS>`,
|
|
2717
|
+
* `RIDGE_ATTACHMENTS>`, `>` …), the leftover is fully stripped from the visible
|
|
2718
|
+
* text. Trailing `>` and surrounding whitespace are also consumed.
|
|
2719
|
+
*/
|
|
2720
|
+
openMarkerRemnant() {
|
|
2721
|
+
const full = "<LMA_BRIDGE_ATTACHMENTS>";
|
|
2722
|
+
const suffixes = [];
|
|
2723
|
+
for (let i = 0; i < full.length; i++)
|
|
2724
|
+
suffixes.push(full.slice(i).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
2725
|
+
// Longest suffixes first so the regex prefers the most complete remnant.
|
|
2726
|
+
return new RegExp(`(?:${suffixes.join("|")})\\s*$`);
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Given the index of a closing tag, walk backwards to extract the balanced
|
|
2730
|
+
* JSON value (`{...}` or `[...]`) that ends just before it. Returns the JSON
|
|
2731
|
+
* string and its start offset, or null if no balanced value is found.
|
|
2732
|
+
*/
|
|
2733
|
+
extractJsonBeforeIndex(text, closeIdx) {
|
|
2734
|
+
// Skip whitespace immediately before the closer.
|
|
2735
|
+
let end = closeIdx;
|
|
2736
|
+
while (end > 0 && /\s/.test(text[end - 1]))
|
|
2737
|
+
end--;
|
|
2738
|
+
if (end === 0)
|
|
2739
|
+
return null;
|
|
2740
|
+
const lastChar = text[end - 1];
|
|
2741
|
+
const open = lastChar === "}" ? "{" : lastChar === "]" ? "[" : null;
|
|
2742
|
+
if (!open)
|
|
2743
|
+
return null;
|
|
2744
|
+
const close = lastChar;
|
|
2745
|
+
// Brace-balance backwards, ignoring braces inside strings.
|
|
2746
|
+
let depth = 0;
|
|
2747
|
+
let inStr = false;
|
|
2748
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
2749
|
+
const ch = text[i];
|
|
2750
|
+
if (inStr) {
|
|
2751
|
+
if (ch === '"' && text[i - 1] !== "\\")
|
|
2752
|
+
inStr = false;
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
if (ch === '"') {
|
|
2756
|
+
inStr = true;
|
|
2757
|
+
continue;
|
|
2758
|
+
}
|
|
2759
|
+
if (ch === close)
|
|
2760
|
+
depth++;
|
|
2761
|
+
else if (ch === open) {
|
|
2762
|
+
depth--;
|
|
2763
|
+
if (depth === 0)
|
|
2764
|
+
return { text: text.slice(i, end), start: i };
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2510
2769
|
pushBridgeAttachment(attachments, item) {
|
|
2511
2770
|
if (!item || typeof item.path !== "string")
|
|
2512
2771
|
return;
|
|
@@ -3112,6 +3371,9 @@ export class FeishuBot {
|
|
|
3112
3371
|
async handleStopCommand(chatId, messageId) {
|
|
3113
3372
|
const sessionKey = this.getSessionKey(chatId);
|
|
3114
3373
|
try {
|
|
3374
|
+
// Bump the stop generation FIRST so any in-flight auto-retry loop sees the
|
|
3375
|
+
// cancellation on its next iteration and stops retrying immediately.
|
|
3376
|
+
this.stopEpoch.set(chatId, (this.stopEpoch.get(chatId) || 0) + 1);
|
|
3115
3377
|
// Abort all active runs for this session (chat.abort with no runId).
|
|
3116
3378
|
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
3117
3379
|
// Force-unlock the busy gate so queued messages can be processed.
|