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.
- package/README.md +67 -2
- package/dist/compact-progress.d.ts +75 -0
- package/dist/compact-progress.js +177 -0
- package/dist/feishu-bot.d.ts +66 -0
- package/dist/feishu-bot.js +487 -14
- package/dist/live-status.js +13 -5
- package/dist/openclaw-client.js +40 -0
- package/dist/session-file-compactor.d.ts +39 -0
- package/dist/session-file-compactor.js +176 -0
- package/package.json +1 -1
package/dist/openclaw-client.js
CHANGED
|
@@ -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
|
+
}
|