





























@@ -1,4 +1,3 @@
1-import { isHeartbeatOkResponse } from "../../../../src/auto-reply/heartbeat-filter.js";
21import { resetToolStream } from "../app-tool-stream.ts";
32import { extractText } from "../chat/message-extract.ts";
43import { formatConnectError } from "../connect-error.ts";
@@ -12,6 +11,8 @@ import {
1211} from "./scope-errors.ts";
13121413const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
14+const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
15+const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
1516const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
1617"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
1718const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
@@ -41,6 +42,97 @@ function shouldApplyChatHistoryResult(
4142function isSilentReplyStream(text: string): boolean {
4243return SILENT_REPLY_PATTERN.test(text);
4344}
45+46+function escapeRegExp(value: string): string {
47+return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48+}
49+50+function stripHeartbeatTokenForDisplay(
51+raw: string,
52+maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
53+): { shouldSkip: boolean } {
54+let text = raw.trim();
55+if (!text) {
56+return { shouldSkip: true };
57+}
58+const strippedMarkup = text
59+.replace(/<[^>]*>/g, " ")
60+.replace(/ /gi, " ")
61+.replace(/^[*`~_]+/, "")
62+.replace(/[*`~_]+$/, "");
63+if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
64+return { shouldSkip: false };
65+}
66+67+const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
68+let changed = true;
69+let didStrip = false;
70+text = strippedMarkup.trim();
71+while (changed) {
72+changed = false;
73+const next = text.trim();
74+if (next.startsWith(HEARTBEAT_TOKEN)) {
75+text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
76+didStrip = true;
77+changed = true;
78+continue;
79+}
80+if (tokenAtEnd.test(next)) {
81+const index = next.lastIndexOf(HEARTBEAT_TOKEN);
82+const before = next.slice(0, index).trimEnd();
83+const after = next.slice(index + HEARTBEAT_TOKEN.length).trimStart();
84+text = before ? `${before}${after}`.trimEnd() : "";
85+didStrip = true;
86+changed = true;
87+}
88+}
89+90+if (!didStrip) {
91+return { shouldSkip: false };
92+}
93+return { shouldSkip: !text || text.length <= maxAckChars };
94+}
95+96+function isHeartbeatOkResponse(message: { role: string; content?: unknown }): boolean {
97+if (message.role !== "assistant") {
98+return false;
99+}
100+const { text, hasNonTextContent } = resolveMessageText(message.content);
101+if (hasNonTextContent) {
102+return false;
103+}
104+return stripHeartbeatTokenForDisplay(text).shouldSkip;
105+}
106+107+function resolveMessageText(content: unknown): { text: string; hasNonTextContent: boolean } {
108+if (typeof content === "string") {
109+return { text: content, hasNonTextContent: false };
110+}
111+if (!Array.isArray(content)) {
112+return { text: "", hasNonTextContent: content != null };
113+}
114+let hasNonTextContent = false;
115+const text = content
116+.filter((block): block is { type: "text"; text: string } => {
117+if (!block || typeof block !== "object" || !("type" in block)) {
118+hasNonTextContent = true;
119+return false;
120+}
121+if ((block as { type?: unknown }).type !== "text") {
122+hasNonTextContent = true;
123+return false;
124+}
125+if (typeof (block as { text?: unknown }).text !== "string") {
126+hasNonTextContent = true;
127+return false;
128+}
129+return true;
130+})
131+.map((block) => block.text)
132+.join("");
133+return { text, hasNonTextContent };
134+}
135+44136/** Client-side defense-in-depth: detect assistant messages whose text is purely NO_REPLY. */
45137function isAssistantSilentReply(message: unknown): boolean {
46138if (!message || typeof message !== "object") {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。