mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-20 21:02:10 +08:00
feat: add no_reply tuning by conversation type
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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.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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof import("../../infra/outbound/deliver-runtime.js")>(
|
||||
"../../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]" },
|
||||
|
||||
@@ -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<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 ? getBundledChannelPlugin(channelId) : undefined;
|
||||
const messaging = loadedPlugin?.messaging ?? bundledPlugin?.messaging;
|
||||
const threading = loadedPlugin?.threading ?? bundledPlugin?.threading;
|
||||
const resolvedAgentId = params.sessionKey
|
||||
? resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -196,6 +197,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,
|
||||
|
||||
107
src/config/silent-reply.test.ts
Normal file
107
src/config/silent-reply.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
src/config/silent-reply.ts
Normal file
48
src/config/silent-reply.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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<string, SurfaceConfigEntry>;
|
||||
models?: ModelsConfig;
|
||||
nodeHost?: NodeHostConfig;
|
||||
agents?: AgentsConfig;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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<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 +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;
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
90
src/shared/silent-reply-policy.test.ts
Normal file
90
src/shared/silent-reply-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
src/shared/silent-reply-policy.ts
Normal file
111
src/shared/silent-reply-policy.ts
Normal file
@@ -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<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];
|
||||
}
|
||||
Reference in New Issue
Block a user