fix: filter NO_REPLY prefixes
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
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";
|
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||||
|
|
||||||
export function buildAgentSystemPrompt(params: {
|
export function buildAgentSystemPrompt(params: {
|
||||||
@@ -286,6 +287,18 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
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",
|
"## Heartbeats",
|
||||||
heartbeatPromptLine,
|
heartbeatPromptLine,
|
||||||
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
|
"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 { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.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 type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import { extractAudioTag } from "./audio-tags.js";
|
import { extractAudioTag } from "./audio-tags.js";
|
||||||
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||||
@@ -536,7 +536,10 @@ export async function runReplyAgent(params: {
|
|||||||
Boolean(taggedPayload.mediaUrl) ||
|
Boolean(taggedPayload.mediaUrl) ||
|
||||||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
(taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||||
if (!cleaned && !hasMedia) return;
|
if (!cleaned && !hasMedia) return;
|
||||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
|
if (
|
||||||
|
isSilentReplyText(cleaned, SILENT_REPLY_TOKEN) &&
|
||||||
|
!hasMedia
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const blockPayload: ReplyPayload = applyReplyToMode({
|
const blockPayload: ReplyPayload = applyReplyToMode({
|
||||||
...taggedPayload,
|
...taggedPayload,
|
||||||
@@ -745,7 +748,8 @@ export async function runReplyAgent(params: {
|
|||||||
|
|
||||||
const shouldSignalTyping = replyPayloads.some((payload) => {
|
const shouldSignalTyping = replyPayloads.some((payload) => {
|
||||||
const trimmed = payload.text?.trim();
|
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.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;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { registerAgentRunContext } from "../../infra/agent-events.js";
|
|||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||||
import type { OriginatingChannelType } from "../templating.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 { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import {
|
import {
|
||||||
@@ -80,7 +80,7 @@ export function createFollowupRunner(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
payload.text?.trim() === SILENT_REPLY_TOKEN &&
|
isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) &&
|
||||||
!payload.mediaUrl &&
|
!payload.mediaUrl &&
|
||||||
!payload.mediaUrls?.length
|
!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).";
|
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
|
||||||
const silenceLine =
|
const silenceLine =
|
||||||
activation === "always"
|
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;
|
: undefined;
|
||||||
const cautionLine =
|
const cautionLine =
|
||||||
activation === "always"
|
activation === "always"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
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";
|
import type { ReplyPayload } from "../types.js";
|
||||||
|
|
||||||
export type NormalizeReplyOptions = {
|
export type NormalizeReplyOptions = {
|
||||||
@@ -20,9 +24,11 @@ export function normalizeReplyPayload(
|
|||||||
if (!trimmed && !hasMedia) return null;
|
if (!trimmed && !hasMedia) return null;
|
||||||
|
|
||||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||||
if (trimmed === silentToken && !hasMedia) return null;
|
|
||||||
|
|
||||||
let text = payload.text ?? undefined;
|
let text = payload.text ?? undefined;
|
||||||
|
if (text && isSilentReplyText(text, silentToken)) {
|
||||||
|
if (!hasMedia) return null;
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
if (text && !trimmed) {
|
if (text && !trimmed) {
|
||||||
// Keep empty text when media exists so media-only replies still send.
|
// Keep empty text when media exists so media-only replies still send.
|
||||||
text = "";
|
text = "";
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ describe("createReplyDispatcher", () => {
|
|||||||
expect(dispatcher.sendFinalReply({})).toBe(false);
|
expect(dispatcher.sendFinalReply({})).toBe(false);
|
||||||
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
|
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
|
||||||
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
|
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
|
||||||
|
expect(
|
||||||
|
dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` }),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
await dispatcher.waitForIdle();
|
await dispatcher.waitForIdle();
|
||||||
expect(deliver).not.toHaveBeenCalled();
|
expect(deliver).not.toHaveBeenCalled();
|
||||||
@@ -54,12 +57,19 @@ describe("createReplyDispatcher", () => {
|
|||||||
mediaUrl: "file:///tmp/photo.jpg",
|
mediaUrl: "file:///tmp/photo.jpg",
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
dispatcher.sendFinalReply({
|
||||||
|
text: `${SILENT_REPLY_TOKEN} -- explanation`,
|
||||||
|
mediaUrl: "file:///tmp/photo.jpg",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
await dispatcher.waitForIdle();
|
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[0][0].text).toBe("PFX already");
|
||||||
expect(deliver.mock.calls[1][0].text).toBe("");
|
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 () => {
|
it("preserves ordering across tool, block, and final replies", async () => {
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ describe("routeReply", () => {
|
|||||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
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 () => {
|
it("applies responsePrefix when routing", async () => {
|
||||||
mocks.sendMessageSlack.mockClear();
|
mocks.sendMessageSlack.mockClear();
|
||||||
const cfg = {
|
const cfg = {
|
||||||
|
|||||||
@@ -1,2 +1,15 @@
|
|||||||
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||||
export const SILENT_REPLY_TOKEN = "NO_REPLY";
|
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([]);
|
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", () => {
|
it("splits media into separate messages by default", () => {
|
||||||
const messages = renderReplyPayloadsToMessages(
|
const messages = renderReplyPayloadsToMessages(
|
||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
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 { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type { MSTeamsReplyStyle } from "../config/types.js";
|
import type { MSTeamsReplyStyle } from "../config/types.js";
|
||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
@@ -107,14 +107,14 @@ function pushTextMessages(
|
|||||||
if (opts.chunkText) {
|
if (opts.chunkText) {
|
||||||
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
|
for (const chunk of chunkMarkdownText(text, opts.chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) return;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
|
||||||
out.push(trimmed);
|
out.push(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from "../auto-reply/reply/mentions.js";
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.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 { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotConfig,
|
ClawdbotConfig,
|
||||||
@@ -1934,7 +1934,8 @@ async function deliverReplies(params: {
|
|||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
for (const chunk of chunkMarkdownText(text, chunkLimit)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN))
|
||||||
|
continue;
|
||||||
await sendMessageSlack(params.target, trimmed, {
|
await sendMessageSlack(params.target, trimmed, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
threadTs,
|
threadTs,
|
||||||
@@ -2013,7 +2014,9 @@ async function deliverSlackSlashReplies(params: {
|
|||||||
for (const payload of params.replies) {
|
for (const payload of params.replies) {
|
||||||
const textRaw = payload.text?.trim() ?? "";
|
const textRaw = payload.text?.trim() ?? "";
|
||||||
const text =
|
const text =
|
||||||
textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined;
|
textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN)
|
||||||
|
? textRaw
|
||||||
|
: undefined;
|
||||||
const mediaList =
|
const mediaList =
|
||||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const combined = [
|
const combined = [
|
||||||
|
|||||||
Reference in New Issue
Block a user