From f9379ecee2865f5172ea7f678b99f4c92df822e0 Mon Sep 17 00:00:00 2001 From: Spacefish Date: Sat, 14 Feb 2026 01:36:04 +0100 Subject: [PATCH] =?UTF-8?q?Ignore=20up=20to=204=20non-word=20characters=20?= =?UTF-8?q?when=20stripping=20HEARTBEAT=5FOK=20token=20=E2=80=A6=20(#15847?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: dc03ce500571a48ff5caf2f5dae611d714ffe390 Co-authored-by: Spacefish <375633+Spacefish@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- CHANGELOG.md | 1 + src/auto-reply/heartbeat.test.ts | 56 ++++++++++++++++++++++++++++++++ src/auto-reply/heartbeat.ts | 20 ++++++++++-- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d3c009fea..52f195a220a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238. - CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle. +- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish. - Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner. - Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. - Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238. diff --git a/src/auto-reply/heartbeat.test.ts b/src/auto-reply/heartbeat.test.ts index 5763d16261b..0506f08af3e 100644 --- a/src/auto-reply/heartbeat.test.ts +++ b/src/auto-reply/heartbeat.test.ts @@ -107,6 +107,62 @@ describe("stripHeartbeatToken", () => { didStrip: true, }); }); + + it("strips trailing punctuation only when directly after the token", () => { + // Token with trailing dot/exclamation/dashes → should still strip + expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN}.`, { mode: "heartbeat" })).toEqual({ + shouldSkip: true, + text: "", + didStrip: true, + }); + expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN}!!!`, { mode: "heartbeat" })).toEqual({ + shouldSkip: true, + text: "", + didStrip: true, + }); + expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN}---`, { mode: "heartbeat" })).toEqual({ + shouldSkip: true, + text: "", + didStrip: true, + }); + }); + + it("strips a sentence-ending token and keeps trailing punctuation", () => { + // Token appears at sentence end with trailing punctuation. + expect( + stripHeartbeatToken(`I should not respond ${HEARTBEAT_TOKEN}.`, { + mode: "message", + }), + ).toEqual({ + shouldSkip: false, + text: `I should not respond.`, + didStrip: true, + }); + }); + + it("strips sentence-ending token with emphasis punctuation in heartbeat mode", () => { + expect( + stripHeartbeatToken( + `There is nothing todo, so i should respond with ${HEARTBEAT_TOKEN} !!!`, + { + mode: "heartbeat", + }, + ), + ).toEqual({ + shouldSkip: true, + text: "", + didStrip: true, + }); + }); + + it("preserves trailing punctuation on text before the token", () => { + // Token at end, preceding text has its own punctuation — only the token is stripped + expect(stripHeartbeatToken(`All clear. ${HEARTBEAT_TOKEN}`, { mode: "message" })).toEqual({ + shouldSkip: false, + text: "All clear.", + didStrip: true, + }); + }); }); describe("isHeartbeatContentEffectivelyEmpty", () => { diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 4f4ef22aa79..4141d180f67 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -1,3 +1,4 @@ +import { escapeRegExp } from "../utils.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; // Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset). @@ -65,6 +66,9 @@ function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } { } const token = HEARTBEAT_TOKEN; + const tokenAtEndWithOptionalTrailingPunctuation = new RegExp( + `${escapeRegExp(token)}[^\\w]{0,4}$`, + ); if (!text.includes(token)) { return { text, didStrip: false }; } @@ -81,9 +85,19 @@ function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } { changed = true; continue; } - if (next.endsWith(token)) { - const before = next.slice(0, Math.max(0, next.length - token.length)); - text = before.trimEnd(); + // Strip the token when it appears at the end of the text. + // Also strip up to 4 trailing non-word characters the model may have appended + // (e.g. ".", "!!!", "---"). Keep trailing punctuation only when real + // sentence text exists before the token. + if (tokenAtEndWithOptionalTrailingPunctuation.test(next)) { + const idx = next.lastIndexOf(token); + const before = next.slice(0, idx).trimEnd(); + if (!before) { + text = ""; + } else { + const after = next.slice(idx + token.length).trimStart(); + text = `${before}${after}`.trimEnd(); + } didStrip = true; changed = true; }