fix: filter NO_REPLY prefixes

This commit is contained in:
Peter Steinberger
2026-01-09 23:29:01 +00:00
parent a9a70ea278
commit 96e17d407a
11 changed files with 85 additions and 16 deletions

View File

@@ -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:",

View File

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

View File

@@ -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
) {

View File

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

View File

@@ -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 = "";

View File

@@ -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 () => {

View File

@@ -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 = {

View File

@@ -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);
}

View File

@@ -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" }],

View File

@@ -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);
}

View File

@@ -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 = [