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.
@@ -175,6 +175,17 @@ export class OpenClawClient {
175
175
  data: { text: textParts.join("\n") },
176
176
  });
177
177
  }
178
+ else if (state === "error") {
179
+ // A chat-level error (e.g. "Request timed out before a response")
180
+ // must NOT be silently dropped — otherwise mid-run delta text gets
181
+ // surfaced as if it were a normal reply and the run neither errors
182
+ // nor retries. Record it so the collect loop can surface/handle it.
183
+ this.agentEvents.get(sk).push({
184
+ ...frame.payload,
185
+ stream: "chatError",
186
+ data: { error: frame.payload?.errorMessage || frame.payload?.error || "chat error" },
187
+ });
188
+ }
178
189
  }
179
190
  else {
180
191
  this.agentEvents.get(sk).push(frame.payload);
@@ -835,6 +846,35 @@ export class OpenClawClient {
835
846
  reject(new Error(`Agent error: ${errText}`));
836
847
  return;
837
848
  }
849
+ if (ev.stream === "chatError") {
850
+ // Chat-level error (most commonly a request timeout). Treat it like
851
+ // a lifecycle error: recoverable ones defer (agent may auto-recover
852
+ // and still produce a real final); otherwise reject so the bridge
853
+ // surfaces the failure / lets auto-retry kick in — instead of
854
+ // surfacing whatever mid-run delta text happened to be collected.
855
+ const errText = String(ev.data?.error || "chat error");
856
+ if (this.isRecoverableAgentError(errText)) {
857
+ pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
858
+ resetIdleTimer();
859
+ console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
860
+ bucket.splice(i, 1);
861
+ continue;
862
+ }
863
+ clearTimeout(idleTimer);
864
+ clearInterval(poller);
865
+ if (chatFinalTimer)
866
+ clearTimeout(chatFinalTimer);
867
+ if (lifecycleEndTimer)
868
+ clearTimeout(lifecycleEndTimer);
869
+ // Abort the (possibly still-running) backgrounded run before we
870
+ // reject, so an auto-retry probe does not race a zombie run on the
871
+ // same session. Idempotent/best-effort.
872
+ this.abortChat(targetSessionKey || sessionKey, evRunId || runId).catch((abortErr) => {
873
+ console.warn(`[OpenClaw] abort after chat error failed:`, abortErr.message);
874
+ });
875
+ reject(new Error(`Agent error: ${errText}`));
876
+ return;
877
+ }
838
878
  }
839
879
  }
840
880
  }, 50);
