fix: filter NO_REPLY prefixes
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user