From 89684fab22bbd3135344c600600edbfae994344c Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:44:35 -0500 Subject: [PATCH] feat: add no_reply tuning by conversation type --- .../telegram/src/bot-message-dispatch.test.ts | 80 ++++++++++++ .../telegram/src/bot-message-dispatch.ts | 36 +++++- .../telegram/src/bot-native-commands.ts | 4 +- .../telegram/src/bot/delivery.replies.ts | 37 +++++- extensions/telegram/src/bot/delivery.test.ts | 33 ++++- src/auto-reply/dispatch.test.ts | 33 +++++ src/auto-reply/dispatch.ts | 28 ++++- src/auto-reply/reply/dispatch-from-config.ts | 4 + src/auto-reply/reply/reply-dispatcher.ts | 78 ++++++++++-- src/auto-reply/reply/reply-flow.test.ts | 64 ++++++++++ src/auto-reply/reply/route-reply.test.ts | 46 ++++++- src/auto-reply/reply/route-reply.ts | 12 +- src/config/silent-reply.test.ts | 107 ++++++++++++++++ src/config/silent-reply.ts | 48 +++++++ src/config/types.agent-defaults.ts | 12 +- src/config/types.openclaw.ts | 12 +- src/config/zod-schema.agent-defaults.ts | 20 +++ src/config/zod-schema.ts | 15 +++ src/infra/outbound/payloads.test.ts | 119 ++++++++++++++++++ src/infra/outbound/payloads.ts | 109 ++++++++++++++-- src/infra/outbound/session-context.ts | 6 + src/plugin-sdk/outbound-runtime.ts | 4 + src/shared/silent-reply-policy.test.ts | 90 +++++++++++++ src/shared/silent-reply-policy.ts | 111 ++++++++++++++++ 24 files changed, 1072 insertions(+), 36 deletions(-) create mode 100644 src/config/silent-reply.test.ts create mode 100644 src/config/silent-reply.ts create mode 100644 src/shared/silent-reply-policy.test.ts create mode 100644 src/shared/silent-reply-policy.ts diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 7efc056eba2..88effbad940 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveChunkMode as resolveChunkModeRuntime } from "../../../src/auto-reply/chunk.js"; import { resolveMarkdownTableMode as resolveMarkdownTableModeRuntime } from "../../../src/config/markdown-tables.js"; import { resolveSessionStoreEntry as resolveSessionStoreEntryRuntime } from "../../../src/config/sessions/store.js"; +import type { OpenClawConfig } from "../../../src/config/types.openclaw.js"; import { getAgentScopedMediaLocalRoots as getAgentScopedMediaLocalRootsRuntime } from "../../../src/media/local-roots.js"; import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label.js"; import type { TelegramBotDeps } from "./bot-deps.js"; @@ -2550,6 +2551,85 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("rewrites a no-visible-response DM turn through silent-reply fallback", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + }); + deliverReplies.mockResolvedValueOnce({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "agent:main:telegram:direct:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(deliverReplies).toHaveBeenCalledTimes(1); + const deliveredReplies = deliverReplies.mock.calls[0]?.[0]?.replies; + expect(Array.isArray(deliveredReplies)).toBe(true); + expect(deliveredReplies?.[0]?.text).toEqual(expect.any(String)); + expect(deliveredReplies?.[0]?.text?.trim()).not.toBe("NO_REPLY"); + }); + + it("keeps no-visible-response group turns silent when policy allows silence", async () => { + const draftStream = createDraftStream(999); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + }); + + await dispatchWithContext({ + context: createContext({ + isGroup: true, + primaryCtx: { + message: { chat: { id: 123, type: "supergroup" } }, + } as TelegramMessageContext["primaryCtx"], + msg: { + chat: { id: 123, type: "supergroup" }, + message_id: 456, + message_thread_id: 777, + } as TelegramMessageContext["msg"], + threadSpec: { id: 777, scope: "group" }, + ctxPayload: { + SessionKey: "agent:main:telegram:group:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig, + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + }); + it("sends fallback and clears preview when deliver throws (dispatcher swallows error)", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index e2ba1b19ff5..d8f45753633 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -12,11 +12,15 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "openclaw/plugin-sdk/outbound-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { isAbortRequestText, type ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger, danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import { @@ -65,6 +69,7 @@ import { editMessageTelegram } from "./send.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; +const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-dispatch"); /** Minimum chars before sending first streaming message (improves push notification UX) */ const DRAFT_MIN_INITIAL_CHARS = 30; @@ -1062,6 +1067,35 @@ export const dispatchTelegramMessage = async ({ sentFallback = result.delivered; } + if (!queuedFinal && !sentFallback && !dispatchError) { + const policySessionKey = + ctxPayload.CommandSource === "native" + ? (ctxPayload.CommandTargetSessionKey ?? ctxPayload.SessionKey) + : ctxPayload.SessionKey; + const silentReplyFallback = projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey: policySessionKey, + surface: "telegram", + }), + ); + if (silentReplyFallback.length > 0) { + const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ + replies: silentReplyFallback, + ...deliveryBaseOptions, + silent: false, + mediaLoader: telegramDeps.loadWebMedia, + }); + sentFallback = result.delivered; + } + silentReplyDispatchLogger.info("telegram turn ended without visible final response", { + sessionKey: policySessionKey, + chatId: String(chatId), + queuedFinal, + sentFallback, + }); + } + const hasFinalResponse = queuedFinal || sentFallback; if (statusReactionController && !hasFinalResponse) { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index dd9bc72c800..566209af6a8 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -19,7 +19,6 @@ import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import type { ReplyToMode, TelegramAccountConfig, - TelegramDirectConfig, TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-runtime"; @@ -322,7 +321,7 @@ async function resolveTelegramCommandAuth(params: { !isGroup && groupConfig && "dmPolicy" in groupConfig ? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing") : (telegramCfg.dmPolicy ?? "pairing"); - const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic; + const requireTopic = groupConfig?.requireTopic; if (!isGroup && requireTopic === true && dmThreadId == null) { logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`); return null; @@ -694,6 +693,7 @@ export const registerTelegramNativeCommands = ({ chunkMode: TelegramChunkMode; linkPreview?: boolean; }) => ({ + cfg, chatId: String(params.chatId), accountId: params.accountId, sessionKeyForInternalHooks: params.sessionKeyForInternalHooks, diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 38c41318743..2d893ed5b6a 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -11,11 +11,16 @@ import { } from "openclaw/plugin-sdk/hook-runtime"; import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime"; +import { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "openclaw/plugin-sdk/outbound-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import type { TelegramInlineButtons } from "../button-types.js"; @@ -45,6 +50,7 @@ const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const CAPTION_TOO_LONG_RE = /caption is too long/i; const GrammyErrorCtor: typeof GrammyError | undefined = typeof GrammyError === "function" ? GrammyError : undefined; +const silentReplyLogger = createSubsystemLogger("telegram/silent-reply"); type DeliveryProgress = ReplyThreadDeliveryProgress & { deliveredCount: number; @@ -581,6 +587,7 @@ export function emitTelegramMessageSentHooks(params: EmitMessageSentHookParams): export async function deliverReplies(params: { replies: ReplyPayload[]; + cfg?: import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; chatId: string; accountId?: string; sessionKeyForInternalHooks?: string; @@ -620,7 +627,35 @@ export async function deliverReplies(params: { chunkMode: params.chunkMode ?? "length", tableMode: params.tableMode, }); - for (const originalReply of params.replies) { + const candidateReplies: ReplyPayload[] = []; + for (const reply of params.replies) { + if (!reply || typeof reply !== "object") { + params.runtime.error?.(danger("reply missing text/media")); + continue; + } + candidateReplies.push(reply); + } + const normalizedReplies = projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan(candidateReplies, { + cfg: params.cfg, + sessionKey: params.sessionKeyForInternalHooks, + surface: "telegram", + }), + ); + const originalExactSilentCount = candidateReplies.filter( + (reply) => typeof reply.text === "string" && reply.text.trim().toUpperCase() === "NO_REPLY", + ).length; + if (originalExactSilentCount > 0) { + silentReplyLogger.info("telegram delivery normalized NO_REPLY candidates", { + sessionKey: params.sessionKeyForInternalHooks, + chatId: params.chatId, + originalCount: candidateReplies.length, + normalizedCount: normalizedReplies.length, + originalExactSilentCount, + normalizedTexts: normalizedReplies.map((reply) => reply.text ?? ""), + }); + } + for (const originalReply of normalizedReplies) { let reply = originalReply; const mediaList = reply?.mediaUrls?.length ? reply.mediaUrls diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 705910f6cbc..852f29adccb 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1,7 +1,6 @@ import type { Bot } from "grammy"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeEach, describe, expect, it, vi } from "vitest"; - const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -294,6 +293,38 @@ describe("deliverReplies", () => { expect(triggerInternalHook).not.toHaveBeenCalled(); }); + it("rewrites exact NO_REPLY for direct Telegram sessions", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 12, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:direct:123", + replies: [{ text: "NO_REPLY" }], + runtime, + bot, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY"); + }); + + it("suppresses exact NO_REPLY for group Telegram sessions", async () => { + const runtime = createRuntime(false); + const sendMessage = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + sessionKeyForInternalHooks: "agent:test:telegram:group:123", + replies: [{ text: "NO_REPLY" }], + runtime, + bot, + }); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("emits internal message:sent with success=false on delivery failure", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockRejectedValue(new Error("network error")); diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 16429ece672..7245c53fca8 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -167,4 +167,37 @@ describe("withReplyDispatcher", () => { expect(typing.markRunComplete).toHaveBeenCalledTimes(1); expect(typing.markDispatchIdle).toHaveBeenCalled(); }); + + it("uses CommandTargetSessionKey for silent-reply policy on native command turns", async () => { + hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ + dispatcher: createDispatcher([]), + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + }); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); + + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: buildTestCtx({ + SessionKey: "agent:test:telegram:slash:8231046597", + CommandSource: "native", + CommandTargetSessionKey: "agent:test:telegram:direct:8231046597", + Surface: "telegram", + }), + cfg: {} as OpenClawConfig, + dispatcherOptions: { + deliver: async () => undefined, + }, + replyResolver: async () => ({ text: "ok" }), + }); + + expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith( + expect.objectContaining({ + silentReplyContext: expect.objectContaining({ + sessionKey: "agent:test:telegram:direct:8231046597", + surface: "telegram", + }), + }), + ); + }); }); diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index bfec4555706..d531d7cba92 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -14,6 +14,22 @@ import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js"; import type { FinalizedMsgContext, MsgContext } from "./templating.js"; import type { GetReplyOptions } from "./types.js"; +function resolveDispatcherSilentReplyContext( + ctx: MsgContext | FinalizedMsgContext, + cfg: OpenClawConfig, +) { + const finalized = finalizeInboundContext(ctx); + const policySessionKey = + finalized.CommandSource === "native" + ? (finalized.CommandTargetSessionKey ?? finalized.SessionKey) + : finalized.SessionKey; + return { + cfg, + sessionKey: policySessionKey, + surface: finalized.Surface ?? finalized.Provider, + }; +} + export type DispatchInboundResult = DispatchFromConfigResult; export { withReplyDispatcher } from "./dispatch-dispatcher.js"; @@ -45,8 +61,12 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { + const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg); const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = - createReplyDispatcherWithTyping(params.dispatcherOptions); + createReplyDispatcherWithTyping({ + ...params.dispatcherOptions, + silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, + }); try { return await dispatchInboundMessage({ ctx: params.ctx, @@ -71,7 +91,11 @@ export async function dispatchInboundMessageWithDispatcher(params: { replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { - const dispatcher = createReplyDispatcher(params.dispatcherOptions); + const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg); + const dispatcher = createReplyDispatcher({ + ...params.dispatcherOptions, + silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext, + }); return await dispatchInboundMessage({ ctx: params.ctx, cfg: params.cfg, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 5c9e5a5500d..0ed2177118c 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -381,6 +381,10 @@ export async function dispatchReplyFromConfig( channel: originatingChannel, to: originatingTo, sessionKey: ctx.SessionKey, + policySessionKey: + ctx.CommandSource === "native" + ? (ctx.CommandTargetSessionKey ?? ctx.SessionKey) + : ctx.SessionKey, accountId: ctx.AccountId, requesterSenderId: ctx.SenderId, requesterSenderName: ctx.SenderName, diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 616f0ca4560..814411dba65 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,7 +1,12 @@ import type { TypingCallbacks } from "../../channels/typing.js"; +import { resolveSilentReplyPolicy } from "../../config/silent-reply.js"; import type { HumanDelayConfig } from "../../config/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { generateSecureInt } from "../../infra/secure-random.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { sleep } from "../../utils.js"; +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { registerDispatcher } from "./dispatcher-registry.js"; import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js"; @@ -25,6 +30,7 @@ type ReplyDispatchDeliverer = ( const DEFAULT_HUMAN_DELAY_MIN_MS = 800; const DEFAULT_HUMAN_DELAY_MAX_MS = 2500; +const silentReplyLogger = createSubsystemLogger("silent-reply/dispatcher"); /** Generate a random delay within the configured range. */ function getHumanDelay(config: HumanDelayConfig | undefined): number { @@ -44,6 +50,12 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number { export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; + silentReplyContext?: { + cfg?: OpenClawConfig; + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; + }; responsePrefix?: string; transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null; /** Static context for response prefix template interpolation. */ @@ -103,6 +115,39 @@ function normalizeReplyPayloadInternal( }); } +function shouldPreserveSilentFinalPayload(params: { + kind: ReplyDispatchKind; + payload: ReplyPayload; + silentReplyContext?: ReplyDispatcherOptions["silentReplyContext"]; +}): boolean { + if (params.kind !== "final") { + return false; + } + if (!isSilentReplyText(params.payload.text, SILENT_REPLY_TOKEN)) { + return false; + } + const context = params.silentReplyContext; + if (!context) { + return false; + } + const resolvedPolicy = resolveSilentReplyPolicy({ + cfg: context.cfg, + sessionKey: context.sessionKey, + surface: context.surface, + conversationType: context.conversationType, + }); + const shouldPreserve = resolvedPolicy !== "allow"; + if (shouldPreserve) { + silentReplyLogger.info("preserving exact NO_REPLY final payload before normalization", { + sessionKey: context.sessionKey, + surface: context.surface, + conversationType: context.conversationType, + resolvedPolicy, + }); + } + return shouldPreserve; +} + export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher { let sendChain: Promise = Promise.resolve(); // Track in-flight deliveries so we can emit a reliable "idle" signal. @@ -131,15 +176,32 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis }); const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { - const normalized = normalizeReplyPayloadInternal(payload, { - responsePrefix: options.responsePrefix, - responsePrefixContext: options.responsePrefixContext, - responsePrefixContextProvider: options.responsePrefixContextProvider, - transformReplyPayload: options.transformReplyPayload, - onHeartbeatStrip: options.onHeartbeatStrip, - onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), - }); + const originalWasExactSilent = isSilentReplyText(payload.text, SILENT_REPLY_TOKEN); + const normalized = shouldPreserveSilentFinalPayload({ + kind, + payload, + silentReplyContext: options.silentReplyContext, + }) + ? { + ...payload, + text: payload.text?.trim() || SILENT_REPLY_TOKEN, + } + : normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + transformReplyPayload: options.transformReplyPayload, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) { + if (kind === "final" && originalWasExactSilent) { + silentReplyLogger.info("exact NO_REPLY final payload was skipped before delivery", { + sessionKey: options.silentReplyContext?.sessionKey, + surface: options.silentReplyContext?.surface, + conversationType: options.silentReplyContext?.conversationType, + }); + } return false; } queuedCounts[kind] += 1; diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index d18b1e53dbd..8a36850a216 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; import { createReplyDispatcher } from "./reply-dispatcher.js"; import { createReplyToModeFilter } from "./reply-threading.js"; @@ -20,6 +21,69 @@ describe("createReplyDispatcher", () => { expect(deliver.mock.calls[1]?.[0]?.text).toBe(`interject.${SILENT_REPLY_TOKEN}`); }); + it("preserves exact NO_REPLY final payloads for direct sessions where silence is disallowed", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + const dispatcher = createReplyDispatcher({ + deliver, + silentReplyContext: { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }, + }); + + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true); + + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0]?.[0]?.text).toBe(SILENT_REPLY_TOKEN); + }); + + it("still drops exact NO_REPLY final payloads for group sessions where silence is allowed", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + const dispatcher = createReplyDispatcher({ + deliver, + silentReplyContext: { + cfg, + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }, + }); + + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); + + await dispatcher.waitForIdle(); + expect(deliver).not.toHaveBeenCalled(); + }); + it("strips heartbeat tokens and applies responsePrefix", async () => { const deliver = vi.fn().mockResolvedValue(undefined); const onHeartbeatStrip = vi.fn(); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 0b3e0454485..47cd3a81e94 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -16,9 +16,15 @@ const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(), })); -vi.mock("../../infra/outbound/deliver-runtime.js", () => ({ - deliverOutboundPayloads: mocks.deliverOutboundPayloads, -})); +vi.mock("../../infra/outbound/deliver-runtime.js", async () => { + const actual = await vi.importActual( + "../../infra/outbound/deliver-runtime.js", + ); + return { + ...actual, + deliverOutboundPayloads: mocks.deliverOutboundPayloads, + }; +}); const { routeReply } = await import("./route-reply.js"); @@ -222,6 +228,40 @@ describe("routeReply", () => { }); }); + it("passes policySessionKey through to outbound delivery for native command targets", async () => { + const cfg = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig; + + const res = await routeReply({ + payload: { text: SILENT_REPLY_TOKEN }, + channel: "telegram", + to: "8231046597", + cfg, + sessionKey: "agent:test:telegram:slash:8231046597", + policySessionKey: "agent:test:telegram:direct:8231046597", + }); + + expect(res.ok).toBe(true); + expectLastDelivery({ + session: expect.objectContaining({ + key: "agent:test:telegram:slash:8231046597", + policyKey: "agent:test:telegram:direct:8231046597", + }), + }); + }); + it("applies responsePrefix when routing", async () => { const cfg = { messages: { responsePrefix: "[openclaw]" }, diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index f580199bc7e..fd6604483a3 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -44,6 +44,8 @@ export type RouteReplyParams = { to: string; /** Session key for deriving agent identity defaults (multi-agent). */ sessionKey?: string; + /** Session key for policy resolution when native-command delivery targets a different session. */ + policySessionKey?: string; /** Provider account id (multi-account). */ accountId?: string; /** Originating sender id for sender-scoped outbound media policy. */ @@ -93,11 +95,10 @@ export async function routeReply(params: RouteReplyParams): Promise { + it("uses the default direct/group/internal policy and rewrite flags", () => { + expect(resolveSilentReplyPolicy({ surface: "webchat" })).toBe("disallow"); + expect(resolveSilentReplyRewriteEnabled({ surface: "webchat" })).toBe(true); + expect( + resolveSilentReplyPolicy({ + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ).toBe("allow"); + expect( + resolveSilentReplyRewriteEnabled({ + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ).toBe(false); + expect( + resolveSilentReplyPolicy({ + sessionKey: "agent:main:subagent:abc", + }), + ).toBe("allow"); + }); + + it("applies configured defaults by conversation type", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "disallow", + internal: "allow", + }, + silentReplyRewrite: { + direct: false, + group: true, + internal: false, + }, + }, + }, + }; + + expect(resolveSilentReplyPolicy({ cfg, surface: "webchat" })).toBe("disallow"); + expect(resolveSilentReplyRewriteEnabled({ cfg, surface: "webchat" })).toBe(false); + expect( + resolveSilentReplyPolicy({ + cfg, + sessionKey: "agent:main:discord:group:123", + surface: "discord", + }), + ).toBe("disallow"); + expect( + resolveSilentReplyRewriteEnabled({ + cfg, + sessionKey: "agent:main:discord:group:123", + surface: "discord", + }), + ).toBe(true); + }); + + it("lets surface overrides beat the default policy and rewrite flags", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + group: false, + internal: false, + }, + }, + }, + surfaces: { + telegram: { + silentReply: { + direct: "allow", + }, + silentReplyRewrite: { + direct: false, + }, + }, + }, + }; + + expect( + resolveSilentReplyPolicy({ + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ).toBe("allow"); + expect( + resolveSilentReplyRewriteEnabled({ + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ).toBe(false); + }); +}); diff --git a/src/config/silent-reply.ts b/src/config/silent-reply.ts new file mode 100644 index 00000000000..9dd101e19f0 --- /dev/null +++ b/src/config/silent-reply.ts @@ -0,0 +1,48 @@ +import { + classifySilentReplyConversationType, + resolveSilentReplyPolicyFromPolicies, + resolveSilentReplyRewriteFromPolicies, + type SilentReplyConversationType, + type SilentReplyPolicy, +} from "../shared/silent-reply-policy.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +type ResolveSilentReplyParams = { + cfg?: OpenClawConfig; + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; +}; + +export function resolveSilentReplyPolicy(params: ResolveSilentReplyParams): SilentReplyPolicy { + const conversationType = classifySilentReplyConversationType({ + sessionKey: params.sessionKey, + surface: params.surface, + conversationType: params.conversationType, + }); + const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface); + return resolveSilentReplyPolicyFromPolicies({ + conversationType, + defaultPolicy: params.cfg?.agents?.defaults?.silentReply, + surfacePolicy: normalizedSurface + ? params.cfg?.surfaces?.[normalizedSurface]?.silentReply + : undefined, + }); +} + +export function resolveSilentReplyRewriteEnabled(params: ResolveSilentReplyParams): boolean { + const conversationType = classifySilentReplyConversationType({ + sessionKey: params.sessionKey, + surface: params.surface, + conversationType: params.conversationType, + }); + const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface); + return resolveSilentReplyRewriteFromPolicies({ + conversationType, + defaultRewrite: params.cfg?.agents?.defaults?.silentReplyRewrite, + surfaceRewrite: normalizedSurface + ? params.cfg?.surfaces?.[normalizedSurface]?.silentReplyRewrite + : undefined, + }); +} diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index e0c98d05a67..069d4d6a685 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -1,3 +1,7 @@ +import type { + SilentReplyPolicyShape, + SilentReplyRewriteShape, +} from "../shared/silent-reply-policy.js"; import type { AgentEmbeddedHarnessConfig, AgentModelConfig, @@ -191,6 +195,10 @@ export type AgentDefaultsConfig = { workspace?: string; /** Optional default allowlist of skills for agents that do not set agents.list[].skills. */ skills?: string[]; + /** Silent-reply policy by conversation type. */ + silentReply?: SilentReplyPolicyShape; + /** Whether disallowed silent replies should be rewritten by conversation type. */ + silentReplyRewrite?: SilentReplyRewriteShape; /** Optional repository root for system prompt runtime line (overrides auto-detect). */ repoRoot?: string; /** Optional full system prompt replacement. Primarily for prompt debugging and controlled experiments. */ @@ -205,9 +213,9 @@ export type AgentDefaultsConfig = { * transcript already contains a completed assistant turn */ contextInjection?: AgentContextInjection; - /** Max chars for injected bootstrap files before truncation (default: 12000). */ + /** Max chars for injected bootstrap files before truncation (default: 20000). */ bootstrapMaxChars?: number; - /** Max total chars across all injected bootstrap files (default: 60000). */ + /** Max total chars across all injected bootstrap files (default: 150000). */ bootstrapTotalMaxChars?: number; /** Experimental agent-default flags. Keep off unless you are intentionally testing a preview surface. */ experimental?: { diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index bac95be0d48..25759ba80dd 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -1,3 +1,7 @@ +import type { + SilentReplyPolicyShape, + SilentReplyRewriteShape, +} from "../shared/silent-reply-policy.js"; import type { AcpConfig } from "./types.acp.js"; import type { AgentBinding, AgentsConfig } from "./types.agents.js"; import type { ApprovalsConfig } from "./types.approvals.js"; @@ -29,9 +33,12 @@ import type { SecretsConfig } from "./types.secrets.js"; import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; +export type SurfaceConfigEntry = { + silentReply?: SilentReplyPolicyShape; + silentReplyRewrite?: SilentReplyRewriteShape; +}; + export type OpenClawConfig = { - /** JSON Schema URL for editor tooling (VS Code, etc.). Preserved across config rewrites. */ - $schema?: string; meta?: { /** Last OpenClaw version that wrote this config. */ lastTouchedVersion?: string; @@ -97,6 +104,7 @@ export type OpenClawConfig = { secrets?: SecretsConfig; skills?: SkillsConfig; plugins?: PluginsConfig; + surfaces?: Record; models?: ModelsConfig; nodeHost?: NodeHostConfig; agents?: AgentsConfig; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 4a553a5e226..28370af8784 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -17,6 +17,24 @@ import { TypingModeSchema, } from "./zod-schema.core.js"; +export const SilentReplyPolicySchema = z.union([z.literal("allow"), z.literal("disallow")]); + +export const SilentReplyPolicyConfigSchema = z + .object({ + direct: SilentReplyPolicySchema.optional(), + group: SilentReplyPolicySchema.optional(), + internal: SilentReplyPolicySchema.optional(), + }) + .strict(); + +export const SilentReplyRewriteConfigSchema = z + .object({ + direct: z.boolean().optional(), + group: z.boolean().optional(), + internal: z.boolean().optional(), + }) + .strict(); + export const AgentDefaultsSchema = z .object({ /** Global default provider params applied to all models before per-model and per-agent overrides. */ @@ -47,6 +65,8 @@ export const AgentDefaultsSchema = z .optional(), workspace: z.string().optional(), skills: z.array(z.string()).optional(), + silentReply: SilentReplyPolicyConfigSchema.optional(), + silentReplyRewrite: SilentReplyRewriteConfigSchema.optional(), repoRoot: z.string().optional(), systemPromptOverride: z.string().optional(), skipBootstrap: z.boolean().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f80b9f7c892..ce8c7a1d63c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -5,6 +5,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { + SilentReplyPolicyConfigSchema, + SilentReplyRewriteConfigSchema, +} from "./zod-schema.agent-defaults.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; import { ApprovalsSchema } from "./zod-schema.approvals.js"; @@ -964,6 +968,17 @@ export const OpenClawSchema = z }) .strict() .optional(), + surfaces: z + .record( + z.string(), + z + .object({ + silentReply: SilentReplyPolicyConfigSchema.optional(), + silentReplyRewrite: SilentReplyRewriteConfigSchema.optional(), + }) + .strict(), + ) + .optional(), }) .strict() .superRefine((cfg, ctx) => { diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index 4fd4ff267d6..eeefec8377a 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -1,6 +1,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { describe, expect, it } from "vitest"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { typedCases } from "../../test-utils/typed-cases.js"; import { createOutboundPayloadPlan, @@ -187,6 +188,124 @@ describe("normalizeReplyPayloadsForDelivery", () => { ]); }); + it("rewrites bare silent replies for direct conversations when requested", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + const sessionKey = "agent:main:telegram:direct:123"; + const projected = projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey, + surface: "telegram", + }), + ); + expect(projected).toHaveLength(1); + expect(projected[0]?.text).toEqual(expect.any(String)); + expect(projected[0]?.text?.trim()).not.toBe("NO_REPLY"); + }); + + it("drops bare silent replies for groups when policy allows silence", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + expect( + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ), + ).toEqual([]); + }); + + it("does not add rewrite chatter when visible content is already being delivered", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }; + + expect( + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }, { text: "visible reply" }], { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ), + ).toEqual([ + expect.objectContaining({ + text: "visible reply", + }), + ]); + }); + + it("keeps bare NO_REPLY visible when silence is disallowed but rewrite is off", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: false, + }, + }, + }, + }; + + expect( + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([{ text: "NO_REPLY" }], { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }), + ), + ).toEqual([ + expect.objectContaining({ + text: "NO_REPLY", + }), + ]); + }); + it("is idempotent for already-normalized delivery payloads", () => { const once = normalizeReplyPayloadsForDelivery([ { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index dfa47dd9795..d9c09569cfb 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -6,12 +6,21 @@ import { shouldSuppressReasoningPayload, } from "../../auto-reply/reply/reply-payloads.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import { + resolveSilentReplyPolicy, + resolveSilentReplyRewriteEnabled, +} from "../../config/silent-reply.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasInteractiveReplyBlocks, hasReplyChannelData, hasReplyPayloadContent, type InteractiveReply, } from "../../interactive/payload.js"; +import { + resolveSilentReplyRewriteText, + type SilentReplyConversationType, +} from "../../shared/silent-reply-policy.js"; export type NormalizedOutboundPayload = { text: string; @@ -37,6 +46,13 @@ export type OutboundPayloadPlan = { hasChannelData: boolean; }; +type OutboundPayloadPlanContext = { + cfg?: OpenClawConfig; + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; +}; + export type OutboundPayloadMirror = { text: string; mediaUrls: string[]; @@ -89,7 +105,16 @@ function mergeMediaUrls(...lists: Array | unde return merged; } -function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadPlan | null { +type PreparedOutboundPayloadPlanEntry = { + payload: ReplyPayload; + hasInteractive: boolean; + hasChannelData: boolean; + isSilent: boolean; +}; + +function createOutboundPayloadPlanEntry( + payload: ReplyPayload, +): PreparedOutboundPayloadPlanEntry | null { if (shouldSuppressReasoningPayload(payload)) { return null; } @@ -104,9 +129,7 @@ function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadP if (isSuppressedRelayStatusText(parsedText) && mergedMedia.length === 0) { return null; } - if (parsed.isSilent && mergedMedia.length === 0) { - return null; - } + const isSilent = parsed.isSilent && mergedMedia.length === 0; const hasMultipleMedia = (explicitMediaUrls?.length ?? 0) > 1; const resolvedMediaUrl = hasMultipleMedia ? undefined : explicitMediaUrl; const normalizedPayload: ReplyPayload = { @@ -123,32 +146,100 @@ function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadP replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent, audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice), }; - if (!isRenderablePayload(normalizedPayload)) { + if (!isRenderablePayload(normalizedPayload) && !isSilent) { return null; } - const parts = resolveSendableOutboundReplyParts(normalizedPayload); const hasChannelData = hasReplyChannelData(normalizedPayload.channelData); return { payload: normalizedPayload, - parts, hasInteractive: hasInteractiveReplyBlocks(normalizedPayload.interactive), hasChannelData, + isSilent, }; } export function createOutboundPayloadPlan( payloads: readonly ReplyPayload[], + context: OutboundPayloadPlanContext = {}, ): OutboundPayloadPlan[] { // Intentionally scoped to channel-agnostic normalization and projection inputs. // Transport concerns (queueing, hooks, retries), channel transforms, and // heartbeat-specific token semantics remain outside this plan boundary. - const plan: OutboundPayloadPlan[] = []; + const resolvedSilentReplyPolicy = resolveSilentReplyPolicy({ + cfg: context.cfg, + sessionKey: context.sessionKey, + surface: context.surface, + conversationType: context.conversationType, + }); + const resolvedSilentReplyRewrite = resolveSilentReplyRewriteEnabled({ + cfg: context.cfg, + sessionKey: context.sessionKey, + surface: context.surface, + conversationType: context.conversationType, + }); + const prepared: PreparedOutboundPayloadPlanEntry[] = []; for (const payload of payloads) { const entry = createOutboundPayloadPlanEntry(payload); if (!entry) { continue; } - plan.push(entry); + prepared.push(entry); + } + const hasVisibleNonSilentContent = prepared.some((entry) => { + if (entry.isSilent) { + return false; + } + const parts = resolveSendableOutboundReplyParts(entry.payload); + return hasReplyPayloadContent( + { ...entry.payload, text: parts.text, mediaUrls: parts.mediaUrls }, + { hasChannelData: entry.hasChannelData }, + ); + }); + const plan: OutboundPayloadPlan[] = []; + for (const entry of prepared) { + if (!entry.isSilent) { + plan.push({ + payload: entry.payload, + parts: resolveSendableOutboundReplyParts(entry.payload), + hasInteractive: entry.hasInteractive, + hasChannelData: entry.hasChannelData, + }); + continue; + } + if (hasVisibleNonSilentContent || resolvedSilentReplyPolicy === "allow") { + continue; + } + if (!resolvedSilentReplyRewrite) { + const visibleSilentPayload: ReplyPayload = { + ...entry.payload, + text: entry.payload.text?.trim() || "NO_REPLY", + }; + if (!isRenderablePayload(visibleSilentPayload)) { + continue; + } + plan.push({ + payload: visibleSilentPayload, + parts: resolveSendableOutboundReplyParts(visibleSilentPayload), + hasInteractive: entry.hasInteractive, + hasChannelData: entry.hasChannelData, + }); + continue; + } + const rewrittenPayload: ReplyPayload = { + ...entry.payload, + text: resolveSilentReplyRewriteText({ + seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${entry.payload.text ?? ""}`, + }), + }; + if (!isRenderablePayload(rewrittenPayload)) { + continue; + } + plan.push({ + payload: rewrittenPayload, + parts: resolveSendableOutboundReplyParts(rewrittenPayload), + hasInteractive: entry.hasInteractive, + hasChannelData: entry.hasChannelData, + }); } return plan; } diff --git a/src/infra/outbound/session-context.ts b/src/infra/outbound/session-context.ts index ce367a656ed..4f4ae41f1c9 100644 --- a/src/infra/outbound/session-context.ts +++ b/src/infra/outbound/session-context.ts @@ -5,6 +5,8 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; export type OutboundSessionContext = { /** Canonical session key used for internal hook dispatch. */ key?: string; + /** Session key used for policy resolution when delivery differs from the control session. */ + policyKey?: string; /** Active agent id used for workspace-scoped media roots. */ agentId?: string; /** Originating account id used for requester-scoped group policy resolution. */ @@ -22,6 +24,7 @@ export type OutboundSessionContext = { export function buildOutboundSessionContext(params: { cfg: OpenClawConfig; sessionKey?: string | null; + policySessionKey?: string | null; agentId?: string | null; requesterAccountId?: string | null; requesterSenderId?: string | null; @@ -30,6 +33,7 @@ export function buildOutboundSessionContext(params: { requesterSenderE164?: string | null; }): OutboundSessionContext | undefined { const key = normalizeOptionalString(params.sessionKey); + const policyKey = normalizeOptionalString(params.policySessionKey); const explicitAgentId = normalizeOptionalString(params.agentId); const requesterAccountId = normalizeOptionalString(params.requesterAccountId); const requesterSenderId = normalizeOptionalString(params.requesterSenderId); @@ -42,6 +46,7 @@ export function buildOutboundSessionContext(params: { const agentId = explicitAgentId ?? derivedAgentId; if ( !key && + !policyKey && !agentId && !requesterAccountId && !requesterSenderId && @@ -53,6 +58,7 @@ export function buildOutboundSessionContext(params: { } return { ...(key ? { key } : {}), + ...(policyKey ? { policyKey } : {}), ...(agentId ? { agentId } : {}), ...(requesterAccountId ? { requesterAccountId } : {}), ...(requesterSenderId ? { requesterSenderId } : {}), diff --git a/src/plugin-sdk/outbound-runtime.ts b/src/plugin-sdk/outbound-runtime.ts index 1440c5da544..7b1aec40769 100644 --- a/src/plugin-sdk/outbound-runtime.ts +++ b/src/plugin-sdk/outbound-runtime.ts @@ -2,3 +2,7 @@ export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forw export { resolveOutboundSendDep, type OutboundSendDeps } from "../infra/outbound/send-deps.js"; export { resolveAgentOutboundIdentity, type OutboundIdentity } from "../infra/outbound/identity.js"; export { sanitizeForPlainText } from "../infra/outbound/sanitize-text.js"; +export { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "../infra/outbound/payloads.js"; diff --git a/src/shared/silent-reply-policy.test.ts b/src/shared/silent-reply-policy.test.ts new file mode 100644 index 00000000000..bdd23623013 --- /dev/null +++ b/src/shared/silent-reply-policy.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SILENT_REPLY_POLICY, + DEFAULT_SILENT_REPLY_REWRITE, + classifySilentReplyConversationType, + resolveSilentReplyPolicyFromPolicies, + resolveSilentReplyRewriteFromPolicies, + resolveSilentReplyRewriteText, +} from "./silent-reply-policy.js"; + +describe("classifySilentReplyConversationType", () => { + it("prefers an explicit conversation type", () => { + expect( + classifySilentReplyConversationType({ + sessionKey: "agent:main:group:123", + conversationType: "internal", + }), + ).toBe("internal"); + }); + + it("classifies direct and group session keys", () => { + expect( + classifySilentReplyConversationType({ + sessionKey: "agent:main:telegram:direct:123", + }), + ).toBe("direct"); + expect( + classifySilentReplyConversationType({ + sessionKey: "agent:main:discord:group:123", + }), + ).toBe("group"); + }); + + it("treats webchat as direct by default and unknown surfaces as internal", () => { + expect(classifySilentReplyConversationType({ surface: "webchat" })).toBe("direct"); + expect(classifySilentReplyConversationType({ surface: "subagent" })).toBe("internal"); + }); +}); + +describe("resolveSilentReplyPolicyFromPolicies", () => { + it("uses defaults when no overrides exist", () => { + expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "direct" })).toBe( + DEFAULT_SILENT_REPLY_POLICY.direct, + ); + expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "group" })).toBe( + DEFAULT_SILENT_REPLY_POLICY.group, + ); + }); + + it("prefers surface policy over defaults", () => { + expect( + resolveSilentReplyPolicyFromPolicies({ + conversationType: "direct", + defaultPolicy: { direct: "disallow" }, + surfacePolicy: { direct: "allow" }, + }), + ).toBe("allow"); + }); +}); + +describe("resolveSilentReplyRewriteFromPolicies", () => { + it("uses default rewrite flags when no overrides exist", () => { + expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "direct" })).toBe( + DEFAULT_SILENT_REPLY_REWRITE.direct, + ); + expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "group" })).toBe( + DEFAULT_SILENT_REPLY_REWRITE.group, + ); + }); + + it("prefers surface rewrite flags over defaults", () => { + expect( + resolveSilentReplyRewriteFromPolicies({ + conversationType: "direct", + defaultRewrite: { direct: true }, + surfaceRewrite: { direct: false }, + }), + ).toBe(false); + }); +}); + +describe("resolveSilentReplyRewriteText", () => { + it("picks a deterministic rewrite for a given seed", () => { + const first = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); + const second = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); + expect(first).toBe(second); + expect(first).not.toBe("NO_REPLY"); + expect(first.length).toBeGreaterThan(0); + }); +}); diff --git a/src/shared/silent-reply-policy.ts b/src/shared/silent-reply-policy.ts new file mode 100644 index 00000000000..9c17edd285d --- /dev/null +++ b/src/shared/silent-reply-policy.ts @@ -0,0 +1,111 @@ +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js"; + +export type SilentReplyPolicy = "allow" | "disallow"; +export type SilentReplyConversationType = "direct" | "group" | "internal"; +export type SilentReplyPolicyShape = Partial< + Record +>; +export type SilentReplyRewriteShape = Partial>; + +export const DEFAULT_SILENT_REPLY_POLICY: Record = { + direct: "disallow", + group: "allow", + internal: "allow", +}; + +export const DEFAULT_SILENT_REPLY_REWRITE: Record = { + direct: true, + group: false, + internal: false, +}; + +const SILENT_REPLY_REWRITE_TEXTS = [ + "Nothing to add right now.", + "All quiet on my side.", + "No extra notes from me.", + "Standing by.", + "No update from me on this one.", + "Nothing further to report.", + "I have nothing else to add.", + "No follow-up needed from me.", + "No additional reply from me here.", + "No extra comment on my end.", + "No further note from me.", + "That is all from me for now.", + "No added response from me.", + "Nothing else to say here.", + "No extra message needed from me.", + "No additional note on this one.", + "No further response from me.", + "Nothing new to add from my side.", + "No extra update from me.", + "I have no further reply here.", + "Nothing additional from me.", + "No added note from my side.", + "No more to report from me.", + "No extra reply needed here.", + "No further word from me.", + "Nothing further on my end.", + "No extra answer from me.", + "No additional response from my side.", +] as const; + +function hashSeed(seed: string): number { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function classifySilentReplyConversationType(params: { + sessionKey?: string; + surface?: string; + conversationType?: SilentReplyConversationType; +}): SilentReplyConversationType { + if (params.conversationType) { + return params.conversationType; + } + const normalizedSessionKey = normalizeLowercaseStringOrEmpty(params.sessionKey); + if (normalizedSessionKey.includes(":group:") || normalizedSessionKey.includes(":channel:")) { + return "group"; + } + if (normalizedSessionKey.includes(":direct:") || normalizedSessionKey.includes(":dm:")) { + return "direct"; + } + const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface); + if (normalizedSurface === "webchat") { + return "direct"; + } + return "internal"; +} + +export function resolveSilentReplyPolicyFromPolicies(params: { + conversationType: SilentReplyConversationType; + defaultPolicy?: SilentReplyPolicyShape; + surfacePolicy?: SilentReplyPolicyShape; +}): SilentReplyPolicy { + return ( + params.surfacePolicy?.[params.conversationType] ?? + params.defaultPolicy?.[params.conversationType] ?? + DEFAULT_SILENT_REPLY_POLICY[params.conversationType] + ); +} + +export function resolveSilentReplyRewriteFromPolicies(params: { + conversationType: SilentReplyConversationType; + defaultRewrite?: SilentReplyRewriteShape; + surfaceRewrite?: SilentReplyRewriteShape; +}): boolean { + return ( + params.surfaceRewrite?.[params.conversationType] ?? + params.defaultRewrite?.[params.conversationType] ?? + DEFAULT_SILENT_REPLY_REWRITE[params.conversationType] + ); +} + +export function resolveSilentReplyRewriteText(params: { seed?: string }): string { + const seed = params.seed?.trim() || "silent-reply"; + const index = hashSeed(seed) % SILENT_REPLY_REWRITE_TEXTS.length; + return SILENT_REPLY_REWRITE_TEXTS[index] ?? SILENT_REPLY_REWRITE_TEXTS[0]; +}