fix: refine HEARTBEAT_OK handling

This commit is contained in:
Peter Steinberger
2026-01-02 01:42:27 +01:00
parent c31070db24
commit 4c2812b429
9 changed files with 261 additions and 91 deletions

View File

@@ -35,7 +35,7 @@
- Chat UI: keep the chat scrolled to the latest message after switching sessions. - Chat UI: keep the chat scrolled to the latest message after switching sessions.
- WebChat: stream live updates for sessions even when runs start outside the chat UI. - WebChat: stream live updates for sessions even when runs start outside the chat UI.
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag. - Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
- Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages. - Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines. - Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured. - Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
- Skills: switch imsg installer to brew tap formula. - Skills: switch imsg installer to brew tap formula.

View File

@@ -10,10 +10,18 @@ surface anything that needs attention without spamming the user.
## Prompt contract ## Prompt contract
- Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`). - Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`).
- If nothing needs attention, the model must reply **exactly** `HEARTBEAT_OK`. - If nothing needs attention, the model should reply **exactly** `HEARTBEAT_OK`.
- Any response containing `HEARTBEAT_OK` is treated as an ack and discarded. - During heartbeat runs, Clawdis treats `HEARTBEAT_OK` as an ack when it appears at
the **start or end** of the reply. Clawdis strips the token and discards the
reply if the remaining content is **≤ 30 characters**.
- If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially.
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text. - For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
### Stray `HEARTBEAT_OK` outside heartbeats
If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
normal (non-heartbeat) reply, Clawdis strips the token and logs a verbose
message. If the reply is only `HEARTBEAT_OK`, it is dropped.
## Config ## Config
```json5 ```json5

View File

