openclaw-lark-multi-agent 1.4.7 → 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 +35 -16
- package/README.zh-CN.md +17 -1
- package/dist/cli.js +4 -4
- package/dist/feishu-bot.d.ts +4 -0
- package/dist/feishu-bot.js +151 -67
- package/dist/live-status.d.ts +11 -0
- package/dist/live-status.js +58 -4
- package/dist/openclaw-client.d.ts +26 -9
- package/dist/openclaw-client.js +280 -62
- 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 +28 -21
- package/plugins/lma-steer/openclaw.plugin.json +57 -2
- package/plugins/lma-steer/package.json +4 -2
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
|
}
|
|
@@ -1228,39 +1403,41 @@ export class OpenClawClient {
|
|
|
1228
1403
|
return this.rpc("chat.inject", { ...params, sessionKey: this.canonicalSessionKey(params.sessionKey) }, 10000);
|
|
1229
1404
|
}
|
|
1230
1405
|
/**
|
|
1231
|
-
* Submit a mid-run message through
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1406
|
+
* Submit a mid-run message through the lma-steer plugin.
|
|
1407
|
+
*
|
|
1408
|
+
* OpenClaw's public `chat.send { queueMode: "steer" }` returns `started` even
|
|
1409
|
+
* when it falls through to a later ordinary run; it therefore cannot tell the
|
|
1410
|
+
* bridge whether THIS message entered the active run. The plugin resolves the
|
|
1411
|
+
* active embedded run and reports `steered` only when it actually queued there.
|
|
1412
|
+
* A matching session.message remains the final consumption confirmation.
|
|
1413
|
+
*
|
|
1414
|
+
* If the plugin is missing, stale, or rejects the injection, return unavailable
|
|
1415
|
+
* and let LMA's durable trigger run normally after the current turn. Never use
|
|
1416
|
+
* the ambiguous public admission as a realtime-insertion success signal.
|
|
1238
1417
|
*/
|
|
1239
1418
|
async steer(sessionKey, text, displayText) {
|
|
1240
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);
|
|
1241
1422
|
try {
|
|
1242
|
-
const res = await this.rpc("
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1423
|
+
const res = await this.rpc("lma.steer", { sessionKey: key, text }, 10000);
|
|
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)?.();
|
|
1429
|
+
if (res?.status && res.status !== "no_active_run") {
|
|
1430
|
+
console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
|
|
1431
|
+
}
|
|
1432
|
+
cancelPending();
|
|
1251
1433
|
return { status: "unavailable" };
|
|
1252
1434
|
}
|
|
1253
|
-
|
|
1254
|
-
// by handleSteerConsumption after the canonical transcript event arrives.
|
|
1255
|
-
const cancelPending = this.registerPendingSteer(key, text, displayText);
|
|
1256
|
-
return {
|
|
1257
|
-
status: "steered",
|
|
1258
|
-
runId: typeof res?.runId === "string" ? res.runId : undefined,
|
|
1259
|
-
cancelPending,
|
|
1260
|
-
};
|
|
1435
|
+
return { status: "steered", cancelPending };
|
|
1261
1436
|
}
|
|
1262
1437
|
catch (err) {
|
|
1263
|
-
|
|
1438
|
+
cancelPending();
|
|
1439
|
+
const message = err?.message || String(err);
|
|
1440
|
+
console.warn(`[OpenClaw] lma.steer unavailable for ${key.slice(-8)}: ${message}`);
|
|
1264
1441
|
return { status: "unavailable" };
|
|
1265
1442
|
}
|
|
1266
1443
|
}
|
|
@@ -1398,6 +1575,37 @@ export class OpenClawClient {
|
|
|
1398
1575
|
/**
|
|
1399
1576
|
* Get session info (model, tokens, etc.) for status display.
|
|
1400
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
|
+
}
|
|
1401
1609
|
async getSessionInfo(sessionKey) {
|
|
1402
1610
|
return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
|
|
1403
1611
|
}
|
|
@@ -1641,6 +1849,7 @@ export class OpenClawClient {
|
|
|
1641
1849
|
const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
|
|
1642
1850
|
this.clearVerboseAssistantState(sk);
|
|
1643
1851
|
let ownedRunId = "";
|
|
1852
|
+
let waitPaused = false;
|
|
1644
1853
|
try {
|
|
1645
1854
|
// Drop stale buffered events for this session before starting a new run.
|
|
1646
1855
|
// This prevents an old final text (e.g. previous "ok") from being consumed by
|
|
@@ -1672,7 +1881,11 @@ export class OpenClawClient {
|
|
|
1672
1881
|
this.progressCallbacks.set(fullSessionKey, params.onProgress);
|
|
1673
1882
|
void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
|
|
1674
1883
|
}
|
|
1675
|
-
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;
|
|
1676
1889
|
}
|
|
1677
1890
|
finally {
|
|
1678
1891
|
// OpenClaw can emit the final assistant session.message a moment after
|
|
@@ -1681,7 +1894,10 @@ export class OpenClawClient {
|
|
|
1681
1894
|
// are unaffected because they do not go through chatSend.
|
|
1682
1895
|
this.progressCallbacks.delete(sk);
|
|
1683
1896
|
this.progressCallbacks.delete(fullSessionKey);
|
|
1684
|
-
|
|
1897
|
+
if (waitPaused)
|
|
1898
|
+
this.releasePausedWait(ownedRunId);
|
|
1899
|
+
else
|
|
1900
|
+
this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
|
|
1685
1901
|
}
|
|
1686
1902
|
}
|
|
1687
1903
|
shouldInjectBridgeAttachmentHint(text) {
|
|
@@ -1733,6 +1949,7 @@ export class OpenClawClient {
|
|
|
1733
1949
|
onSendAttempt: params.onSendAttempt,
|
|
1734
1950
|
onSubmitted: params.onSubmitted,
|
|
1735
1951
|
onProgress: params.onProgress,
|
|
1952
|
+
onWaitPaused: params.onWaitPaused,
|
|
1736
1953
|
});
|
|
1737
1954
|
}
|
|
1738
1955
|
// Build context block + actual message in one chat.send, or write the
|
|
@@ -1769,6 +1986,7 @@ export class OpenClawClient {
|
|
|
1769
1986
|
onSendAttempt: params.onSendAttempt,
|
|
1770
1987
|
onSubmitted: params.onSubmitted,
|
|
1771
1988
|
onProgress: params.onProgress,
|
|
1989
|
+
onWaitPaused: params.onWaitPaused,
|
|
1772
1990
|
});
|
|
1773
1991
|
}
|
|
1774
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"
|
|
@@ -1,38 +1,45 @@
|
|
|
1
|
-
# lma-steer
|
|
1
|
+
# lma-steer
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
`lma-steer` provides observable mid-run insertion for
|
|
4
|
+
`openclaw-lark-multi-agent`.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
OpenClaw's public `chat.send { queueMode: "steer" }` acknowledges admission with
|
|
7
|
+
`started` even when the message falls through to a later ordinary run. That is
|
|
8
|
+
not enough for LMA to tell users whether a message actually entered the active
|
|
9
|
+
run. This plugin resolves the active embedded run and reports one of:
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
- `steered` — queued into the active embedded run;
|
|
12
|
+
- `no_active_run` — no active embedded run was found;
|
|
13
|
+
- `rejected` — the runtime refused queueing.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
acceptance and actual model consumption remain distinct.
|
|
15
|
+
LMA treats `steered` as provisional until the matching `session.message`
|
|
16
|
+
confirms transcript consumption. Any other outcome remains in LMA's durable
|
|
17
|
+
normal queue.
|
|
17
18
|
|
|
18
|
-
##
|
|
19
|
+
## Installation
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
requires `lma.steer`:
|
|
21
|
+
Realtime mid-run insertion requires this plugin:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
lma install-steer-plugin
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Restart the OpenClaw Gateway after installation.
|
|
28
|
-
|
|
27
|
+
Restart the OpenClaw Gateway after installation. LMA itself remains functional
|
|
28
|
+
without the plugin, but messages received during a run wait safely in the
|
|
29
|
+
normal queue.
|
|
29
30
|
|
|
30
|
-
##
|
|
31
|
+
## Gateway method
|
|
31
32
|
|
|
32
33
|
- method: `lma.steer`
|
|
33
34
|
- params: `{ sessionKey: string, text: string }`
|
|
34
35
|
- result: `{ status: "steered" | "no_active_run" | "rejected", sessionId?: string }`
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
the
|
|
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.
|