fix: strip sentence-ending heartbeat token punctuation (openclaw#15847) thanks @Spacefish

This commit is contained in:
Peter Steinberger 2026-02-14 01:13:17 +01:00
parent 000d9e6e72
commit dc03ce5005
3 changed files with 33 additions and 11 deletions

View File

@ -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.

View File

@ -127,8 +127,8 @@ describe("stripHeartbeatToken", () => {
});
});
it("does not strip trailing punctuation from unrelated text containing the token", () => {
// Token is in the middle — trailing dot belongs to surrounding sentence, not the token
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",
@ -140,11 +140,24 @@ describe("stripHeartbeatToken", () => {
});
});
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({
expect(stripHeartbeatToken(`All clear. ${HEARTBEAT_TOKEN}`, { mode: "message" })).toEqual({
shouldSkip: false,
text: "All clear.",
didStrip: true,

View File

@ -1,5 +1,5 @@
import { HEARTBEAT_TOKEN } from "./tokens.js";
import { escapeRegExp } from "../utils.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
@ -66,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 };
}
@ -84,12 +87,17 @@ function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
}
// 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. ".", "!!!", "---"), but only when the token is the entire remaining text
// (^ anchor). This prevents mangling sentences like "I should not respond HEARTBEAT_OK."
// where the punctuation belongs to the surrounding text.
if (new RegExp(`${escapeRegExp(token)}[^\\w]{0,4}$`).test(next)) {
// (e.g. ".", "!!!", "---"). Keep trailing punctuation only when real
// sentence text exists before the token.
if (tokenAtEndWithOptionalTrailingPunctuation.test(next)) {
const idx = next.lastIndexOf(token);
text = `${next.slice(0, idx).trimEnd()}${next.slice(idx + token.length)}`.trimEnd();
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;
}