openclaw-lark-multi-agent 1.4.9 → 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.
@@ -1388,7 +1388,10 @@ export class FeishuBot {
1388
1388
  onProgress: (event) => liveStatus?.progress(event),
1389
1389
  onWaitPaused: async (notice) => {
1390
1390
  await liveStatus?.showWaitingForResult(notice.message);
1391
- if (Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
1391
+ // Yield is a confirmed handoff, not the timeout heuristic. A
1392
+ // recent unrelated reply must not suppress this turn's notice.
1393
+ if (notice.reason === "yield" ? !this.store.hasDeliveredReply(this.config.name, chatId, triggerId)
1394
+ : Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
1392
1395
  await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, notice);
1393
1396
  },
1394
1397
  }));
@@ -231,7 +231,9 @@ export class OpenClawClient {
231
231
  this.agentEvents.get(sk).push({
232
232
  ...frame.payload,
233
233
  stream: "chatFinal",
234
- data: { text: textParts.join("\n") },
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 },
235
237
  });
236
238
  }
237
239
  else if (state === "error") {
@@ -467,6 +469,11 @@ export class OpenClawClient {
467
469
  /** Render protocol-4 structured tool args/results without `[object Object]`.
468
470
  * Prefer useful, non-secret fields and keep the fallback bounded. */
469
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 "";
470
477
  if (value === null || value === undefined || value === "")
471
478
  return "";
472
479
  if (typeof value === "string")
@@ -704,7 +711,7 @@ export class OpenClawClient {
704
711
  */
705
712
  collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
706
713
  let cleanupObservation = () => { };
707
- return new Promise((resolve, reject) => {
714
+ return new Promise((resolveResult, rejectResult) => {
708
715
  // Whether we were connected when the run started. We only treat a later
709
716
  // disconnect as a mid-run gateway drop (fail fast) if we began connected;
710
717
  // this keeps unit tests that drive collectReply without a live socket from
@@ -713,6 +720,7 @@ export class OpenClawClient {
713
720
  let text = "";
714
721
  let chatDeltaText = "";
715
722
  let chatFinalText = "";
723
+ let chatFinalSeen = false;
716
724
  let transcriptAssistantText = "";
717
725
  let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
718
726
  let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
@@ -739,12 +747,18 @@ export class OpenClawClient {
739
747
  };
740
748
  let idleTimer;
741
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); };
742
754
  let checkingState = false;
743
755
  let lastStateCheckAt = -Infinity;
744
756
  let quietNotified = false;
745
757
  let decidingTimeout = false;
746
758
  let waitNoticeSent = false;
747
759
  let foregroundPaused = false;
760
+ let foregroundYielded = false;
761
+ let turnYielded = false;
748
762
  let foregroundWaitTimer;
749
763
  const handleTimeoutResult = async (error) => {
750
764
  if (observationClosed)
@@ -982,6 +996,16 @@ export class OpenClawClient {
982
996
  }
983
997
  continue;
984
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
+ }
985
1009
  if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
986
1010
  bucket.splice(i, 1);
987
1011
  preAnchorEvents.push(ev);
@@ -1035,6 +1059,10 @@ export class OpenClawClient {
1035
1059
  if (ev.stream !== "lifecycle") {
1036
1060
  pendingRuntimeFailureText = "";
1037
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;
1038
1066
  if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
1039
1067
  lifecycleStartedLogged = true;
1040
1068
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
@@ -1067,17 +1095,48 @@ export class OpenClawClient {
1067
1095
  }
1068
1096
  }
1069
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
+ }
1070
1123
  chatFinalText = ev.data?.text || "";
1124
+ if (chatFinalText.trim())
1125
+ turnYielded = false;
1126
+ else if (turnYielded)
1127
+ continue;
1071
1128
  // Fallback: if lifecycle end doesn't arrive within 5s, resolve
1072
1129
  if (!chatFinalTimer) {
1073
1130
  chatFinalTimer = setTimeout(() => {
1074
1131
  console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
1075
- this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
1076
- console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
1077
- });
1078
1132
  // Prefer final chat message over accumulated deltas: some providers may
1079
1133
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1080
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
+ });
1081
1140
  if (latestFinalText) {
1082
1141
  finish(latestFinalText);
1083
1142
  }
@@ -1092,6 +1151,11 @@ export class OpenClawClient {
1092
1151
  }
1093
1152
  }
1094
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;
1095
1159
  // Prefer final chat message over accumulated deltas: some providers may
1096
1160
  // emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
1097
1161
  const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
@@ -1143,7 +1207,12 @@ export class OpenClawClient {
1143
1207
  };
1144
1208
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
1145
1209
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
1146
- 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);
1147
1216
  lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
1148
1217
  }
1149
1218
  else {
@@ -11,8 +11,9 @@ export declare const isWaitTimeout: (text: string) => boolean;
11
11
  export declare function normalizeSessionRuntimeStatus(value: any): SessionRuntimeStatus;
12
12
  /** Compact presentation only; never replaces the actual status/model values. */
13
13
  export declare function formatSessionFooter(snapshot: SessionRuntimeStatus): string;
14
- /** Local observation has ended, NOT an assertion that execution failed. */
14
+ /** Foreground waiting paused, NOT an assertion that execution ended. */
15
15
  export declare class SessionWaitPaused extends Error {
16
16
  readonly snapshot: SessionRuntimeStatus;
17
- constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean);
17
+ readonly reason: "timeout" | "yield";
18
+ constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean, reason?: "timeout" | "yield");
18
19
  }
@@ -26,14 +26,18 @@ export function formatSessionFooter(snapshot) {
26
26
  const limitK = limit === undefined ? "?" : Math.max(1, Math.round(limit / 1000));
27
27
  return `${snapshot.status} · ${usedK}K/${limitK}K`;
28
28
  }
29
- /** Local observation has ended, NOT an assertion that execution failed. */
29
+ /** Foreground waiting paused, NOT an assertion that execution ended. */
30
30
  export class SessionWaitPaused extends Error {
31
31
  snapshot;
32
- constructor(snapshot, reportedTimeout = false) {
33
- super(reportedTimeout
34
- ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
35
- : "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
32
+ reason;
33
+ constructor(snapshot, reportedTimeout = false, reason = "timeout") {
34
+ super(reason === "yield"
35
+ ? "⏳ 本轮已转入后台等待;若有后续结果,将继续在此回复。未发送停止请求,也未自动重发请求。"
36
+ : reportedTimeout
37
+ ? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
38
+ : "⏳ 本次等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
36
39
  this.snapshot = snapshot;
40
+ this.reason = reason;
37
41
  this.name = "SessionWaitPaused";
38
42
  }
39
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.9",
3
+ "version": "1.4.10",
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": {