diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 4bac02e40..9c78aa018 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,4 +1,5 @@ import type { ThinkLevel } from "../auto-reply/thinking.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; export function buildAgentSystemPrompt(params: { @@ -286,6 +287,18 @@ export function buildAgentSystemPrompt(params: { } lines.push( + "## Silent Replies", + `When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`, + "", + "⚠️ Rules:", + "- It must be your ENTIRE message — nothing else", + `- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`, + "- Never wrap it in markdown or code blocks", + "", + `❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`, + `❌ Wrong: "${SILENT_REPLY_TOKEN}"`, + `✅ Right: ${SILENT_REPLY_TOKEN}`, + "", "## Heartbeats", heartbeatPromptLine, "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 03ab41f75..9d443b54e 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -32,7 +32,7 @@ import { import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; -import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { extractAudioTag } from "./audio-tags.js"; import { createBlockReplyPipeline } from "./block-reply-pipeline.js"; @@ -536,7 +536,10 @@ export async function runReplyAgent(params: { Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0; if (!cleaned && !hasMedia) return; - if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) + if ( + isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) && + !hasMedia + ) return; const blockPayload: ReplyPayload = applyReplyToMode({ ...taggedPayload, @@ -745,7 +748,8 @@ export async function runReplyAgent(params: { const shouldSignalTyping = replyPayloads.some((payload) => { const trimmed = payload.text?.trim(); - if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true; + if (trimmed && !isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) + return true; if (payload.mediaUrl) return true; if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; return false; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 986740726..f031c9957 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -11,7 +11,7 @@ import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType } from "../templating.js"; -import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { FollowupRun } from "./queue.js"; import { @@ -80,7 +80,7 @@ export function createFollowupRunner(params: { continue; } if ( - payload.text?.trim() === SILENT_REPLY_TOKEN && + isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) && !payload.mediaUrl && !payload.mediaUrls?.length ) { diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index a50023991..f3445001a 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -215,7 +215,7 @@ export function buildGroupIntro(params: { : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; const silenceLine = activation === "always" - ? `If no response is needed, reply with exactly "${params.silentToken}" (no other text) so Clawdbot stays silent.` + ? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so Clawdbot stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.` : undefined; const cautionLine = activation === "always" diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 4e70a0b81..7c451bbb3 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,5 +1,9 @@ import { stripHeartbeatToken } from "../heartbeat.js"; -import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { + HEARTBEAT_TOKEN, + isSilentReplyText, + SILENT_REPLY_TOKEN, +} from "../tokens.js"; import type { ReplyPayload } from "../types.js"; export type NormalizeReplyOptions = { @@ -20,9 +24,11 @@ export function normalizeReplyPayload( if (!trimmed && !hasMedia) return null; const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; - if (trimmed === silentToken && !hasMedia) return null; - let text = payload.text ?? undefined; + if (text && isSilentReplyText(text, silentToken)) { + if (!hasMedia) return null; + text = ""; + } if (text && !trimmed) { // Keep empty text when media exists so media-only replies still send. text = ""; diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-dispatcher.test.ts index dee7795d2..9493dd5a1 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-dispatcher.test.ts @@ -10,6 +10,9 @@ describe("createReplyDispatcher", () => { expect(dispatcher.sendFinalReply({})).toBe(false); expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); + expect( + dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` }), + ).toBe(false); await dispatcher.waitForIdle(); expect(deliver).not.toHaveBeenCalled(); @@ -54,12 +57,19 @@ describe("createReplyDispatcher", () => { mediaUrl: "file:///tmp/photo.jpg", }), ).toBe(true); + expect( + dispatcher.sendFinalReply({ + text: `${SILENT_REPLY_TOKEN} -- explanation`, + mediaUrl: "file:///tmp/photo.jpg", + }), + ).toBe(true); await dispatcher.waitForIdle(); - expect(deliver).toHaveBeenCalledTimes(2); + expect(deliver).toHaveBeenCalledTimes(3); expect(deliver.mock.calls[0][0].text).toBe("PFX already"); expect(deliver.mock.calls[1][0].text).toBe(""); + expect(deliver.mock.calls[2][0].text).toBe(""); }); it("preserves ordering across tool, block, and final replies", async () => { diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 07a620d1d..41fd9a8e2 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -81,6 +81,18 @@ describe("routeReply", () => { expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); }); + it("drops payloads that start with the silent token", async () => { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + }); + it("applies responsePrefix when routing", async () => { mocks.sendMessageSlack.mockClear(); const cfg = { diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 917ddd095..62a99c157 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -1,2 +1,15 @@ export const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; export const SILENT_REPLY_TOKEN = "NO_REPLY"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function isSilentReplyText( + text: string | undefined, + token: string = SILENT_REPLY_TOKEN, +): boolean { + if (!text) return false; + const re = new RegExp(`^\\s*${escapeRegExp(token)}(?=$|\\W)`); + return re.test(text); +} diff --git a/src/msteams/messenger.test.ts b/src/msteams/messenger.test.ts index 2da449d4f..a73ecdbd1 100644 --- a/src/msteams/messenger.test.ts +++ b/src/msteams/messenger.test.ts @@ -18,6 +18,14 @@ describe("msteams messenger", () => { expect(messages).toEqual([]); }); + it("filters silent reply prefixes", () => { + const messages = renderReplyPayloadsToMessages( + [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], + { textChunkLimit: 4000 }, + ); + expect(messages).toEqual([]); + }); + it("splits media into separate messages by default", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], diff --git a/src/msteams/messenger.ts b/src/msteams/messenger.ts index 82c970002..177981a36 100644 --- a/src/msteams/messenger.ts +++ b/src/msteams/messenger.ts @@ -1,5 +1,5 @@ import { chunkMarkdownText } from "../auto-reply/chunk.js"; -import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { MSTeamsReplyStyle } from "../config/types.js"; import type { StoredConversationReference } from "./conversation-store.js"; @@ -107,14 +107,14 @@ function pushTextMessages( if (opts.chunkText) { for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) { const trimmed = chunk.trim(); - if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue; out.push(trimmed); } return; } const trimmed = text.trim(); - if (!trimmed || trimmed === SILENT_REPLY_TOKEN) return; + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return; out.push(trimmed); } diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 11be09501..5ddbd9de2 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -29,7 +29,7 @@ import { } from "../auto-reply/reply/mentions.js"; import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; -import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ClawdbotConfig, @@ -1934,7 +1934,8 @@ async function deliverReplies(params: { if (mediaList.length === 0) { for (const chunk of chunkMarkdownText(text, chunkLimit)) { const trimmed = chunk.trim(); - if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) + continue; await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs, @@ -2013,7 +2014,9 @@ async function deliverSlackSlashReplies(params: { for (const payload of params.replies) { const textRaw = payload.text?.trim() ?? ""; const text = - textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined; + textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) + ? textRaw + : undefined; const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const combined = [