openclaw-lark-multi-agent 1.4.8 → 1.4.10
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 +147 -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 +336 -44
- package/dist/session-status.d.ts +19 -0
- package/dist/session-status.js +43 -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();
|
|
@@ -222,7 +231,9 @@ export class OpenClawClient {
|
|
|
222
231
|
this.agentEvents.get(sk).push({
|
|
223
232
|
...frame.payload,
|
|
224
233
|
stream: "chatFinal",
|
|
225
|
-
|
|
234
|
+
// Only the Gateway chat-final envelope confirms a handoff.
|
|
235
|
+
// Never infer it from tool names, arguments or raw tool output.
|
|
236
|
+
data: { text: textParts.join("\n"), gatewayYielded: frame.payload?.yielded === true },
|
|
226
237
|
});
|
|
227
238
|
}
|
|
228
239
|
else if (state === "error") {
|
|
@@ -458,6 +469,11 @@ export class OpenClawClient {
|
|
|
458
469
|
/** Render protocol-4 structured tool args/results without `[object Object]`.
|
|
459
470
|
* Prefer useful, non-secret fields and keep the fallback bounded. */
|
|
460
471
|
formatToolValue(value, toolName, phase) {
|
|
472
|
+
// sessions_yield.message is private resumed context. Do not mirror its
|
|
473
|
+
// arguments/results (including JSON strings) into process cards or verbose
|
|
474
|
+
// tool replies. This is redaction only, never evidence of a handoff.
|
|
475
|
+
if (toolName === "sessions_yield")
|
|
476
|
+
return "";
|
|
461
477
|
if (value === null || value === undefined || value === "")
|
|
462
478
|
return "";
|
|
463
479
|
if (typeof value === "string")
|
|
@@ -689,11 +705,13 @@ export class OpenClawClient {
|
|
|
689
705
|
* Collect agent reply by polling accumulated events.
|
|
690
706
|
* Matches by initial runId OR sessionKey to handle multi-turn tool calling
|
|
691
707
|
* where OpenClaw creates new runIds for each tool-call round.
|
|
692
|
-
*
|
|
693
|
-
*
|
|
708
|
+
* Existing event/anchor matching is retained. After 30 minutes of silence,
|
|
709
|
+
* reconcile the original run instead of aborting or treating a draft as final.
|
|
710
|
+
* Explicit terminal/error/disconnect paths keep their existing behavior.
|
|
694
711
|
*/
|
|
695
|
-
collectReply(runId, timeoutMs =
|
|
696
|
-
|
|
712
|
+
collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
|
|
713
|
+
let cleanupObservation = () => { };
|
|
714
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
697
715
|
// Whether we were connected when the run started. We only treat a later
|
|
698
716
|
// disconnect as a mid-run gateway drop (fail fast) if we began connected;
|
|
699
717
|
// this keeps unit tests that drive collectReply without a live socket from
|
|
@@ -702,6 +720,7 @@ export class OpenClawClient {
|
|
|
702
720
|
let text = "";
|
|
703
721
|
let chatDeltaText = "";
|
|
704
722
|
let chatFinalText = "";
|
|
723
|
+
let chatFinalSeen = false;
|
|
705
724
|
let transcriptAssistantText = "";
|
|
706
725
|
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
|
|
707
726
|
let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
|
|
@@ -727,37 +746,117 @@ export class OpenClawClient {
|
|
|
727
746
|
return actual === expected || actual.includes(expected) || expected.includes(actual);
|
|
728
747
|
};
|
|
729
748
|
let idleTimer;
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
749
|
+
let observationClosed = false;
|
|
750
|
+
// Close synchronously: a deferred yield notice must not overtake a final
|
|
751
|
+
// or error just because Promise.finally cleanup has not run yet.
|
|
752
|
+
const resolve = (value) => { observationClosed = true; resolveResult(value); };
|
|
753
|
+
const reject = (error) => { observationClosed = true; rejectResult(error); };
|
|
754
|
+
let checkingState = false;
|
|
755
|
+
let lastStateCheckAt = -Infinity;
|
|
756
|
+
let quietNotified = false;
|
|
757
|
+
let decidingTimeout = false;
|
|
758
|
+
let waitNoticeSent = false;
|
|
759
|
+
let foregroundPaused = false;
|
|
760
|
+
let foregroundYielded = false;
|
|
761
|
+
let turnYielded = false;
|
|
762
|
+
let foregroundWaitTimer;
|
|
763
|
+
const handleTimeoutResult = async (error) => {
|
|
764
|
+
if (observationClosed)
|
|
765
|
+
return;
|
|
766
|
+
if (chatFinalText.trim()) {
|
|
767
|
+
finish(chatFinalText);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (error instanceof SessionWaitPaused && options?.onWaitPaused) {
|
|
771
|
+
// Foreground notice, background observation. Keep the original queue
|
|
772
|
+
// owner/progress path so later input still steers into the same run.
|
|
773
|
+
foregroundPaused = true;
|
|
774
|
+
if (foregroundWaitTimer)
|
|
775
|
+
clearTimeout(foregroundWaitTimer);
|
|
776
|
+
if (!waitNoticeSent) {
|
|
777
|
+
waitNoticeSent = true;
|
|
778
|
+
try {
|
|
779
|
+
await options.onWaitPaused(error);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
console.warn("[OpenClaw] wait notice delivery pending; observation retained");
|
|
783
|
+
}
|
|
750
784
|
}
|
|
751
|
-
|
|
752
|
-
|
|
785
|
+
decidingTimeout = false;
|
|
786
|
+
if (!observationClosed)
|
|
787
|
+
resetIdleTimer(60_000, false);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
reject(error);
|
|
791
|
+
};
|
|
792
|
+
const checkRunState = async () => {
|
|
793
|
+
if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
|
|
794
|
+
return;
|
|
795
|
+
checkingState = true;
|
|
796
|
+
lastStateCheckAt = Date.now();
|
|
797
|
+
try {
|
|
798
|
+
const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
|
|
799
|
+
if (observationClosed || snapshot?.runId !== runId || snapshot?.pendingError === true
|
|
800
|
+
|| snapshot?.timeoutPhase === "queue" || snapshot?.timeoutPhase === "gateway_draining")
|
|
801
|
+
return;
|
|
802
|
+
// A bare wait timeout is not an execution timeout. Require a recorded
|
|
803
|
+
// terminal time; never infer failure from stopReason or replay flags.
|
|
804
|
+
const ended = snapshot.endedAt;
|
|
805
|
+
if (typeof ended !== "number" || !Number.isFinite(ended) || ended <= 0
|
|
806
|
+
|| (typeof snapshot.startedAt === "number" && ended < snapshot.startedAt))
|
|
807
|
+
return;
|
|
808
|
+
if (snapshot.status === "ok" && snapshot.yielded !== true) {
|
|
809
|
+
const terminal = snapshot.terminalReply;
|
|
810
|
+
const visible = terminal?.disposition === "visible" && typeof terminal.text === "string"
|
|
811
|
+
? terminal.text : terminal?.disposition === "silent" ? "NO_REPLY"
|
|
812
|
+
: this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
813
|
+
resolve(visible || "NO_REPLY");
|
|
753
814
|
}
|
|
754
|
-
else if (
|
|
755
|
-
|
|
815
|
+
else if (snapshot.status === "error" || snapshot.status === "timeout") {
|
|
816
|
+
if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
|
|
817
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
818
|
+
if (!observationClosed && status.running) {
|
|
819
|
+
await handleTimeoutResult(new SessionWaitPaused(status, true));
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (observationClosed)
|
|
824
|
+
return;
|
|
825
|
+
reject(new Error(`Agent error: ${typeof snapshot.error === "string" && snapshot.error.trim()
|
|
826
|
+
? snapshot.error : `Gateway 已确认运行以 ${snapshot.status} 结束`}`));
|
|
756
827
|
}
|
|
757
|
-
|
|
758
|
-
|
|
828
|
+
}
|
|
829
|
+
catch { /* Unavailable evidence means keep waiting, never abort/replay. */ }
|
|
830
|
+
finally {
|
|
831
|
+
checkingState = false;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
|
|
835
|
+
if (activity) {
|
|
836
|
+
if (!foregroundPaused)
|
|
837
|
+
waitNoticeSent = false;
|
|
838
|
+
quietNotified = false;
|
|
839
|
+
}
|
|
840
|
+
if (idleTimer)
|
|
841
|
+
clearTimeout(idleTimer);
|
|
842
|
+
if (observationClosed)
|
|
843
|
+
return;
|
|
844
|
+
idleTimer = setTimeout(async () => {
|
|
845
|
+
if (!quietNotified) {
|
|
846
|
+
quietNotified = true;
|
|
847
|
+
console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
|
|
759
848
|
}
|
|
760
|
-
|
|
849
|
+
await checkRunState();
|
|
850
|
+
if (!observationClosed) {
|
|
851
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
852
|
+
if (!observationClosed && status.running) {
|
|
853
|
+
await handleTimeoutResult(new SessionWaitPaused(status));
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (!observationClosed)
|
|
858
|
+
resetIdleTimer(60_000, false);
|
|
859
|
+
}, delayMs);
|
|
761
860
|
};
|
|
762
861
|
const summarizeActivity = (ev) => {
|
|
763
862
|
if (ev.stream === "item") {
|
|
@@ -897,6 +996,16 @@ export class OpenClawClient {
|
|
|
897
996
|
}
|
|
898
997
|
continue;
|
|
899
998
|
}
|
|
999
|
+
// After handoff, an unowned new run (including requester-settle)
|
|
1000
|
+
// retains its existing proactive delivery route. A session/lifecycle
|
|
1001
|
+
// start alone must not turn it into the old run's final. Discussion's
|
|
1002
|
+
// transcript mute marks scheduler-owned collection; retain its existing
|
|
1003
|
+
// anchored collector path rather than changing discussion routing.
|
|
1004
|
+
if (eventSessionMatches && foregroundYielded && evRunId && !matchesRun
|
|
1005
|
+
&& !this.mutedProactiveSessions.has(sessionKey) && !this.mutedProactiveSessions.has(shortSessionKey)) {
|
|
1006
|
+
bucket.splice(i, 1);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
900
1009
|
if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
|
|
901
1010
|
bucket.splice(i, 1);
|
|
902
1011
|
preAnchorEvents.push(ev);
|
|
@@ -950,6 +1059,10 @@ export class OpenClawClient {
|
|
|
950
1059
|
if (ev.stream !== "lifecycle") {
|
|
951
1060
|
pendingRuntimeFailureText = "";
|
|
952
1061
|
}
|
|
1062
|
+
// A matched resumed turn can again complete via the existing
|
|
1063
|
+
// assistant/transcript + lifecycle path, even without chat final.
|
|
1064
|
+
if (ev.stream === "lifecycle" && ev.data?.phase === "start")
|
|
1065
|
+
turnYielded = false;
|
|
953
1066
|
if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
|
|
954
1067
|
lifecycleStartedLogged = true;
|
|
955
1068
|
console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
|
|
@@ -982,17 +1095,48 @@ export class OpenClawClient {
|
|
|
982
1095
|
}
|
|
983
1096
|
}
|
|
984
1097
|
if (ev.stream === "chatFinal") {
|
|
1098
|
+
chatFinalSeen = true;
|
|
1099
|
+
if (ev.data?.gatewayYielded === true) {
|
|
1100
|
+
// A yielded turn is not a terminal answer, even if its buffered
|
|
1101
|
+
// message contains a preface. Preserve the collector/queue owner
|
|
1102
|
+
// and final-delivery key; only pause foreground presentation.
|
|
1103
|
+
if (chatFinalText.trim())
|
|
1104
|
+
continue; // an actual final wins
|
|
1105
|
+
foregroundYielded = true;
|
|
1106
|
+
turnYielded = true;
|
|
1107
|
+
text = chatDeltaText = transcriptAssistantText = "";
|
|
1108
|
+
if (chatFinalTimer) {
|
|
1109
|
+
clearTimeout(chatFinalTimer);
|
|
1110
|
+
chatFinalTimer = null;
|
|
1111
|
+
}
|
|
1112
|
+
if (lifecycleEndTimer) {
|
|
1113
|
+
clearTimeout(lifecycleEndTimer);
|
|
1114
|
+
lifecycleEndTimer = null;
|
|
1115
|
+
}
|
|
1116
|
+
// Finish scanning this batch first so an already-arrived real
|
|
1117
|
+
// final/error wins over a stale waiting notice. No status RPC
|
|
1118
|
+
// (or ten-minute budget) is needed to confirm this wire fact.
|
|
1119
|
+
if (options?.onWaitPaused)
|
|
1120
|
+
void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
985
1123
|
chatFinalText = ev.data?.text || "";
|
|
1124
|
+
if (chatFinalText.trim())
|
|
1125
|
+
turnYielded = false;
|
|
1126
|
+
else if (turnYielded)
|
|
1127
|
+
continue;
|
|
986
1128
|
// Fallback: if lifecycle end doesn't arrive within 5s, resolve
|
|
987
1129
|
if (!chatFinalTimer) {
|
|
988
1130
|
chatFinalTimer = setTimeout(() => {
|
|
989
1131
|
console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
|
|
990
|
-
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
991
|
-
console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
|
|
992
|
-
});
|
|
993
1132
|
// Prefer final chat message over accumulated deltas: some providers may
|
|
994
1133
|
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
995
1134
|
const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
1135
|
+
// Never abort a yielded original run, nor an empty final.
|
|
1136
|
+
if (!foregroundYielded && latestFinalText)
|
|
1137
|
+
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
1138
|
+
console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
|
|
1139
|
+
});
|
|
996
1140
|
if (latestFinalText) {
|
|
997
1141
|
finish(latestFinalText);
|
|
998
1142
|
}
|
|
@@ -1007,6 +1151,11 @@ export class OpenClawClient {
|
|
|
1007
1151
|
}
|
|
1008
1152
|
}
|
|
1009
1153
|
if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
|
|
1154
|
+
// paused/end_turn can beat the confirming chat event, including
|
|
1155
|
+
// discussion's emptyFinalAsNoReply path. It is neither success
|
|
1156
|
+
// nor failure and alone does NOT authorize a handoff notice.
|
|
1157
|
+
if (!chatFinalText.trim() && (turnYielded || (ev.data?.livenessState === "paused" && ev.data?.stopReason === "end_turn")))
|
|
1158
|
+
continue;
|
|
1010
1159
|
// Prefer final chat message over accumulated deltas: some providers may
|
|
1011
1160
|
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
1012
1161
|
const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
@@ -1041,7 +1190,14 @@ export class OpenClawClient {
|
|
|
1041
1190
|
return;
|
|
1042
1191
|
}
|
|
1043
1192
|
if (state !== "working") {
|
|
1044
|
-
|
|
1193
|
+
if (isWaitTimeout(failureText)) {
|
|
1194
|
+
if (!decidingTimeout) {
|
|
1195
|
+
decidingTimeout = true;
|
|
1196
|
+
void this.classifyWaitTimeout(failureText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
else
|
|
1200
|
+
finish(failureText);
|
|
1045
1201
|
return;
|
|
1046
1202
|
}
|
|
1047
1203
|
console.warn(`[OpenClaw] empty lifecycle end ignored for runId=${evRunId || runId}; waiting for real text or idle timeout`);
|
|
@@ -1051,7 +1207,12 @@ export class OpenClawClient {
|
|
|
1051
1207
|
};
|
|
1052
1208
|
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
1053
1209
|
// final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
|
|
1054
|
-
if (!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
|
|
1210
|
+
if ((!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
|
|
1211
|
+
|| (options?.emptyFinalAsNoReply && !chatFinalSeen && !finalText)) {
|
|
1212
|
+
// A lifecycle-only empty discussion end also needs this small
|
|
1213
|
+
// window for a confirming yielded chat final, not instant NO_REPLY.
|
|
1214
|
+
if (lifecycleEndTimer)
|
|
1215
|
+
clearTimeout(lifecycleEndTimer);
|
|
1055
1216
|
lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
|
|
1056
1217
|
}
|
|
1057
1218
|
else {
|
|
@@ -1076,6 +1237,14 @@ export class OpenClawClient {
|
|
|
1076
1237
|
console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
|
|
1077
1238
|
return;
|
|
1078
1239
|
}
|
|
1240
|
+
if (isWaitTimeout(errText)) {
|
|
1241
|
+
bucket.splice(i, 1);
|
|
1242
|
+
if (!decidingTimeout) {
|
|
1243
|
+
decidingTimeout = true;
|
|
1244
|
+
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1245
|
+
}
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1079
1248
|
clearTimeout(idleTimer);
|
|
1080
1249
|
clearInterval(poller);
|
|
1081
1250
|
if (chatFinalTimer)
|
|
@@ -1101,7 +1270,13 @@ export class OpenClawClient {
|
|
|
1101
1270
|
if (!genericChatErrorTimer) {
|
|
1102
1271
|
genericChatErrorTimer = setTimeout(() => {
|
|
1103
1272
|
genericChatErrorTimer = null;
|
|
1104
|
-
void this.resolveTerminalChatError(evRunId || runId).then((detail) => {
|
|
1273
|
+
void this.resolveTerminalChatError(evRunId || runId, targetSessionKey || sessionKey).then((detail) => {
|
|
1274
|
+
if (observationClosed)
|
|
1275
|
+
return;
|
|
1276
|
+
if (chatFinalText.trim()) {
|
|
1277
|
+
finish(chatFinalText);
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1105
1280
|
clearTimeout(idleTimer);
|
|
1106
1281
|
clearInterval(poller);
|
|
1107
1282
|
if (chatFinalTimer)
|
|
@@ -1109,7 +1284,7 @@ export class OpenClawClient {
|
|
|
1109
1284
|
if (lifecycleEndTimer)
|
|
1110
1285
|
clearTimeout(lifecycleEndTimer);
|
|
1111
1286
|
reject(new Error(`Agent error: ${detail}`));
|
|
1112
|
-
});
|
|
1287
|
+
}, error => { void handleTimeoutResult(error); });
|
|
1113
1288
|
}, 350);
|
|
1114
1289
|
}
|
|
1115
1290
|
continue;
|
|
@@ -1121,6 +1296,14 @@ export class OpenClawClient {
|
|
|
1121
1296
|
bucket.splice(i, 1);
|
|
1122
1297
|
continue;
|
|
1123
1298
|
}
|
|
1299
|
+
if (isWaitTimeout(errText)) {
|
|
1300
|
+
bucket.splice(i, 1);
|
|
1301
|
+
if (!decidingTimeout) {
|
|
1302
|
+
decidingTimeout = true;
|
|
1303
|
+
void this.classifyWaitTimeout(errText, targetSessionKey || sessionKey).then(handleTimeoutResult);
|
|
1304
|
+
}
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1124
1307
|
clearTimeout(idleTimer);
|
|
1125
1308
|
clearInterval(poller);
|
|
1126
1309
|
if (chatFinalTimer)
|
|
@@ -1141,7 +1324,61 @@ export class OpenClawClient {
|
|
|
1141
1324
|
}
|
|
1142
1325
|
}
|
|
1143
1326
|
}, 50);
|
|
1144
|
-
|
|
1327
|
+
const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
|
|
1328
|
+
const checkAfterNoActiveRun = async () => {
|
|
1329
|
+
await checkRunState();
|
|
1330
|
+
if (observationClosed || !observationKey)
|
|
1331
|
+
return;
|
|
1332
|
+
try {
|
|
1333
|
+
// Used only after the plugin reports no active run. Missing terminal
|
|
1334
|
+
// history is not failure, but an explicit idle session must not keep
|
|
1335
|
+
// a phantom queue owner blocking this newly-arrived user message.
|
|
1336
|
+
const history = await this.rpc("chat.history", { sessionKey: observationKey, limit: 1 }, 2500);
|
|
1337
|
+
if (!observationClosed && history?.sessionKey === observationKey && history?.sessionInfo?.hasActiveRun === false) {
|
|
1338
|
+
reject(new InactiveRunObservation());
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
catch { /* Unknown activity cannot authorize releasing an active owner. */ }
|
|
1342
|
+
};
|
|
1343
|
+
if (observationKey)
|
|
1344
|
+
this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
|
|
1345
|
+
// Fixed foreground budget: tool/assistant activity must not keep a card
|
|
1346
|
+
// refreshing indefinitely. The original result observer stays alive.
|
|
1347
|
+
if (options?.onWaitPaused) {
|
|
1348
|
+
foregroundWaitTimer = setTimeout(async () => {
|
|
1349
|
+
if (observationClosed || foregroundPaused)
|
|
1350
|
+
return;
|
|
1351
|
+
await checkRunState();
|
|
1352
|
+
if (observationClosed || foregroundPaused)
|
|
1353
|
+
return;
|
|
1354
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
1355
|
+
if (!observationClosed && !foregroundPaused)
|
|
1356
|
+
await handleTimeoutResult(new SessionWaitPaused(status));
|
|
1357
|
+
}, timeoutMs);
|
|
1358
|
+
foregroundWaitTimer.unref?.();
|
|
1359
|
+
}
|
|
1360
|
+
// Bound local resources without cancelling an execution we cannot prove stuck.
|
|
1361
|
+
const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
|
|
1362
|
+
observationHorizon.unref?.();
|
|
1363
|
+
cleanupObservation = () => {
|
|
1364
|
+
observationClosed = true;
|
|
1365
|
+
clearTimeout(idleTimer);
|
|
1366
|
+
if (foregroundWaitTimer)
|
|
1367
|
+
clearTimeout(foregroundWaitTimer);
|
|
1368
|
+
clearTimeout(observationHorizon);
|
|
1369
|
+
clearInterval(poller);
|
|
1370
|
+
if (chatFinalTimer)
|
|
1371
|
+
clearTimeout(chatFinalTimer);
|
|
1372
|
+
if (lifecycleEndTimer)
|
|
1373
|
+
clearTimeout(lifecycleEndTimer);
|
|
1374
|
+
if (replayInvalidTimer)
|
|
1375
|
+
clearTimeout(replayInvalidTimer);
|
|
1376
|
+
if (genericChatErrorTimer)
|
|
1377
|
+
clearTimeout(genericChatErrorTimer);
|
|
1378
|
+
if (observationKey && this.runStateChecks.get(observationKey) === checkAfterNoActiveRun)
|
|
1379
|
+
this.runStateChecks.delete(observationKey);
|
|
1380
|
+
};
|
|
1381
|
+
}).finally(() => cleanupObservation());
|
|
1145
1382
|
}
|
|
1146
1383
|
/**
|
|
1147
1384
|
* Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
|
|
@@ -1155,12 +1392,19 @@ export class OpenClawClient {
|
|
|
1155
1392
|
const t = (errText || "").trim().toLowerCase();
|
|
1156
1393
|
return t === "chat error" || t === "agent error: chat error";
|
|
1157
1394
|
}
|
|
1158
|
-
async resolveTerminalChatError(runId) {
|
|
1395
|
+
async resolveTerminalChatError(runId, sessionKey) {
|
|
1159
1396
|
try {
|
|
1160
1397
|
const snapshot = await this.rpc("agent.wait", { runId, timeoutMs: 1500 }, 2500);
|
|
1398
|
+
if (sessionKey && (snapshot?.status === "timeout" || isWaitTimeout(String(snapshot?.error || "")))) {
|
|
1399
|
+
const status = await this.getSessionRuntimeStatus(sessionKey);
|
|
1400
|
+
if (status.running)
|
|
1401
|
+
throw new SessionWaitPaused(status, true);
|
|
1402
|
+
}
|
|
1161
1403
|
return this.formatTerminalRunError(snapshot);
|
|
1162
1404
|
}
|
|
1163
1405
|
catch (err) {
|
|
1406
|
+
if (err instanceof SessionWaitPaused)
|
|
1407
|
+
throw err;
|
|
1164
1408
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1165
1409
|
return `OpenClaw 运行失败,但未返回具体原因${detail ? `(${detail})` : ""}`;
|
|
1166
1410
|
}
|
|
@@ -1242,18 +1486,25 @@ export class OpenClawClient {
|
|
|
1242
1486
|
*/
|
|
1243
1487
|
async steer(sessionKey, text, displayText) {
|
|
1244
1488
|
const key = this.canonicalSessionKey(sessionKey);
|
|
1489
|
+
// Consumption can arrive before the plugin RPC response at a tool boundary.
|
|
1490
|
+
const cancelPending = this.registerPendingSteer(key, text, displayText);
|
|
1245
1491
|
try {
|
|
1246
1492
|
const res = await this.rpc("lma.steer", { sessionKey: key, text }, 10000);
|
|
1247
1493
|
if (res?.status !== "steered") {
|
|
1494
|
+
// A locally busy collector can outlive the actual Gateway turn. Check
|
|
1495
|
+
// its exact terminal record now, rather than parking input for 30 min.
|
|
1496
|
+
if (res?.status === "no_active_run")
|
|
1497
|
+
await this.runStateChecks.get(key)?.();
|
|
1248
1498
|
if (res?.status && res.status !== "no_active_run") {
|
|
1249
1499
|
console.warn(`[OpenClaw] lma.steer ${res.status} for ${key.slice(-8)}`);
|
|
1250
1500
|
}
|
|
1501
|
+
cancelPending();
|
|
1251
1502
|
return { status: "unavailable" };
|
|
1252
1503
|
}
|
|
1253
|
-
const cancelPending = this.registerPendingSteer(key, text, displayText);
|
|
1254
1504
|
return { status: "steered", cancelPending };
|
|
1255
1505
|
}
|
|
1256
1506
|
catch (err) {
|
|
1507
|
+
cancelPending();
|
|
1257
1508
|
const message = err?.message || String(err);
|
|
1258
1509
|
console.warn(`[OpenClaw] lma.steer unavailable for ${key.slice(-8)}: ${message}`);
|
|
1259
1510
|
return { status: "unavailable" };
|
|
@@ -1393,6 +1644,37 @@ export class OpenClawClient {
|
|
|
1393
1644
|
/**
|
|
1394
1645
|
* Get session info (model, tokens, etc.) for status display.
|
|
1395
1646
|
*/
|
|
1647
|
+
/** Stop claiming an exact run's final output after observation has paused. */
|
|
1648
|
+
releasePausedWait(runId) {
|
|
1649
|
+
if (!runId)
|
|
1650
|
+
return;
|
|
1651
|
+
const timer = this.ownedDeliveryRunTimers.get(runId);
|
|
1652
|
+
if (timer)
|
|
1653
|
+
clearTimeout(timer);
|
|
1654
|
+
this.ownedDeliveryRunTimers.delete(runId);
|
|
1655
|
+
this.ownedDeliveryRuns.delete(runId);
|
|
1656
|
+
}
|
|
1657
|
+
async getSessionRuntimeStatus(sessionKey) {
|
|
1658
|
+
let timer;
|
|
1659
|
+
try {
|
|
1660
|
+
const value = await Promise.race([
|
|
1661
|
+
this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) }, 1500),
|
|
1662
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("status lookup timeout")), 1500); }),
|
|
1663
|
+
]);
|
|
1664
|
+
return normalizeSessionRuntimeStatus(value);
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
return normalizeSessionRuntimeStatus(undefined);
|
|
1668
|
+
}
|
|
1669
|
+
finally {
|
|
1670
|
+
if (timer)
|
|
1671
|
+
clearTimeout(timer);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
async classifyWaitTimeout(text, sessionKey) {
|
|
1675
|
+
const status = await this.getSessionRuntimeStatus(sessionKey).catch(() => normalizeSessionRuntimeStatus(undefined));
|
|
1676
|
+
return status.running ? new SessionWaitPaused(status, true) : new Error(`Agent error: ${text}`);
|
|
1677
|
+
}
|
|
1396
1678
|
async getSessionInfo(sessionKey) {
|
|
1397
1679
|
return this.rpc("sessions.describe", { key: this.canonicalSessionKey(sessionKey) });
|
|
1398
1680
|
}
|
|
@@ -1636,6 +1918,7 @@ export class OpenClawClient {
|
|
|
1636
1918
|
const fullSessionKey = this.canonicalSessionKey(params.sessionKey);
|
|
1637
1919
|
this.clearVerboseAssistantState(sk);
|
|
1638
1920
|
let ownedRunId = "";
|
|
1921
|
+
let waitPaused = false;
|
|
1639
1922
|
try {
|
|
1640
1923
|
// Drop stale buffered events for this session before starting a new run.
|
|
1641
1924
|
// This prevents an old final text (e.g. previous "ok") from being consumed by
|
|
@@ -1667,7 +1950,11 @@ export class OpenClawClient {
|
|
|
1667
1950
|
this.progressCallbacks.set(fullSessionKey, params.onProgress);
|
|
1668
1951
|
void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
|
|
1669
1952
|
}
|
|
1670
|
-
return await this.collectReply(result.runId, params.timeoutMs ||
|
|
1953
|
+
return await this.collectReply(result.runId, params.timeoutMs || FOREGROUND_WAIT_MS, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message, onWaitPaused: params.onWaitPaused });
|
|
1954
|
+
}
|
|
1955
|
+
catch (error) {
|
|
1956
|
+
waitPaused = error instanceof SessionWaitPaused;
|
|
1957
|
+
throw error;
|
|
1671
1958
|
}
|
|
1672
1959
|
finally {
|
|
1673
1960
|
// OpenClaw can emit the final assistant session.message a moment after
|
|
@@ -1676,7 +1963,10 @@ export class OpenClawClient {
|
|
|
1676
1963
|
// are unaffected because they do not go through chatSend.
|
|
1677
1964
|
this.progressCallbacks.delete(sk);
|
|
1678
1965
|
this.progressCallbacks.delete(fullSessionKey);
|
|
1679
|
-
|
|
1966
|
+
if (waitPaused)
|
|
1967
|
+
this.releasePausedWait(ownedRunId);
|
|
1968
|
+
else
|
|
1969
|
+
this.releaseOwnedDeliveryRunAfter(ownedRunId, 30000);
|
|
1680
1970
|
}
|
|
1681
1971
|
}
|
|
1682
1972
|
shouldInjectBridgeAttachmentHint(text) {
|
|
@@ -1728,6 +2018,7 @@ export class OpenClawClient {
|
|
|
1728
2018
|
onSendAttempt: params.onSendAttempt,
|
|
1729
2019
|
onSubmitted: params.onSubmitted,
|
|
1730
2020
|
onProgress: params.onProgress,
|
|
2021
|
+
onWaitPaused: params.onWaitPaused,
|
|
1731
2022
|
});
|
|
1732
2023
|
}
|
|
1733
2024
|
// Build context block + actual message in one chat.send, or write the
|
|
@@ -1764,6 +2055,7 @@ export class OpenClawClient {
|
|
|
1764
2055
|
onSendAttempt: params.onSendAttempt,
|
|
1765
2056
|
onSubmitted: params.onSubmitted,
|
|
1766
2057
|
onProgress: params.onProgress,
|
|
2058
|
+
onWaitPaused: params.onWaitPaused,
|
|
1767
2059
|
});
|
|
1768
2060
|
}
|
|
1769
2061
|
formatContextLines(messages) {
|