fix: add silent reply policy by conversation type (#68644)

Thanks @Takhoffman.
This commit is contained in:
Tak Hoffman
2026-04-20 23:17:55 -05:00
committed by GitHub
parent 5986431b02
commit 1303b03241
35 changed files with 1379 additions and 55 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- Active Memory: degrade gracefully when memory recall fails during prompt building, logging a warning and letting the reply continue without memory context instead of failing the whole turn. (#69485) Thanks @Magicray1217.
- Ollama: add provider-policy defaults for `baseUrl` and `models` so implicit local discovery can run before config validation rejects a minimal Ollama provider config. (#69370) Thanks @PratikRai0101.
- Agents/model selection: clear transient auto-failover session overrides before each turn so recovered primary models are retried immediately without emitting user-override reset warnings. (#69365) Thanks @hitesh-github99.
- Auto-reply: apply silent `NO_REPLY` policy per conversation type, so direct chats get a helpful rewritten reply while groups and internal deliveries can remain quiet. (#68644) Thanks @Takhoffman.
- Telegram/status reactions: honor `messages.removeAckAfterReply` when lifecycle status reactions are enabled, clearing or restoring the reaction after success/error using the configured hold timings. (#68067) Thanks @poiskgit.
- Web search/plugins: resolve plugin-scoped SecretRef API keys for bundled Exa, Firecrawl, Gemini, Kimi, Perplexity, Tavily, and Grok web-search providers when they are selected through the shared web-search config. (#68424) Thanks @afurm.
- Telegram/polling: raise the default polling watchdog threshold from 90s to 120s and add configurable `channels.telegram.pollingStallThresholdMs` (also per-account) so long-running Telegram work gets more room before polling is treated as stalled. (#57737) Thanks @Vitalcheffe.

View File

@@ -1,4 +1,4 @@
580abc79677d84fa66cb55e42ea093bfa9681655861166c02dfaa5a313d44310 config-baseline.json
04a82c2208bf69e0a195e7712e3a518a8255c1bb002c31f712cb95003325635b config-baseline.core.json
e3a16ceb9e933c5b707b717c18a1d9d50f98e687a98e6c35f4f3a290f7036a62 config-baseline.json
ae1ab87635e7bf613c84fee04425af901ceeb67fb5dbcf1c74095aa00a59ee88 config-baseline.core.json
e239cc20f20f8d0172812bc0ad3ee6df52da88e2e2702e3d03a47e01561132ae config-baseline.channel.json
8fb3a1cf5fe56ab8fc2cb46341c3403aed32b0d1f0aaeac0e96cd3599db4f06e config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
e6da774a43c16fddc77e04b0d2888d06454d1adb84814c8db4fee0f495c1eec1 plugin-sdk-api-baseline.json
ef8b5fd8081dfa05740f6a609144e755d95a19196a1617037dba1213134699df plugin-sdk-api-baseline.jsonl
f135ddc1802b7f8b2d29bf495fd0ac1f497a89bab8164ca8c7c8f18efc010e6e plugin-sdk-api-baseline.json
a47d06095ec5c3701a94888a11e89700d8a8511db46fa3122fb9407e160707b6 plugin-sdk-api-baseline.jsonl

View File

@@ -153,6 +153,20 @@ Outbound message formatting is centralized in `messages`:
Details: [Configuration](/gateway/configuration-reference#messages) and channel docs.
## Silent replies
The exact silent token `NO_REPLY` / `no_reply` means “do not deliver a user-visible reply”.
OpenClaw resolves that behavior by conversation type:
- Direct conversations disallow silence by default and rewrite a bare silent
reply to a short visible fallback.
- Groups/channels allow silence by default.
- Internal orchestration allows silence by default.
Defaults live under `agents.defaults.silentReply` and
`agents.defaults.silentReplyRewrite`; `surfaces.<id>.silentReply` and
`surfaces.<id>.silentReplyRewrite` can override them per surface.
## Related
- [Streaming](/concepts/streaming) — real-time message delivery

View File

@@ -167,7 +167,7 @@ surfaces:
- `openclaw/plugin-sdk/messaging-targets` for target parsing/matching
- `openclaw/plugin-sdk/outbound-media` and
`openclaw/plugin-sdk/outbound-runtime` for media loading plus outbound
identity/send delegates
identity/send delegates and payload planning
- `openclaw/plugin-sdk/thread-bindings-runtime` for thread-binding lifecycle
and adapter registration
- `openclaw/plugin-sdk/agent-media-payload` only when a legacy agent/media

View File

@@ -207,7 +207,7 @@ Current bundled provider examples:
| `plugin-sdk/inbound-reply-dispatch` | Inbound reply helpers | Shared record-and-dispatch helpers |
| `plugin-sdk/messaging-targets` | Messaging target parsing | Target parsing/matching helpers |
| `plugin-sdk/outbound-media` | Outbound media helpers | Shared outbound media loading |
| `plugin-sdk/outbound-runtime` | Outbound runtime helpers | Outbound identity/send delegate helpers |
| `plugin-sdk/outbound-runtime` | Outbound runtime helpers | Outbound identity/send delegate and payload planning helpers |
| `plugin-sdk/thread-bindings-runtime` | Thread-binding helpers | Thread-binding lifecycle and adapter helpers |
| `plugin-sdk/agent-media-payload` | Legacy media payload helpers | Agent media payload builder for legacy field layouts |
| `plugin-sdk/channel-runtime` | Deprecated compatibility shim | Legacy channel runtime utilities only |

View File

@@ -95,7 +95,7 @@ explicitly promotes one as public.
| `plugin-sdk/inbound-reply-dispatch` | Shared inbound record-and-dispatch helpers |
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
| `plugin-sdk/outbound-runtime` | Outbound identity/send delegate helpers |
| `plugin-sdk/outbound-runtime` | Outbound identity, send delegate, and payload planning helpers |
| `plugin-sdk/poll-runtime` | Narrow poll normalization helpers |
| `plugin-sdk/thread-bindings-runtime` | Thread-binding lifecycle and adapter helpers |
| `plugin-sdk/agent-media-payload` | Legacy agent media payload builder |

View File

@@ -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";
@@ -2563,6 +2564,124 @@ 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("does not add silent-reply fallback after visible block delivery", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "visible block" }, { kind: "block" });
return { queuedFinal: false };
});
deliverReplies.mockResolvedValue({ 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);
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "visible block" })],
}),
);
});
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: "forum" },
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);