@@ -95,7 +95,7 @@ export function buildAgentSystemPromptAppend(params: {
"## Heartbeats", "## Heartbeats",
'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:', 'If you receive a heartbeat poll (a user message containing just "HEARTBEAT"), and there is nothing that needs attention, reply exactly:',
"HEARTBEAT_OK", "HEARTBEAT_OK",
'Any response containing "HEARTBEAT_OK" is treated as a heartbeat ack and will not be delivered.', 'Clawdis treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"", "",
"## Runtime", "## Runtime",

View File

@@ -5,55 +5,89 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => { describe("stripHeartbeatToken", () => {
it("skips empty or token-only replies", () => { it("skips empty or token-only replies", () => {
expect(stripHeartbeatToken(undefined)).toEqual({ expect(stripHeartbeatToken(undefined, { mode: "heartbeat" })).toEqual({
shouldSkip: true, shouldSkip: true,
text: "", text: "",
didStrip: false,
}); });
expect(stripHeartbeatToken(" ")).toEqual({ expect(stripHeartbeatToken(" ", { mode: "heartbeat" })).toEqual({
shouldSkip: true, shouldSkip: true,
text: "", text: "",
didStrip: false,
}); });
expect(stripHeartbeatToken(HEARTBEAT_TOKEN)).toEqual({ expect(stripHeartbeatToken(HEARTBEAT_TOKEN, { mode: "heartbeat" })).toEqual(
{
shouldSkip: true,
text: "",
didStrip: true,
},
);
});
it("drops heartbeats with small junk in heartbeat mode", () => {
expect(
stripHeartbeatToken("HEARTBEAT_OK 🦞", { mode: "heartbeat" }),
).toEqual({
shouldSkip: true, shouldSkip: true,
text: "", text: "",
didStrip: true,
});
expect(
stripHeartbeatToken(`🦞 ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
}); });
}); });
it("skips any reply that includes the heartbeat token", () => { it("drops short remainder in heartbeat mode", () => {
expect(stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`)).toEqual({ expect(
shouldSkip: true, stripHeartbeatToken(`ALERT ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
text: "", ).toEqual({
});
expect(stripHeartbeatToken("HEARTBEAT_OK 🦞")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK_OK_OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK_OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK _OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("HEARTBEAT_OK OK")).toEqual({
shouldSkip: true,
text: "",
});
expect(stripHeartbeatToken("ALERT HEARTBEAT_OK_OK")).toEqual({
shouldSkip: true, shouldSkip: true,
text: "", text: "",
didStrip: true,
}); });
}); });
it("keeps non-heartbeat content", () => { it("keeps heartbeat replies when remaining content exceeds threshold", () => {
expect(stripHeartbeatToken("hello")).toEqual({ const long = "A".repeat(31);
expect(
stripHeartbeatToken(`${long} ${HEARTBEAT_TOKEN}`, { mode: "heartbeat" }),
).toEqual({
shouldSkip: false,
text: long,
didStrip: true,
});
});
it("strips token at edges for normal messages", () => {
expect(
stripHeartbeatToken(`${HEARTBEAT_TOKEN} hello`, { mode: "message" }),
).toEqual({
shouldSkip: false, shouldSkip: false,
text: "hello", text: "hello",
didStrip: true,
});
expect(
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN}`, { mode: "message" }),
).toEqual({
shouldSkip: false,
text: "hello",
didStrip: true,
});
});
it("does not touch token in the middle", () => {
expect(
stripHeartbeatToken(`hello ${HEARTBEAT_TOKEN} there`, {
mode: "message",
}),
).toEqual({
shouldSkip: false,
text: `hello ${HEARTBEAT_TOKEN} there`,
didStrip: false,
}); });
}); });
}); });

View File

@@ -2,12 +2,69 @@ import { HEARTBEAT_TOKEN } from "./tokens.js";
export const HEARTBEAT_PROMPT = "HEARTBEAT"; export const HEARTBEAT_PROMPT = "HEARTBEAT";
export function stripHeartbeatToken(raw?: string) { export type StripHeartbeatMode = "heartbeat" | "message";
if (!raw) return { shouldSkip: true, text: "" };
const trimmed = raw.trim(); function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
if (!trimmed) return { shouldSkip: true, text: "" }; let text = raw.trim();
if (trimmed.includes(HEARTBEAT_TOKEN)) { if (!text) return { text: "", didStrip: false };
return { shouldSkip: true, text: "" };
const token = HEARTBEAT_TOKEN;
if (!text.includes(token)) return { text, didStrip: false };
let didStrip = false;
let changed = true;
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(token)) {
const after = next.slice(token.length).trimStart();
text = after;
didStrip = true;
changed = true;
continue;
}
if (next.endsWith(token)) {
const before = next.slice(0, Math.max(0, next.length - token.length));
text = before.trimEnd();
didStrip = true;
changed = true;
}
} }
return { shouldSkip: false, text: trimmed };
const collapsed = text.replace(/\s+/g, " ").trim();
return { text: collapsed, didStrip };
}
export function stripHeartbeatToken(
raw?: string,
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
) {
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
const mode: StripHeartbeatMode = opts.mode ?? "message";
const maxAckChars = Math.max(0, opts.maxAckChars ?? 30);
if (!trimmed.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
const stripped = stripTokenAtEdges(trimmed);
if (!stripped.didStrip) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
if (!stripped.text) {
return { shouldSkip: true, text: "", didStrip: true };
}
if (mode === "heartbeat") {
const rest = stripped.text.trim();
if (rest.length <= maxAckChars) {
return { shouldSkip: true, text: "", didStrip: true };
}
}
return { shouldSkip: false, text: stripped.text, didStrip: true };
} }

View File

@@ -186,6 +186,31 @@ describe("trigger handling", () => {
}); });
}); });
it("strips HEARTBEAT_OK at edges outside heartbeat runs", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: `${HEARTBEAT_TOKEN} hello` }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("hello");
});
});
it("updates group activation when the owner sends /activation", async () => { it("updates group activation when the owner sends /activation", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);

View File

@@ -53,6 +53,7 @@ import {
normalizeGroupActivation, normalizeGroupActivation,
parseActivationCommand, parseActivationCommand,
} from "./group-activation.js"; } from "./group-activation.js";
import { stripHeartbeatToken } from "./heartbeat.js";
import { extractModelDirective } from "./model.js"; import { extractModelDirective } from "./model.js";
import { buildStatusMessage } from "./status.js"; import { buildStatusMessage } from "./status.js";
import type { MsgContext, TemplateContext } from "./templating.js"; import type { MsgContext, TemplateContext } from "./templating.js";
@@ -62,7 +63,7 @@ import {
type ThinkLevel, type ThinkLevel,
type VerboseLevel, type VerboseLevel,
} from "./thinking.js"; } from "./thinking.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "./tokens.js"; import { SILENT_REPLY_TOKEN } from "./tokens.js";
import { isAudio, transcribeInboundAudio } from "./transcription.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js";
@@ -1191,7 +1192,7 @@ export async function getReplyFromConfig(
return undefined; return undefined;
} }
let suppressedByHeartbeatAck = false; let didLogHeartbeatStrip = false;
try { try {
if (shouldEagerType) { if (shouldEagerType) {
await startTypingLoop(); await startTypingLoop();
@@ -1221,20 +1222,24 @@ export async function getReplyFromConfig(
runId, runId,
onPartialReply: opts?.onPartialReply onPartialReply: opts?.onPartialReply
? async (payload) => { ? async (payload) => {
if ( let text = payload.text;
!opts?.isHeartbeat && if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
payload.text?.includes(HEARTBEAT_TOKEN) const stripped = stripHeartbeatToken(text, { mode: "message" });
) { if (stripped.didStrip && !didLogHeartbeatStrip) {
suppressedByHeartbeatAck = true; didLogHeartbeatStrip = true;
logVerbose( logVerbose("Stripped stray HEARTBEAT_OK token from reply");
"Suppressing partial reply: detected HEARTBEAT_OK token", }
); if (
return; stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
} }
if (suppressedByHeartbeatAck) return; await startTypingOnText(text);
await startTypingOnText(payload.text);
await opts.onPartialReply?.({ await opts.onPartialReply?.({
text: payload.text, text,
mediaUrls: payload.mediaUrls, mediaUrls: payload.mediaUrls,
}); });
} }
@@ -1242,22 +1247,23 @@ export async function getReplyFromConfig(
shouldEmitToolResult, shouldEmitToolResult,
onToolResult: opts?.onToolResult onToolResult: opts?.onToolResult
? async (payload) => { ? async (payload) => {
if ( let text = payload.text;
!opts?.isHeartbeat && if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
payload.text?.includes(HEARTBEAT_TOKEN) const stripped = stripHeartbeatToken(text, { mode: "message" });
) { if (stripped.didStrip && !didLogHeartbeatStrip) {
suppressedByHeartbeatAck = true; didLogHeartbeatStrip = true;
logVerbose( logVerbose("Stripped stray HEARTBEAT_OK token from reply");
"Suppressing tool result: detected HEARTBEAT_OK token", }
); if (
return; stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
} }
if (suppressedByHeartbeatAck) return; await startTypingOnText(text);
await startTypingOnText(payload.text); await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
await opts.onToolResult?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
} }
: undefined, : undefined,
}); });
@@ -1288,22 +1294,28 @@ export async function getReplyFromConfig(
const payloadArray = runResult.payloads ?? []; const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return undefined; if (payloadArray.length === 0) return undefined;
if (
suppressedByHeartbeatAck || const sanitizedPayloads = opts?.isHeartbeat
(!opts?.isHeartbeat && ? payloadArray
payloadArray.some((payload) => payload.text?.includes(HEARTBEAT_TOKEN))) : payloadArray.flatMap((payload) => {
) { const text = payload.text;
logVerbose("Suppressing reply: detected HEARTBEAT_OK token"); if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
return undefined; const stripped = stripHeartbeatToken(text, { mode: "message" });
} if (stripped.didStrip && !didLogHeartbeatStrip) {
const shouldSignalTyping = payloadArray.some((payload) => { didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
if (sanitizedPayloads.length === 0) return undefined;
const shouldSignalTyping = sanitizedPayloads.some((payload) => {
const trimmed = payload.text?.trim(); const trimmed = payload.text?.trim();
if ( if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
trimmed &&
trimmed !== SILENT_REPLY_TOKEN &&
!trimmed.includes(HEARTBEAT_TOKEN)
)
return true;
if (payload.mediaUrl) return true; if (payload.mediaUrl) return true;
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false; return false;
@@ -1356,11 +1368,11 @@ export async function getReplyFromConfig(
} }
// If verbose is enabled and this is a new session, prepend a session hint. // If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = payloadArray; let finalPayloads = sanitizedPayloads;
if (resolvedVerboseLevel === "on" && isNewSession) { if (resolvedVerboseLevel === "on" && isNewSession) {
finalPayloads = [ finalPayloads = [
{ text: `🧭 New session: ${sessionIdFinal}` }, { text: `🧭 New session: ${sessionIdFinal}` },
...payloadArray, ...finalPayloads,
]; ];
} }

View File

@@ -266,7 +266,10 @@ function normalizeHeartbeatReply(
payload: ReplyPayload, payload: ReplyPayload,
responsePrefix?: string, responsePrefix?: string,
) { ) {
const stripped = stripHeartbeatToken(payload.text); const stripped = stripHeartbeatToken(payload.text, {
mode: "heartbeat",
maxAckChars: 30,
});
const hasMedia = Boolean( const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0, payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
); );

View File

@@ -185,7 +185,6 @@ export { stripHeartbeatToken };
function isSilentReply(payload?: ReplyPayload): boolean { function isSilentReply(payload?: ReplyPayload): boolean {
if (!payload) return false; if (!payload) return false;
const text = payload.text?.trim(); const text = payload.text?.trim();
if (text?.includes(HEARTBEAT_TOKEN)) return true;
if (!text || text !== SILENT_REPLY_TOKEN) return false; if (!text || text !== SILENT_REPLY_TOKEN) return false;
if (payload.mediaUrl || payload.mediaUrls?.length) return false; if (payload.mediaUrl || payload.mediaUrls?.length) return false;
return true; return true;
@@ -337,7 +336,10 @@ export async function runWebHeartbeatOnce(opts: {
const hasMedia = Boolean( const hasMedia = Boolean(
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0, replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
); );
const stripped = stripHeartbeatToken(replyPayload.text); const stripped = stripHeartbeatToken(replyPayload.text, {
mode: "heartbeat",
maxAckChars: 30,
});
if (stripped.shouldSkip && !hasMedia) { if (stripped.shouldSkip && !hasMedia) {
// Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.
const storePath = resolveStorePath(cfg.session?.store); const storePath = resolveStorePath(cfg.session?.store);
@@ -1034,6 +1036,7 @@ export async function monitorWebProvider(
} }
const responsePrefix = cfg.messages?.responsePrefix; const responsePrefix = cfg.messages?.responsePrefix;
let didLogHeartbeatStrip = false;
let didSendReply = false; let didSendReply = false;
let toolSendChain: Promise<void> = Promise.resolve(); let toolSendChain: Promise<void> = Promise.resolve();
const sendToolResult = (payload: ReplyPayload) => { const sendToolResult = (payload: ReplyPayload) => {
@@ -1046,6 +1049,20 @@ export async function monitorWebProvider(
} }
if (isSilentReply(payload)) return; if (isSilentReply(payload)) return;
const toolPayload: ReplyPayload = { ...payload }; const toolPayload: ReplyPayload = { ...payload };
if (toolPayload.text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(toolPayload.text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
}
const hasMedia = Boolean(
toolPayload.mediaUrl || (toolPayload.mediaUrls?.length ?? 0) > 0,
);
if (stripped.shouldSkip && !hasMedia) return;
toolPayload.text = stripped.text;
}
if ( if (
responsePrefix && responsePrefix &&
toolPayload.text && toolPayload.text &&
@@ -1134,6 +1151,20 @@ export async function monitorWebProvider(
await toolSendChain; await toolSendChain;
for (const replyPayload of sendableReplies) { for (const replyPayload of sendableReplies) {
if (replyPayload.text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(replyPayload.text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
}
const hasMedia = Boolean(
replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0,
);
if (stripped.shouldSkip && !hasMedia) continue;
replyPayload.text = stripped.text;
}
if ( if (
responsePrefix && responsePrefix &&
replyPayload.text && replyPayload.text &&