openclaw-lark-multi-agent 1.4.8 → 1.4.9
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 +18 -0
- package/README.zh-CN.md +16 -0
- package/dist/feishu-bot.d.ts +4 -0
- package/dist/feishu-bot.js +144 -32
- package/dist/live-status.d.ts +11 -0
- package/dist/live-status.js +58 -4
- package/dist/openclaw-client.d.ts +15 -2
- package/dist/openclaw-client.js +261 -38
- package/dist/session-status.d.ts +18 -0
- package/dist/session-status.js +39 -0
- package/package.json +4 -4
- package/plugins/lma-steer/README.md +10 -0
- package/plugins/lma-steer/openclaw.plugin.json +57 -2
- package/plugins/lma-steer/package.json +4 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SessionWaitPaused, type SessionRuntimeStatus } from "./session-status.js";
|
|
1
2
|
import { OpenClawConfig } from "./config.js";
|
|
2
3
|
import { ChatMessage } from "./message-store.js";
|
|
3
4
|
export type ProgressEvent = {
|
|
@@ -15,6 +16,9 @@ export type ProgressEvent = {
|
|
|
15
16
|
kind: "lifecycle";
|
|
16
17
|
text: string;
|
|
17
18
|
};
|
|
19
|
+
export declare class InactiveRunObservation extends Error {
|
|
20
|
+
constructor();
|
|
21
|
+
}
|
|
18
22
|
export type ChatAttachment = {
|
|
19
23
|
type?: string;
|
|
20
24
|
mimeType?: string;
|
|
@@ -37,6 +41,8 @@ export declare class OpenClawClient {
|
|
|
37
41
|
private reconnectDelay;
|
|
38
42
|
private maxReconnectDelay;
|
|
39
43
|
private shouldReconnect;
|
|
44
|
+
/** Read-only reconciliation for a collector whose plugin reports no active run. */
|
|
45
|
+
private runStateChecks;
|
|
40
46
|
/** Callbacks for tool events (verbose mode) */
|
|
41
47
|
private toolEventCallbacks;
|
|
42
48
|
private lastToolNamesByItemId;
|
|
@@ -102,8 +108,9 @@ export declare class OpenClawClient {
|
|
|
102
108
|
* Collect agent reply by polling accumulated events.
|
|
103
109
|
* Matches by initial runId OR sessionKey to handle multi-turn tool calling
|
|
104
110
|
* where OpenClaw creates new runIds for each tool-call round.
|
|
105
|
-
*
|
|
106
|
-
*
|
|
111
|
+
* Existing event/anchor matching is retained. After 30 minutes of silence,
|
|
112
|
+
* reconcile the original run instead of aborting or treating a draft as final.
|
|
113
|
+
* Explicit terminal/error/disconnect paths keep their existing behavior.
|
|
107
114
|
*/
|
|
108
115
|
private collectReply;
|
|
109
116
|
/**
|
|
@@ -179,6 +186,10 @@ export declare class OpenClawClient {
|
|
|
179
186
|
/**
|
|
180
187
|
* Get session info (model, tokens, etc.) for status display.
|
|
181
188
|
*/
|
|
189
|
+
/** Stop claiming an exact run's final output after observation has paused. */
|
|
190
|
+
releasePausedWait(runId: string): void;
|
|
191
|
+
getSessionRuntimeStatus(sessionKey: string): Promise<SessionRuntimeStatus>;
|
|
192
|
+
private classifyWaitTimeout;
|
|
182
193
|
getSessionInfo(sessionKey: string): Promise<any>;
|
|
183
194
|
/**
|
|
184
195
|
* Ensure session is using the expected model. If not, patch it back.
|
|
@@ -222,6 +233,7 @@ export declare class OpenClawClient {
|
|
|
222
233
|
/** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
|
|
223
234
|
onSubmitted?: (runId: string) => void | Promise<void>;
|
|
224
235
|
onProgress?: (event: ProgressEvent) => void | Promise<void>;
|
|
236
|
+
onWaitPaused?: (notice: SessionWaitPaused) => void | Promise<void>;
|
|
225
237
|
}): Promise<string>;
|
|
226
238
|
private shouldInjectBridgeAttachmentHint;
|
|
227
239
|
private bridgeAttachmentHint;
|
|
@@ -255,6 +267,7 @@ export declare class OpenClawClient {
|
|
|
255
267
|
/** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
|
|
256
268
|
onSubmitted?: (runId: string) => void | Promise<void>;
|
|
257
269
|
onProgress?: (event: ProgressEvent) => void | Promise<void>;
|
|
270
|
+
onWaitPaused?: (notice: SessionWaitPaused) => void | Promise<void>;
|
|
258
271
|
}): Promise<string>;
|
|
259
272
|
private formatContextLines;
|
|
260
273
|
private writeContextSyncFile;
|
package/dist/openclaw-client.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
+
import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
2
3
|
import { randomUUID } from "crypto";
|
|
3
4
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
4
5
|
import { basename, extname, join, resolve, sep } from "path";
|
|
5
6
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
7
|
+
export class InactiveRunObservation extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super("Gateway 确认当前会话已空闲,但旧运行结果未能确认;追加消息将继续处理,未停止任何后台任务。");
|
|
10
|
+
this.name = "InactiveRunObservation";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
6
13
|
export const GATEWAY_PROTOCOL_MIN = 4;
|
|
7
14
|
export const GATEWAY_PROTOCOL_MAX = 4;
|
|
8
15
|
const REQUIRED_GATEWAY_METHODS = [
|
|
@@ -38,6 +45,8 @@ export class OpenClawClient {
|
|
|
38
45
|
reconnectDelay = 1000;
|
|
39
46
|
maxReconnectDelay = 30000;
|
|
40
47
|
shouldReconnect = true;
|
|
48
|
+
/** Read-only reconciliation for a collector whose plugin reports no active run. */
|
|
49
|
+
runStateChecks = new Map();
|
|
41
50
|
/** Callbacks for tool events (verbose mode) */
|
|
42
51
|
toolEventCallbacks = new Map();
|
|
43
52
|
lastToolNamesByItemId = new Map();
|
|
@@ -689,10 +698,12 @@ export class OpenClawClient {
|
|
|
689
698
|
* Collect agent reply by polling accumulated events.
|
|
690
699
|
* Matches by initial runId OR sessionKey to handle multi-turn tool calling
|
|
691
700
|
* where OpenClaw creates new runIds for each tool-call round.
|
|
692
|
-
*
|
|
693
|
-
*
|
|
701
|
+
* Existing event/anchor matching is retained. After 30 minutes of silence,
|
|
702
|
+
* reconcile the original run instead of aborting or treating a draft as final.
|
|
703
|
+
* Explicit terminal/error/disconnect paths keep their existing behavior.
|
|
694
704
|
*/
|
|
695
|
-
collectReply(runId, timeoutMs =
|
|
705
|
+
collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
|
|
706
|
+
let cleanupObservation = () => { };
|
|
696
707
|
return new Promise((resolve, reject) => {
|
|
697
708
|
// Whether we were connected when the run started. We only treat a later
|
|
698
709
|
// disconnect as a mid-run gateway drop (fail fast) if we began connected;
|
|
@@ -727,37 +738,111 @@ export class OpenClawClient {
|
|
|
727
738
|
return actual === expected || actual.includes(expected) || expected.includes(actual);
|
|
728
739
|
};
|
|
729
740
|
let idleTimer;
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
741
|
+
let observationClosed = false;
|
|
742
|
+
let checkingState = false;
|
|
743
|
+
let lastStateCheckAt = -Infinity;
|
|
744
|
+
let quietNotified = false;
|
|
745
|
+
let decidingTimeout = false;
|
|
746
|
+
let waitNoticeSent = false;
|
|
747
|
+
let foregroundPaused = false;
|
|
748
|
+
let foregroundWaitTimer;
|
|
749
|
+
const handleTimeoutResult = async (error) => {
|
|
750
|
+
if (observationClosed)
|
|
751
|
+
return;
|
|
752
|
+
if (chatFinalText.trim()) {
|
|
753
|
+
finish(chatFinalText);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (error instanceof SessionWaitPaused && options?.onWaitPaused) {
|
|
757
|
+
// Foreground notice, background observation. Keep the original queue
|
|
758
|
+
// owner/progress path so later input still steers into the same run.
|
|
759
|
+
foregroundPaused = true;
|
|
760
|
+
if (foregroundWaitTimer)
|
|
761
|
+
clearTimeout(foregroundWaitTimer);
|
|
762
|
+
if (!waitNoticeSent) {
|
|
763
|
+
waitNoticeSent = true;
|
|
764
|
+
try {
|
|
765
|
+
await options.onWaitPaused(error);
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
console.warn("[OpenClaw] wait notice delivery pending; observation retained");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
decidingTimeout = false;
|
|
772
|
+
if (!observationClosed)
|
|
773
|
+
resetIdleTimer(60_000, false);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
reject(error);
|
|
777
|
+
};
|
|
778
|
+
const checkRunState = async () => {
|
|
779
|
+
if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
|
|
780
|
+
return;
|
|
781
|
+
checkingState = true;
|
|
782
|
+
lastStateCheckAt = Date.now();
|
|
783
|
+
try {
|
|
784
|
+
const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
|
|
785
|
+
if (observationClosed || snapshot?.runId !== runId || snapshot?.pendingError === true
|
|
786
|
+
|| snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
|
|
787
|
+
return;
|
|
788
|
+
// A bare wait timeout is not an execution timeout. Require a recorded
|
|
789
|
+
// terminal time; never infer failure from stopReason or replay flags.
|
|
790
|
+
const ended = snapshot.endedAt;
|
|
791
|
+
if (typeof ended !== "number" || !Number.isFinite(ended) || ended <= 0
|
|
792
|
+
|| (typeof snapshot.startedAt === "number" && ended < snapshot.startedAt))
|
|
793
|
+
return;
|
|
794
|
+
if (snapshot.status === "ok" && snapshot.yielded !== true) {
|
|
795
|
+
const terminal = snapshot.terminalReply;
|
|
796
|
+
const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
|
|
797
|
+
? terminal.text : terminal?.disposition === "silent" ? "NO_REPLY"
|
|
798
|
+
: this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
799
|
+
resolve(visible || "NO_REPLY");
|
|
750
800
|
}
|
|
751
|
-
else if (
|
|
752
|
-
|
|
801
|
+
else if (snapshot.status === "error" || snapshot.status === "timeout") {
|
|
802
|
+
if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
|
|
803
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
804
|
+
if (!observationClosed && status.running) {
|
|
805
|
+
await handleTimeoutResult(new SessionWaitPaused(status, true));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (observationClosed)
|
|
810
|
+
return;
|
|
811
|
+
reject(new Error(`Agent error: ${typeof snapshot.error === "string" && snapshot.error.trim()
|
|
812
|
+
? snapshot.error : `Gateway 已确认运行以 ${snapshot.status} 结束`}`));
|
|
753
813
|
}
|
|
754
|
-
|
|
755
|
-
|
|
814
|
+
}
|
|
815
|
+
catch { /* Unavailable evidence means keep waiting, never abort/replay. */ }
|
|
816
|
+
finally {
|
|
817
|
+
checkingState = false;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
|
|
821
|
+
if (activity) {
|
|
822
|
+
if (!foregroundPaused)
|
|
823
|
+
waitNoticeSent = false;
|
|
824
|
+
quietNotified = false;
|
|
825
|
+
}
|
|
826
|
+
if (idleTimer)
|
|
827
|
+
clearTimeout(idleTimer);
|
|
828
|
+
if (observationClosed)
|
|
829
|
+
return;
|
|
830
|
+
idleTimer = setTimeout(async () => {
|
|
831
|
+
if (!quietNotified) {
|
|
832
|
+
quietNotified = true;
|
|
833
|
+
console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
|
|
756
834
|
}
|
|
757
|
-
|
|
758
|
-
|
|
835
|
+
await checkRunState();
|
|
836
|
+
if (!observationClosed) {
|
|
837
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
838
|
+
if (!observationClosed && status.running) {
|
|
839
|
+
await handleTimeoutResult(new SessionWaitPaused(status));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
759
842
|
}
|
|
760
|
-
|
|
843
|
+
if (!observationClosed)
|
|
844
|
+
resetIdleTimer(60_000, false);
|
|
845
|
+
}, delayMs);
|
|
761
846
|
};
|
|
762
847
|
const summarizeActivity = (ev) => {
|
|
763
848
|
if (ev.stream === "item") {
|
|
@@ -1041,7 +1126,14 @@ export class OpenClawClient {
|
|
|
1041
1126
|
return;
|
|
1042
1127
|
}
|
|
1043
1128
|
if (state !== "working") {
|
|
1044
|
-
|
|
1129
|
+
if (isWaitTimeout(failureText)) {
|
|
1130
|
+
if (!decidingTimeout) {
|
|
1131
|
+
decidingTimeout = true;
|
|
1132
|
+
void this.classifyWaitTimeout(failureText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
else
|
|
1136
|
+
finish(failureText);
|
|
1045
1137
|
return;
|
|
1046
1138
|
}
|
|
1047
1139
|
console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
|
|
@@ -1076,6 +1168,14 @@ export class OpenClawClient {
|
|
|
1076
1168
|
console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
|
|
1077
1169
|
return;
|
|
1078
1170
|
}
|
|
1171
|
+
if (isWaitTimeout(errText)) {
|
|
1172
|
+
bucket.splice(i, 1);
|
|
1173
|
+
if (!decidingTimeout) {
|
|
1174
|
+
decidingTimeout = true;
|
|
1175
|
+
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1176
|
+
}
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1079
1179
|
clearTimeout(idleTimer);
|
|
1080
1180
|
clearInterval(poller);
|
|
1081
1181
|
if (chatFinalTimer)
|
|
@@ -1101,7 +1201,13 @@ export class OpenClawClient {
|
|
|
1101
1201
|
if (!genericChatErrorTimer) {
|
|
1102
1202
|
genericChatErrorTimer = setTimeout(() => {
|
|
1103
1203
|
genericChatErrorTimer = null;
|
|
1104
|
-
void this.resolveTerminalChatError(evRunId || runId).then((detail) => {
|
|
1204
|
+
void this.resolveTerminalChatError(evRunId || runId, targetSessionKey || sessionKey).then((detail) => {
|
|
1205
|
+
if (observationClosed)
|
|
1206
|
+
return;
|
|
1207
|
+
if (chatFinalText.trim()) {
|
|
1208
|
+
finish(chatFinalText);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1105
1211
|
clearTimeout(idleTimer);
|
|
1106
1212
|
clearInterval(poller);
|
|
1107
1213
|
if (chatFinalTimer)
|
|
@@ -1109,7 +1215,7 @@ export class OpenClawClient {
|
|
|
1109
1215
|
if (lifecycleEndTimer)
|
|
1110
1216
|
clearTimeout(lifecycleEndTimer);
|
|
1111
1217
|
reject(new Error(`Agent error: ${detail}`));
|
|
1112
|
-
});
|
|
1218
|
+
}, error => { void handleTimeoutResult(error); });
|
|
1113
1219
|
}, 350);
|
|
1114
1220
|
}
|
|
1115
1221
|
continue;
|
|
@@ -1121,6 +1227,14 @@ export class OpenClawClient {
|
|
|
1121
1227
|
bucket.splice(i, 1);
|
|
1122
1228
|
continue;
|
|
1123
1229
|
}
|
|
1230
|
+
if (isWaitTimeout(errText)) {
|
|
1231
|
+
bucket.splice(i, 1);
|
|
1232
|
+
if (!decidingTimeout) {
|
|
1233
|
+
decidingTimeout = true;
|
|
1234
|
+
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1235
|
+
}
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1124
1238
|
clearTimeout(idleTimer);
|
|
1125
1239
|
clearInterval(poller);
|
|
1126
1240
|
if (chatFinalTimer)
|
|
@@ -1141,7 +1255,61 @@ export class OpenClawClient {
|
|
|
1141
1255
|
}
|
|
1142
1256
|
}
|
|
1143
1257
|
}, 50);
|
|
1144
|
-
|
|
1258
|
+
const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
|
|
1259
|
+
const checkAfterNoActiveRun = async () => {
|
|
1260
|
+
await checkRunState();
|
|
1261
|
+
if (observationClosed || !observationKey)
|
|
1262
|
+
return;
|
|
1263
|
+
try {
|
|
1264
|
+
// Used only after the plugin reports no active run. Missing terminal
|
|
1265
|
+
// history is not failure, but an explicit idle session must not keep
|
|
1266
|
+
// a phantom queue owner blocking this newly-arrived user message.
|
|
1267
|
+
const history = await this.rpc("chat.history", { sessionKey: observationKey, limit: 1 }, 2500);
|
|
1268
|
+
if (!observationClosed && history?.sessionKey === observationKey && history?.sessionInfo?.hasActiveRun === false) {
|
|
1269
|
+
reject(new InactiveRunObservation());
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
catch { /* Unknown activity cannot authorize releasing an active owner. */ }
|
|
1273
|
+
};
|
|
1274
|
+
if (observationKey)
|
|
1275
|
+
this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
|
|
1276
|
+
// Fixed foreground budget: tool/assistant activity must not keep a card
|
|
1277
|
+
// refreshing indefinitely. The original result observer stays alive.
|
|
1278
|
+
if (options?.onWaitPaused) {
|
|
1279
|
+
foregroundWaitTimer = setTimeout(async () => {
|
|
1280
|
+
if (observationClosed || foregroundPaused)
|
|
1281
|
+
return;
|
|
1282
|
+
await checkRunState();
|
|
1283
|
+
if (observationClosed || foregroundPaused)
|
|
1284
|
+
return;
|
|
1285
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
1286
|
+
if (!observationClosed && !foregroundPaused)
|
|
1287
|
+
await handleTimeoutResult(new SessionWaitPaused(status));
|
|
1288
|
+
}, timeoutMs);
|
|
1289
|
+
foregroundWaitTimer.unref?.();
|
|
1290
|
+
}
|
|
1291
|
+
// Bound local resources without cancelling an execution we cannot prove stuck.
|
|
1292
|
+
const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
|
|
1293
|
+
observationHorizon.unref?.();
|
|
1294
|
+
cleanupObservation = () => {
|
|
1295
|
+
observationClosed = true;
|
|
1296
|
+
clearTimeout(idleTimer);
|
|
1297
|
+
if (foregroundWaitTimer)
|
|
1298
|
+
clearTimeout(foregroundWaitTimer);
|
|
1299
|
+
clearTimeout(observationHorizon);
|
|
1300
|
+
clearInterval(poller);
|
|
1301
|
+
if (chatFinalTimer)
|
|
1302
|
+
clearTimeout(chatFinalTimer);
|
|
1303
|
+
if (lifecycleEndTimer)
|
|
1304
|
+
clearTimeout(lifecycleEndTimer);
|
|
1305
|
+
if (replayInvalidTimer)
|
|
1306
|
+
clearTimeout(replayInvalidTimer);
|
|
1307
|
+
if (genericChatErrorTimer)
|
|
1308
|
+
clearTimeout(genericChatErrorTimer);
|
|
1309
|
+
if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
|
|
1310
|
+
this.runStateChecks.delete(observationKey);
|
|
1311
|
+
};
|
|
1312
|
+
}).finally(() => cleanupObservation());
|
|
1145
1313
|
}
|
|
1146
1314
|
/**
|
|
1147
1315
|
* Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
|
|
@@ -1155,12 +1323,19 @@ export class OpenClawClient {
|
|
|
1155
1323
|
const t = (errText || "").trim().toLowerCase();
|
|
1156
1324
|
return t === "chat error" || t === "agent error: chat error";
|
|
1157
1325
|
}
|
|
1158
|
-
async resolveTerminalChatError(runId) {
|
|
1326
|
+
async resolveTerminalChatError(runId, sessionKey) {
|
|
1159
1327
|
try {
|
|
1160
1328
|
const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
|
|
1329
|
+
if (sessionKey && (snapshot?.status === "timeout" || isWaitTimeout(String(snapshot?.error || "")))) {
|
|
1330
|
+
const status = await this.getSessionRuntimeStatus(sessionKey);
|
|
1331
|
+
if (status.running)
|
|
1332
|
+
throw new SessionWaitPaused(status, true);
|
|
1333
|
+
}
|
|
1161
1334
|
return this.formatTerminalRunError(snapshot);
|
|
1162
1335
|
}
|
|
1163
1336
|
catch (err) {
|
|
1337
|
+
if (err instanceof SessionWaitPaused)
|
|
1338
|
+
throw err;
|
|
1164
1339
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1165
1340
|
return `OpenClaw 运行失败,但未返回具体原因${detail ? `(${detail})` : ""}`;
|
|
1166
1341
|
}
|
|
@@ -1242,18 +1417,25 @@ export class OpenClawClient {
|
|
|
1242
1417
|
*/
|
|
1243
1418
|
async steer(sessionKey, text, displayText) {
|
|
1244
1419
|
const key = this.canonicalSessionKey(sessionKey);
|
|
1420
|
+
// Consumption can arrive before the plugin RPC response at a tool boundary.
|
|
1421
|
+
const cancelPending = this.registerPendingSteer(key, text, displayText);
|
|
1245
1422
|
try {
|
|
1246
1423
|
const res = await this.rpc("lma.steer", { sessionKey: key, text }, 10000);
|
|
1247
1424
|
if (res?.status !== "steered") {
|
|
1425
|
+
// A locally busy collector can outlive the actual Gateway turn. Check
|
|
1426
|
+
// its exact terminal record now, rather than parking input for 30 min.
|
|
1427
|
+
if (res?.status === "no_active_run")
|
|
1428
|
+
await this.runStateChecks.get(key)?.();
|
|
1248
1429
|
if (res?.status && res.status !== "no_active_run") {
|
|
1249
1430
|
console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
|
|
1250
1431
|
}
|
|
1432
|
+
cancelPending();
|
|
1251
1433
|
return { status: "unavailable" };
|
|
1252
1434
|
}
|
|
1253
|
-
const cancelPending = this.registerPendingSteer(key, text, displayText);
|
|
1254
1435
|
return { status: "steered", cancelPending };
|
|
1255
1436
|
}
|
|
1256
1437
|
catch (err) {
|
|
1438
|
+
cancelPending();
|
|
1257
1439
|
const message = err?.message || String(err);
|
|
1258
1440
|
console.warn(`[OpenClaw] lma.steer unavailable for ${key.slice(-8)}: ${message}`);
|
|
1259
1441
|
return { status: "unavailable" };
|
|
@@ -1393,6 +1575,37 @@ export class OpenClawClient {
|
|
|
1393
1575
|
/**
|
|
1394
1576
|
* Get session info (model, tokens, etc.) for status display.
|
|
1395
1577
|
*/
|
|
1578
|
+
/** Stop claiming an exact run's final output after observation has paused. */
|
|
1579
|
+
releasePausedWait(runId) {
|
|
1580
|
+
if (!runId)
|
|
1581
|
+
return;
|
|
1582
|
+
const timer = this.ownedDeliveryRunTimers.get(runId);
|
|
1583
|
+
if (timer)
|
|
1584
|
+
clearTimeout(timer);
|
|
1585
|
+
this.ownedDeliveryRunTimers.delete(runId);
|
|
1586
|
+
this.ownedDeliveryRuns.delete(runId);
|
|
1587
|
+
}
|
|
1588
|
+
async getSessionRuntimeStatus(sessionKey) {
|
|
1589
|
+
let timer;
|
|
1590
|
+
try {
|
|
1591
|
+
const value = await Promise.race([
|
|
1592
|
+
this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, 1500),
|
|
1593
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("status lookup timeout")), 1500); }),
|
|
1594
|
+
]);
|
|
1595
|
+
return normalizeSessionRuntimeStatus(value);
|
|
1596
|
+
}
|
|
1597
|
+
catch {
|
|
1598
|
+
return normalizeSessionRuntimeStatus(undefined);
|
|
1599
|
+
}
|
|
1600
|
+
finally {
|
|
1601
|
+
if (timer)
|
|
1602
|
+
clearTimeout(timer);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
async classifyWaitTimeout(text, sessionKey) {
|
|
1606
|
+
const status = await this.getSessionRuntimeStatus(sessionKey).catch(() => normalizeSessionRuntimeStatus(undefined));
|
|
1607
|
+
return status.running ? new SessionWaitPaused(status, true) : new Error(`Agent error: ${text}`);
|
|
1608
|
+
}
|
|
1396
1609
|
async getSessionInfo(sessionKey) {
|
|
1397
1610
|
return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
|
|
1398
1611
|
}
|
|
@@ -1636,6 +1849,7 @@ export class OpenClawClient {
|
|
|
1636
1849
|
const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
|
|
1637
1850
|
this.clearVerboseAssistantState(sk);
|
|
1638
1851
|
let ownedRunId = "";
|
|
1852
|
+
let waitPaused = false;
|
|
1639
1853
|
try {
|
|
1640
1854
|
// Drop stale buffered events for this session before starting a new run.
|
|
1641
1855
|
// This prevents an old final text (e.g. previous "ok") from being consumed by
|
|
@@ -1667,7 +1881,11 @@ export class OpenClawClient {
|
|
|
1667
1881
|
this.progressCallbacks.set(fullSessionKey, params.onProgress);
|
|
1668
1882
|
void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
|
|
1669
1883
|
}
|
|
1670
|
-
return await this.collectReply(result.runId, params.timeoutMs ||
|
|
1884
|
+
return await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
|
|
1885
|
+
}
|
|
1886
|
+
catch (error) {
|
|
1887
|
+
waitPaused = error instanceof SessionWaitPaused;
|
|
1888
|
+
throw error;
|
|
1671
1889
|
}
|
|
1672
1890
|
finally {
|
|
1673
1891
|
// OpenClaw can emit the final assistant session.message a moment after
|
|
@@ -1676,7 +1894,10 @@ export class OpenClawClient {
|
|
|
1676
1894
|
// are unaffected because they do not go through chatSend.
|
|
1677
1895
|
this.progressCallbacks.delete(sk);
|
|
1678
1896
|
this.progressCallbacks.delete(fullSessionKey);
|
|
1679
|
-
|
|
1897
|
+
if (waitPaused)
|
|
1898
|
+
this.releasePausedWait(ownedRunId);
|
|
1899
|
+
else
|
|
1900
|
+
this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
|
|
1680
1901
|
}
|
|
1681
1902
|
}
|
|
1682
1903
|
shouldInjectBridgeAttachmentHint(text) {
|
|
@@ -1728,6 +1949,7 @@ export class OpenClawClient {
|
|
|
1728
1949
|
onSendAttempt: params.onSendAttempt,
|
|
1729
1950
|
onSubmitted: params.onSubmitted,
|
|
1730
1951
|
onProgress: params.onProgress,
|
|
1952
|
+
onWaitPaused: params.onWaitPaused,
|
|
1731
1953
|
});
|
|
1732
1954
|
}
|
|
1733
1955
|
// Build context block + actual message in one chat.send, or write the
|
|
@@ -1764,6 +1986,7 @@ export class OpenClawClient {
|
|
|
1764
1986
|
onSendAttempt: params.onSendAttempt,
|
|
1765
1987
|
onSubmitted: params.onSubmitted,
|
|
1766
1988
|
onProgress: params.onProgress,
|
|
1989
|
+
onWaitPaused: params.onWaitPaused,
|
|
1767
1990
|
});
|
|
1768
1991
|
}
|
|
1769
1992
|
formatContextLines(messages) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Frontend budget only; does not change Gateway execution limits. */
|
|
2
|
+
export declare const FOREGROUND_WAIT_MS: number;
|
|
3
|
+
export type SessionRuntimeStatus = {
|
|
4
|
+
status: string;
|
|
5
|
+
running: boolean;
|
|
6
|
+
checkedAt: number;
|
|
7
|
+
contextUsedTokens?: number;
|
|
8
|
+
contextLimitTokens?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const isWaitTimeout: (text: string) => boolean;
|
|
11
|
+
export declare function normalizeSessionRuntimeStatus(value: any): SessionRuntimeStatus;
|
|
12
|
+
/** Compact presentation only; never replaces the actual status/model values. */
|
|
13
|
+
export declare function formatSessionFooter(snapshot: SessionRuntimeStatus): string;
|
|
14
|
+
/** Local observation has ended, NOT an assertion that execution failed. */
|
|
15
|
+
export declare class SessionWaitPaused extends Error {
|
|
16
|
+
readonly snapshot: SessionRuntimeStatus;
|
|
17
|
+
constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean);
|
|
18
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Frontend budget only; does not change Gateway execution limits. */
|
|
2
|
+
export const FOREGROUND_WAIT_MS = 10 * 60_000;
|
|
3
|
+
export const isWaitTimeout = (text) => /\btimeout\b|timed\s*out|超时/i.test(text) && !/invalid|validation|schema|malformed|permission denied|forbidden|unauthorized|authentication failed|quota|rate limit|无效参数|参数错误|校验失败/i.test(text);
|
|
4
|
+
export function normalizeSessionRuntimeStatus(value) {
|
|
5
|
+
const session = value?.session || value?.sessionInfo;
|
|
6
|
+
const raw = typeof session?.status === "string" ? session.status.trim().toLowerCase() : "unknown";
|
|
7
|
+
const status = /^[a-z][a-z0-9 _|,-]{0,63}$/.test(raw) ? raw : "unknown";
|
|
8
|
+
const running = session?.hasActiveRun === true || status.split(/[|,]/).some((part) => part.trim() === "running");
|
|
9
|
+
// Same context counters as /status, not cumulative input/output billing.
|
|
10
|
+
// A missing/stale count must not be presented as an empty context.
|
|
11
|
+
const used = session?.totalTokens;
|
|
12
|
+
const limit = session?.contextTokens;
|
|
13
|
+
const contextUsedTokens = session?.totalTokensFresh !== false && typeof used === "number" && Number.isFinite(used) && used >= 0 ? used : undefined;
|
|
14
|
+
const contextLimitTokens = typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? limit : undefined;
|
|
15
|
+
return { status: running ? "running" : status, running, checkedAt: Date.now(),
|
|
16
|
+
...(contextUsedTokens !== undefined ? { contextUsedTokens } : {}),
|
|
17
|
+
...(contextLimitTokens !== undefined ? { contextLimitTokens } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Compact presentation only; never replaces the actual status/model values. */
|
|
21
|
+
export function formatSessionFooter(snapshot) {
|
|
22
|
+
const { contextUsedTokens: used, contextLimitTokens: limit } = snapshot;
|
|
23
|
+
if (used === undefined && limit === undefined)
|
|
24
|
+
return snapshot.status;
|
|
25
|
+
const usedK = used === undefined ? "?" : Math.round(used / 1000);
|
|
26
|
+
const limitK = limit === undefined ? "?" : Math.max(1, Math.round(limit / 1000));
|
|
27
|
+
return `${snapshot.status} · ${usedK}K/${limitK}K`;
|
|
28
|
+
}
|
|
29
|
+
/** Local observation has ended, NOT an assertion that execution failed. */
|
|
30
|
+
export class SessionWaitPaused extends Error {
|
|
31
|
+
snapshot;
|
|
32
|
+
constructor(snapshot, reportedTimeout = false) {
|
|
33
|
+
super(reportedTimeout
|
|
34
|
+
? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
|
|
35
|
+
: "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
|
|
36
|
+
this.snapshot = snapshot;
|
|
37
|
+
this.name = "SessionWaitPaused";
|
|
38
|
+
}
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-lark-multi-agent",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.9",
|
|
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": {
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"build:steer-plugin": "tsc -p plugins/lma-steer/tsconfig.json",
|
|
9
9
|
"start": "node dist/index.js",
|
|
10
10
|
"dev": "tsx src/index.ts",
|
|
11
|
-
"test": "vitest run",
|
|
12
|
-
"test:coverage": "vitest run --coverage",
|
|
11
|
+
"test": "vitest run --config tests/normal-flow-offline.config.ts",
|
|
12
|
+
"test:coverage": "vitest run --config tests/normal-flow-offline.config.ts --coverage",
|
|
13
13
|
"prepack": "npm run build",
|
|
14
14
|
"prepublishOnly": "npm test && npm pack --dry-run"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@larksuiteoapi/node-sdk": "^1.
|
|
17
|
+
"@larksuiteoapi/node-sdk": "^1.73.3",
|
|
18
18
|
"@types/ws": "^8.18.1",
|
|
19
19
|
"better-sqlite3": "^12.9.0",
|
|
20
20
|
"ws": "^8.20.0"
|
|
@@ -33,3 +33,13 @@ normal queue.
|
|
|
33
33
|
- method: `lma.steer`
|
|
34
34
|
- params: `{ sessionKey: string, text: string }`
|
|
35
35
|
- result: `{ status: "steered" | "no_active_run" | "rejected", sessionId?: string }`
|
|
36
|
+
|
|
37
|
+
## 0.1.1 compatibility refresh
|
|
38
|
+
|
|
39
|
+
This release retains the existing `lma.steer` runtime behavior. A synchronous
|
|
40
|
+
`steered` result is provisional; LMA waits for a matching transcript message
|
|
41
|
+
before reporting consumption. The experimental steer V2 and question service
|
|
42
|
+
are not part of this release. A previously installed strict `questionBridge`
|
|
43
|
+
configuration shape is accepted for upgrade compatibility only; the plugin
|
|
44
|
+
ignores it and does not enable question handling. Existing values need not be
|
|
45
|
+
removed or exposed during this update.
|