View File

@@ -13,11 +13,20 @@ 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, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import {
createSubsystemLogger,
danger,
logVerbose,
sleepWithAbort,
} from "openclaw/plugin-sdk/runtime-env";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageContext } from "./bot-message-context.js";
import {
@@ -66,6 +75,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;
@@ -1107,7 +1117,36 @@ export const dispatchTelegramMessage = async ({
sentFallback = result.delivered;
}
const hasFinalResponse = queuedFinal || sentFallback;
if (!queuedFinal && !sentFallback && !dispatchError && !deliverySummary.delivered) {
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.debug("telegram turn ended without visible final response", {
hasSessionKey: Boolean(policySessionKey),
hasChatId: chatId != null,
queuedFinal,
sentFallback,
});
}
const hasFinalResponse = queuedFinal || sentFallback || deliverySummary.delivered;
if (statusReactionController && !hasFinalResponse) {
void finalizeTelegramStatusReaction({ outcome: "error", hasFinalResponse: false }).catch(

View File

@@ -90,6 +90,10 @@ type TelegramNativeReplyChannelData = {
buttons?: TelegramInlineButtons;
pin?: boolean;
};
type TelegramResolvedGroupConfig = {
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
};
type TelegramCommandAuthResult = {
chatId: number;
@@ -98,7 +102,7 @@ type TelegramCommandAuthResult = {
resolvedThreadId?: number;
senderId: string;
senderUsername: string;
groupConfig?: TelegramGroupConfig;
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
commandAuthorized: boolean;
};
@@ -187,7 +191,7 @@ export type RegisterTelegramHandlerParams = {
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
) => TelegramResolvedGroupConfig;
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
processMessage: (
ctx: TelegramContext,
@@ -240,7 +244,7 @@ export type RegisterTelegramNativeCommandsParams = {
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
) => TelegramResolvedGroupConfig;
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
telegramDeps?: TelegramNativeCommandDeps;
opts: { token: string };
@@ -260,7 +264,7 @@ async function resolveTelegramCommandAuth(params: {
resolveTelegramGroupConfig: (
chatId: string | number,
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
) => TelegramResolvedGroupConfig;
requireAuth: boolean;
}): Promise<TelegramCommandAuthResult | null> {
const {
@@ -322,7 +326,8 @@ 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 =
!isGroup && groupConfig && "requireTopic" in groupConfig ? groupConfig.requireTopic : undefined;
if (!isGroup && requireTopic === true && dmThreadId == null) {
logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`);
return null;
@@ -683,9 +688,11 @@ export const registerTelegramNativeCommands = ({
return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode };
};
const buildCommandDeliveryBaseOptions = (params: {
cfg: OpenClawConfig;
chatId: string | number;
accountId: string;
sessionKeyForInternalHooks?: string;
policySessionKey?: string;
mirrorIsGroup?: boolean;
mirrorGroupId?: string;
mediaLocalRoots?: readonly string[];
@@ -694,9 +701,11 @@ export const registerTelegramNativeCommands = ({
chunkMode: TelegramChunkMode;
linkPreview?: boolean;
}) => ({
cfg: params.cfg,
chatId: String(params.chatId),
accountId: params.accountId,
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,
policySessionKey: params.policySessionKey,
mirrorIsGroup: params.mirrorIsGroup,
mirrorGroupId: params.mirrorGroupId,
token: opts.token,
@@ -851,9 +860,11 @@ export const registerTelegramNativeCommands = ({
targetSessionKey: sessionKey,
});
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
cfg: executionCfg,
chatId,
accountId: route.accountId,
sessionKeyForInternalHooks: commandSessionKey,
policySessionKey: commandTargetSessionKey,
mirrorIsGroup: isGroup,
mirrorGroupId: isGroup ? String(chatId) : undefined,
mediaLocalRoots,
@@ -1036,9 +1047,11 @@ export const registerTelegramNativeCommands = ({
}
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } = runtimeContext;
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
cfg: runtimeCfg,
chatId,
accountId: route.accountId,
sessionKeyForInternalHooks: route.sessionKey,
policySessionKey: route.sessionKey,
mirrorIsGroup: isGroup,
mirrorGroupId: isGroup ? String(chatId) : undefined,
mediaLocalRoots,

View File

@@ -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,9 +587,11 @@ 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;
policySessionKey?: string;
mirrorIsGroup?: boolean;
mirrorGroupId?: string;
token: string;
@@ -620,7 +628,34 @@ 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.policySessionKey ?? params.sessionKeyForInternalHooks,
surface: "telegram",
}),
);
const originalExactSilentCount = candidateReplies.filter(
(reply) => typeof reply.text === "string" && reply.text.trim().toUpperCase() === "NO_REPLY",
).length;
if (originalExactSilentCount > 0) {
silentReplyLogger.debug("telegram delivery normalized NO_REPLY candidates", {
hasSessionKey: Boolean(params.sessionKeyForInternalHooks),
hasChatId: params.chatId.length > 0,
originalCount: candidateReplies.length,
normalizedCount: normalizedReplies.length,
originalExactSilentCount,
});
}
for (const originalReply of normalizedReplies) {
let reply = originalReply;
const mediaList = reply?.mediaUrls?.length
? reply.mediaUrls

View File

@@ -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,56 @@ 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("uses the policy session key for exact NO_REPLY policy", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 121, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:slash:123",
policySessionKey: "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"));

View File

@@ -32,6 +32,8 @@ const WATCH_GATEWAY_SKIP_ENV = {
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
NODE_ENV: "test",
};
function parseArgs(argv) {

View File

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

View File

@@ -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<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: GetReplyFromConfig;
}): Promise<DispatchInboundResult> {
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<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: GetReplyFromConfig;
}): Promise<DispatchInboundResult> {
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,

View File

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

View File

@@ -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.debug("preserving exact NO_REPLY final payload before normalization", {
hasSessionKey: Boolean(context.sessionKey),
surface: context.surface,
conversationType: context.conversationType,
resolvedPolicy,
});
}
return shouldPreserve;
}
export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher {
let sendChain: Promise<void> = 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.debug("exact NO_REPLY final payload was skipped before delivery", {
hasSessionKey: Boolean(options.silentReplyContext?.sessionKey),
surface: options.silentReplyContext?.surface,
conversationType: options.silentReplyContext?.conversationType,
});
}
return false;
}
queuedCounts[kind] += 1;

View File

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

View File

@@ -222,6 +222,41 @@ describe("routeReply", () => {
});
});
it("passes policySessionKey through to outbound delivery 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: "native command response" },
channel: "slack",
to: "channel:C123",
cfg,
sessionKey: "agent:main:main",
policySessionKey: "agent:main:direct:U123",
});
expect(res.ok).toBe(true);
expectLastDelivery({
payloads: [expect.objectContaining({ text: "native command response" })],
session: expect.objectContaining({
key: "agent:main:main",
policyKey: "agent:main:direct:U123",
}),
});
});
it("applies responsePrefix when routing", async () => {
const cfg = {
messages: { responsePrefix: "[openclaw]" },

View File

@@ -12,6 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { normalizeChatChannelId } from "../../channels/registry.js";
import { resolveSilentReplyPolicy } from "../../config/silent-reply.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
@@ -19,6 +20,7 @@ import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import type { OriginatingChannelType } from "../templating.js";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import {
@@ -44,6 +46,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 +97,10 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
const normalizedChannel = normalizeMessageChannel(channel);
const channelId =
normalizeChannelId(channel) ?? normalizeOptionalLowercaseString(channel) ?? null;
const plugin = channelId
? (getLoadedChannelPlugin(channelId) ?? getBundledChannelPlugin(channelId))
: undefined;
const messaging = plugin?.messaging;
const threading = plugin?.threading;
const loadedPlugin = channelId ? getLoadedChannelPlugin(channelId) : undefined;
const bundledPlugin = channelId && !loadedPlugin ? getBundledChannelPlugin(channelId) : undefined;
const messaging = loadedPlugin?.messaging ?? bundledPlugin?.messaging;
const threading = loadedPlugin?.threading ?? bundledPlugin?.threading;
const resolvedAgentId = params.sessionKey
? resolveSessionAgentId({
sessionKey: params.sessionKey,
@@ -115,17 +118,30 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
: cfg.messages?.responsePrefix === "auto"
? undefined
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
transformReplyPayload: messaging?.transformReplyPayload
? (nextPayload) =>
messaging.transformReplyPayload?.({
payload: nextPayload,
cfg,
accountId,
}) ?? nextPayload
: undefined,
});
const policySessionKey = params.policySessionKey ?? params.sessionKey;
const shouldPreserveSilentPayload =
isSilentReplyPayloadText(payload.text) &&
resolveSilentReplyPolicy({
cfg,
sessionKey: policySessionKey,
surface: channelId ?? String(channel),
}) !== "allow";
const normalized = shouldPreserveSilentPayload
? {
...payload,
text: payload.text?.trim() || SILENT_REPLY_TOKEN,
}
: normalizeReplyPayload(payload, {
responsePrefix,
transformReplyPayload: messaging?.transformReplyPayload
? (nextPayload) =>
messaging.transformReplyPayload?.({
payload: nextPayload,
cfg,
accountId,
}) ?? nextPayload
: undefined,
});
if (!normalized) {
return { ok: true };
}
@@ -196,6 +212,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
cfg,
agentId: resolvedAgentId,
sessionKey: params.sessionKey,
policySessionKey: params.policySessionKey,
requesterSenderId: params.requesterSenderId,
requesterSenderName: params.requesterSenderName,
requesterSenderUsername: params.requesterSenderUsername,

View File

@@ -3255,6 +3255,63 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.",
},
silentReply: {
type: "object",
properties: {
direct: {
anyOf: [
{
type: "string",
const: "allow",
},
{
type: "string",
const: "disallow",
},
],
},
group: {
anyOf: [
{
type: "string",
const: "allow",
},
{
type: "string",
const: "disallow",
},
],
},
internal: {
anyOf: [
{
type: "string",
const: "allow",
},
{
type: "string",
const: "disallow",
},
],
},
},
additionalProperties: false,
},
silentReplyRewrite: {
type: "object",
properties: {
direct: {
type: "boolean",
},
group: {
type: "boolean",
},
internal: {
type: "boolean",
},
},
additionalProperties: false,
},
repoRoot: {
type: "string",
title: "Repo Root",
@@ -22764,6 +22821,75 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Plugin system controls for enabling extensions, constraining load scope, configuring entries, and tracking installs. Keep plugin policy explicit and least-privilege in production environments.",
},
surfaces: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
silentReply: {
type: "object",
properties: {
direct: {
anyOf: [
{
type: "string",
const: "allow",
},
{
type: "string",
const: "disallow",
},
],
},
group: {
anyOf: [
{
type: "string",
const: "allow",
},
{
type: "string",
const: "disallow",
},
],
},
internal: {
anyOf: [
{
type: "string",
const: "allow",
},
{
type: "string",
const: "disallow",
},
],
},
},
additionalProperties: false,
},
silentReplyRewrite: {
type: "object",
properties: {
direct: {
type: "boolean",
},
group: {
type: "boolean",
},
internal: {
type: "boolean",
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
},
},
required: ["commands"],
additionalProperties: false,

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { resolveSilentReplyPolicy, resolveSilentReplyRewriteEnabled } from "./silent-reply.js";
import type { OpenClawConfig } from "./types.openclaw.js";
describe("silent reply config resolution", () => {
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);
});
});

View File

@@ -0,0 +1,60 @@
import {
classifySilentReplyConversationType,
resolveSilentReplyPolicyFromPolicies,
resolveSilentReplyRewriteFromPolicies,
type SilentReplyConversationType,
type SilentReplyPolicy,
type SilentReplyPolicyShape,
type SilentReplyRewriteShape,
} 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;
};
function resolveSilentReplyConversationContext(params: ResolveSilentReplyParams): {
conversationType: SilentReplyConversationType;
defaultPolicy?: SilentReplyPolicyShape;
defaultRewrite?: SilentReplyRewriteShape;
surfacePolicy?: SilentReplyPolicyShape;
surfaceRewrite?: SilentReplyRewriteShape;
} {
const conversationType = classifySilentReplyConversationType({
sessionKey: params.sessionKey,
surface: params.surface,
conversationType: params.conversationType,
});
const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface);
const surface = normalizedSurface ? params.cfg?.surfaces?.[normalizedSurface] : undefined;
return {
conversationType,
defaultPolicy: params.cfg?.agents?.defaults?.silentReply,
defaultRewrite: params.cfg?.agents?.defaults?.silentReplyRewrite,
surfacePolicy: surface?.silentReply,
surfaceRewrite: surface?.silentReplyRewrite,
};
}
export function resolveSilentReplySettings(params: ResolveSilentReplyParams): {
policy: SilentReplyPolicy;
rewrite: boolean;
} {
const context = resolveSilentReplyConversationContext(params);
return {
policy: resolveSilentReplyPolicyFromPolicies(context),
rewrite: resolveSilentReplyRewriteFromPolicies(context),
};
}
export function resolveSilentReplyPolicy(params: ResolveSilentReplyParams): SilentReplyPolicy {
return resolveSilentReplySettings(params).policy;
}
export function resolveSilentReplyRewriteEnabled(params: ResolveSilentReplyParams): boolean {
return resolveSilentReplySettings(params).rewrite;
}

View File

@@ -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?: {

View File

@@ -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,8 +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. */
@@ -97,6 +105,7 @@ export type OpenClawConfig = {
secrets?: SecretsConfig;
skills?: SkillsConfig;
plugins?: PluginsConfig;
surfaces?: Record<string, SurfaceConfigEntry>;
models?: ModelsConfig;
nodeHost?: NodeHostConfig;
agents?: AgentsConfig;

View File

@@ -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(),

View File

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

View File

@@ -903,6 +903,57 @@ describe("deliverOutboundPayloads", () => {
);
});
it("applies silent-reply policy from the outbound session", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-silent", roomId: "!room" });
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
await deliverOutboundPayloads({
cfg,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "NO_REPLY" }],
deps: { matrix: sendMatrix },
session: {
key: "agent:main:matrix:slash:!room",
policyKey: "agent:main:matrix:direct:!room",
},
});
expect(sendMatrix).toHaveBeenCalledTimes(1);
expect(sendMatrix.mock.calls[0]?.[1]).toEqual(expect.any(String));
expect(sendMatrix.mock.calls[0]?.[1]).not.toBe("NO_REPLY");
});
it("keeps allowed group silent replies silent during outbound delivery", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-silent", roomId: "!room" });
await deliverOutboundPayloads({
cfg: matrixChunkConfig,
channel: "matrix",
to: "!room:example",
payloads: [{ text: "NO_REPLY" }],
deps: { matrix: sendMatrix },
session: {
key: "agent:main:matrix:group:ops",
},
});
expect(sendMatrix).not.toHaveBeenCalled();
});
it("acks the queue entry when delivery is aborted", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" });
const abortController = new AbortController();

View File

@@ -553,7 +553,11 @@ async function deliverOutboundPayloadsCore(
params: DeliverOutboundPayloadsCoreParams,
): Promise<OutboundDeliveryResult[]> {
const { cfg, channel, to, payloads } = params;
const outboundPayloadPlan = createOutboundPayloadPlan(payloads);
const outboundPayloadPlan = createOutboundPayloadPlan(payloads, {
cfg,
sessionKey: params.session?.policyKey ?? params.session?.key,
surface: channel,
});
const accountId = params.accountId;
const deps = params.deps;
const abortSignal = params.abortSignal;

View File

@@ -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([
{

View File

@@ -6,12 +6,18 @@ import {
shouldSuppressReasoningPayload,
} from "../../auto-reply/reply/reply-payloads.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveSilentReplySettings } 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 +43,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 +102,16 @@ function mergeMediaUrls(...lists: Array<ReadonlyArray<string | undefined> | 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 +126,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 +143,94 @@ 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 resolvedSilentReplySettings = resolveSilentReplySettings({
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 || resolvedSilentReplySettings.policy === "allow") {
continue;
}
if (!resolvedSilentReplySettings.rewrite) {
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;
}

View File

@@ -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 } : {}),

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
export type SilentReplyPolicy = "allow" | "disallow";
export type SilentReplyMode = "allow" | "rewrite";
export type SilentReplyConversationType = "direct" | "group" | "internal";
export type SilentReplyPolicyShape = Partial<
Record<SilentReplyConversationType, SilentReplyPolicy>
>;
export type SilentReplyRewriteShape = Partial<Record<SilentReplyConversationType, boolean>>;
export const DEFAULT_SILENT_REPLY_POLICY: Record<SilentReplyConversationType, SilentReplyPolicy> = {
direct: "disallow",
group: "allow",
internal: "allow",
};
export const DEFAULT_SILENT_REPLY_REWRITE: Record<SilentReplyConversationType, boolean> = {
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];
}