@@ -0,0 +1,39 @@
1
+ export type ToolTrimResult = {
2
+ ok: boolean;
3
+ reason?: string;
4
+ /** Bytes before / after, and line counts, for reporting. */
5
+ bytesBefore?: number;
6
+ bytesAfter?: number;
7
+ linesBefore?: number;
8
+ linesAfter?: number;
9
+ removedToolResults?: number;
10
+ strippedToolCalls?: number;
11
+ removedEmptyAssistants?: number;
12
+ keptRecentToolCalls?: number;
13
+ backupPath?: string;
14
+ };
15
+ /**
16
+ * Resolve the on-disk OpenClaw transcript file for a session.
17
+ *
18
+ * Session key looks like `agent:<agentId>:<label>` (e.g.
19
+ * `agent:main:lma-claude-oc_xxx`). The transcript lives at
20
+ * `<openclawHome>/agents/<agentId>/sessions/<sessionId>.jsonl`.
21
+ */
22
+ export declare function resolveSessionFilePath(sessionKey: string, sessionId: string, openclawHome?: string): string | null;
23
+ /**
24
+ * "Tool-trim" compaction: rewrite a session transcript in place, removing the
25
+ * tool-call noise while keeping the conversation intact:
26
+ * - drop every toolResult message entirely;
27
+ * - strip the `toolCall` parts from assistant messages but KEEP their text
28
+ * (the model's "thinking out loud while acting"); if an assistant message
29
+ * had only tool calls and no text, drop the whole line;
30
+ * - fix `stopReason: "toolUse"` -> "stop" on messages that no longer have a
31
+ * tool call, so the loader is not confused.
32
+ *
33
+ * This does NOT call any model, so it works no matter how large the session is
34
+ * (unlike OpenClaw's LLM-summary compaction, which itself fails to fit an
35
+ * oversized session into the model). A backup is always written first.
36
+ *
37
+ * Typical reduction is ~70-90% for tool-heavy sessions.
38
+ */
39
+ export declare function toolTrimCompactFile(filePath: string, keepRecentToolCalls?: number): ToolTrimResult;
@@ -0,0 +1,176 @@
1
+ import { existsSync, copyFileSync, readFileSync, writeFileSync, renameSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ /**
5
+ * Resolve the on-disk OpenClaw transcript file for a session.
6
+ *
7
+ * Session key looks like `agent:<agentId>:<label>` (e.g.
8
+ * `agent:main:lma-claude-oc_xxx`). The transcript lives at
9
+ * `<openclawHome>/agents/<agentId>/sessions/<sessionId>.jsonl`.
10
+ */
11
+ export function resolveSessionFilePath(sessionKey, sessionId, openclawHome) {
12
+ if (!sessionId)
13
+ return null;
14
+ const home = openclawHome
15
+ || process.env.OPENCLAW_HOME
16
+ || process.env.OPENCLAW_CONFIG_DIR
17
+ || join(homedir(), ".openclaw");
18
+ // agentId is the second segment of agent:<agentId>:<label>; default to "main".
19
+ const parts = sessionKey.split(":");
20
+ const agentId = parts.length >= 3 && parts[0] === "agent" ? parts[1] : "main";
21
+ const file = join(home, "agents", agentId, "sessions", `${sessionId}.jsonl`);
22
+ return existsSync(file) ? file : null;
23
+ }
24
+ /**
25
+ * "Tool-trim" compaction: rewrite a session transcript in place, removing the
26
+ * tool-call noise while keeping the conversation intact:
27
+ * - drop every toolResult message entirely;
28
+ * - strip the `toolCall` parts from assistant messages but KEEP their text
29
+ * (the model's "thinking out loud while acting"); if an assistant message
30
+ * had only tool calls and no text, drop the whole line;
31
+ * - fix `stopReason: "toolUse"` -> "stop" on messages that no longer have a
32
+ * tool call, so the loader is not confused.
33
+ *
34
+ * This does NOT call any model, so it works no matter how large the session is
35
+ * (unlike OpenClaw's LLM-summary compaction, which itself fails to fit an
36
+ * oversized session into the model). A backup is always written first.
37
+ *
38
+ * Typical reduction is ~70-90% for tool-heavy sessions.
39
+ */
40
+ export function toolTrimCompactFile(filePath, keepRecentToolCalls = 3) {
41
+ if (!existsSync(filePath))
42
+ return { ok: false, reason: "transcript file not found" };
43
+ const original = readFileSync(filePath, "utf8");
44
+ const lines = original.split("\n");
45
+ const bytesBefore = Buffer.byteLength(original, "utf8");
46
+ // First pass: collect every toolCall id in order, so we can keep the most
47
+ // recent N of them (and their matching toolResults) intact. Recent tool calls
48
+ // are often still relevant to what the agent is doing next.
49
+ const toolCallIds = [];
50
+ for (const line of lines) {
51
+ if (line.trim() === "")
52
+ continue;
53
+ let d;
54
+ try {
55
+ d = JSON.parse(line);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (d?.type !== "message")
61
+ continue;
62
+ const msg = d.message;
63
+ if (msg?.role === "assistant" && Array.isArray(msg.content)) {
64
+ for (const p of msg.content) {
65
+ if (p && typeof p === "object" && p.type === "toolCall" && p.id)
66
+ toolCallIds.push(String(p.id));
67
+ }
68
+ }
69
+ }
70
+ const keepN = Math.max(0, Math.floor(keepRecentToolCalls));
71
+ const keepIds = new Set(keepN > 0 ? toolCallIds.slice(-keepN) : []);
72
+ const out = [];
73
+ let removedToolResults = 0;
74
+ let strippedToolCalls = 0;
75
+ let removedEmptyAssistants = 0;
76
+ let keptRecentToolCalls = 0;
77
+ let linesBefore = 0;
78
+ for (const line of lines) {
79
+ if (line.trim() === "") {
80
+ continue;
81
+ } // drop blank lines; we re-join with \n
82
+ linesBefore++;
83
+ let d;
84
+ try {
85
+ d = JSON.parse(line);
86
+ }
87
+ catch {
88
+ out.push(line);
89
+ continue;
90
+ }
91
+ if (d?.type !== "message") {
92
+ out.push(line);
93
+ continue;
94
+ }
95
+ const msg = d.message;
96
+ const role = msg?.role;
97
+ if (role === "toolResult") {
98
+ // Keep the toolResult only if its toolCall is in the recent keep-set.
99
+ if (msg?.toolCallId && keepIds.has(String(msg.toolCallId))) {
100
+ out.push(line);
101
+ continue;
102
+ }
103
+ removedToolResults++;
104
+ continue;
105
+ }
106
+ if (role === "assistant" && Array.isArray(msg?.content)) {
107
+ const content = msg.content;
108
+ const hasToolCall = content.some((p) => p && typeof p === "object" && p.type === "toolCall");
109
+ if (hasToolCall) {
110
+ // Keep toolCalls that are in the recent keep-set; strip the rest.
111
+ const kept = content.filter((p) => {
112
+ if (!p || typeof p !== "object")
113
+ return false;
114
+ if (p.type !== "toolCall")
115
+ return true;
116
+ return p.id && keepIds.has(String(p.id));
117
+ });
118
+ const keptToolCall = kept.some((p) => p.type === "toolCall");
119
+ const hasText = kept.some((p) => p.type === "text" && typeof p.text === "string" && p.text.trim() !== "");
120
+ if (!keptToolCall && !hasText) {
121
+ removedEmptyAssistants++;
122
+ continue;
123
+ }
124
+ if (keptToolCall) {
125
+ keptRecentToolCalls++;
126
+ // stopReason stays toolUse since a toolCall remains.
127
+ if (kept.length !== content.length)
128
+ msg.content = kept;
129
+ out.push(JSON.stringify(d));
130
+ continue;
131
+ }
132
+ // All toolCalls stripped: keep text only, fix stopReason.
133
+ msg.content = kept;
134
+ if (msg.stopReason === "toolUse")
135
+ msg.stopReason = "stop";
136
+ strippedToolCalls++;
137
+ out.push(JSON.stringify(d));
138
+ continue;
139
+ }
140
+ }
141
+ out.push(line);
142
+ }
143
+ // Safety: verify every output line is valid JSON before committing.
144
+ for (const l of out) {
145
+ try {
146
+ JSON.parse(l);
147
+ }
148
+ catch {
149
+ return { ok: false, reason: "produced invalid JSON; aborted" };
150
+ }
151
+ }
152
+ if (removedToolResults === 0 && strippedToolCalls === 0 && removedEmptyAssistants === 0) {
153
+ return { ok: true, reason: "no tool content to trim", bytesBefore, bytesAfter: bytesBefore, linesBefore, linesAfter: out.length, keptRecentToolCalls };
154
+ }
155
+ // Always back up first.
156
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
157
+ const backupPath = `${filePath}.bak-tooltrim-${stamp}`;
158
+ copyFileSync(filePath, backupPath);
159
+ // Atomic write: temp file + rename.
160
+ const body = out.join("\n") + "\n";
161
+ const tmp = `${filePath}.tmp-tooltrim`;
162
+ writeFileSync(tmp, body, "utf8");
163
+ renameSync(tmp, filePath);
164
+ return {
165
+ ok: true,
166
+ bytesBefore,
167
+ bytesAfter: Buffer.byteLength(body, "utf8"),
168
+ linesBefore,
169
+ linesAfter: out.length,
170
+ removedToolResults,
171
+ strippedToolCalls,
172
+ removedEmptyAssistants,
173
+ keptRecentToolCalls,
174
+ backupPath,
175
+ };
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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": {