fix: Align silent reply prompt guidance (#70954)

* Align silent reply prompt guidance

* Pass explicit silent reply conversation types

* Handle dm alias in direct prompt guidance

* Respect policy session type for routed replies

* Preserve routed silent reply policy type

* Propagate silent reply dispatcher chat type

* Align prompt silent reply target policy

* Avoid direct silent fallback prompt token

* Use inbound key for prompt silent policy

* Rewrite direct silent replies in dispatcher
This commit is contained in:
Tak Hoffman
2026-04-24 12:06:54 -05:00
committed by GitHub
parent c9f2403547
commit cc57d56b92
24 changed files with 604 additions and 123 deletions

View File

@@ -1,4 +1,4 @@
f0421335bfd388b7ebe1b8d478036ece4bf5eb8fd7b1de81b8cdc4ec6522ce20 config-baseline.json
de02a4b0ec521fda7d951d2dc0c742fc2fa310647ffd56a666346e5ddc6b5a59 config-baseline.json
bf00f7910d8f0d8e12592e8a1c6bd0397f8e62fef2c11eb0cbd3b3a3e2a78ffe config-baseline.core.json
22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json
c6f99aed28b98e5914585956ec303b615a8ef975abf5cec186a61781c20b9106 config-baseline.plugin.json
b79dc28c1b6002dc59bd77bde47c1855a9ece72b9fdf94c0baf0c5320b2be12c config-baseline.plugin.json

View File

@@ -70,6 +70,18 @@ describe("prompt composition invariants", () => {
expect(steady.systemPrompt).toBe(eventTurn.systemPrompt);
});
it("includes direct-chat guidance that routes NO_REPLY through the default rewrite path", () => {
const directScenario = fixture.scenarios.find(
(entry) => entry.scenario === "auto-reply-direct",
);
expect(directScenario).toBeDefined();
const first = getTurn(directScenario!, "t1");
expect(first.systemPrompt).toContain("You are in a Slack direct conversation.");
expect(first.systemPrompt).toContain('reply with exactly "NO_REPLY"');
expect(first.systemPrompt).toContain("so OpenClaw can send a short fallback reply");
});
it("keeps maintenance prompts out of the normal stable-turn invariant set", () => {
const maintenanceScenario = fixture.scenarios.find(
(entry) => entry.scenario === "maintenance-prompts",

View File

@@ -200,4 +200,72 @@ describe("withReplyDispatcher", () => {
}),
);
});
it("passes explicit direct conversation type for generic silent-reply policy keys", 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:main",
ChatType: "dm",
Surface: "discord",
}),
cfg: {} as OpenClawConfig,
dispatcherOptions: {
deliver: async () => undefined,
},
replyResolver: async () => ({ text: "ok" }),
});
expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith(
expect.objectContaining({
silentReplyContext: expect.objectContaining({
sessionKey: "agent:test:main",
surface: "discord",
conversationType: "direct",
}),
}),
);
});
it("does not copy source conversation type onto cross-session native silent-reply targets", 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:main",
CommandSource: "native",
CommandTargetSessionKey: "agent:test:direct:user",
ChatType: "group",
Surface: "telegram",
}),
cfg: {} as OpenClawConfig,
dispatcherOptions: {
deliver: async () => undefined,
},
replyResolver: async () => ({ text: "ok" }),
});
const silentReplyContext =
hoisted.createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0]?.silentReplyContext;
expect(silentReplyContext).toEqual(
expect.objectContaining({
sessionKey: "agent:test:direct:user",
surface: "telegram",
}),
);
expect(silentReplyContext).not.toEqual(expect.objectContaining({ conversationType: "group" }));
});
});

View File

@@ -1,4 +1,6 @@
import { normalizeChatType } from "../channels/chat-type.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SilentReplyConversationType } from "../shared/silent-reply-policy.js";
import { withReplyDispatcher } from "./dispatch-dispatcher.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js";
@@ -23,10 +25,22 @@ function resolveDispatcherSilentReplyContext(
finalized.CommandSource === "native"
? (finalized.CommandTargetSessionKey ?? finalized.SessionKey)
: finalized.SessionKey;
const chatType = normalizeChatType(finalized.ChatType);
const conversationType: SilentReplyConversationType | undefined =
finalized.CommandSource === "native" &&
finalized.CommandTargetSessionKey &&
finalized.CommandTargetSessionKey !== finalized.SessionKey
? undefined
: chatType === "direct"
? "direct"
: chatType === "group" || chatType === "channel"
? "group"
: undefined;
return {
cfg,
sessionKey: policySessionKey,
surface: finalized.Surface ?? finalized.Provider,
conversationType,
};
}

View File

@@ -1083,6 +1083,7 @@ describe("dispatchReplyFromConfig", () => {
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
channel: "imessage",
policyConversationType: "direct",
to: "imessage:+15550001111",
}),
);

View File

@@ -9,6 +9,7 @@ import {
resolveConversationBindingRecord,
touchConversationBindingRecord,
} from "../../bindings/records.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js";
import type { SessionEntry } from "../../config/sessions/types.js";
@@ -151,6 +152,26 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
return AUDIO_HEADER_RE.test(trimmed);
};
const resolveRoutedPolicyConversationType = (
ctx: FinalizedMsgContext,
): "direct" | "group" | undefined => {
if (
ctx.CommandSource === "native" &&
ctx.CommandTargetSessionKey &&
ctx.CommandTargetSessionKey !== ctx.SessionKey
) {
return undefined;
}
const chatType = normalizeChatType(ctx.ChatType);
if (chatType === "direct") {
return "direct";
}
if (chatType === "group" || chatType === "channel") {
return "group";
}
return undefined;
};
const resolveSessionStoreLookup = (
ctx: FinalizedMsgContext,
cfg: OpenClawConfig,
@@ -391,6 +412,7 @@ export async function dispatchReplyFromConfig(
ctx.CommandSource === "native"
? (ctx.CommandTargetSessionKey ?? ctx.SessionKey)
: ctx.SessionKey,
policyConversationType: resolveRoutedPolicyConversationType(ctx),
accountId: replyRoute.accountId,
requesterSenderId: ctx.SenderId,
requesterSenderName: ctx.SenderName,

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildExecOverridePromptHint } from "./get-reply-run.js";
import {
buildExecOverridePromptHint,
resolvePromptSilentReplyConversationType,
} from "./get-reply-run.js";
import { buildGetReplyCtx, buildGetReplyGroupCtx } from "./get-reply.test-fixtures.js";
describe("buildExecOverridePromptHint", () => {
it("returns undefined when exec state is fully inherited and elevated is off", () => {
@@ -52,3 +56,53 @@ describe("buildExecOverridePromptHint", () => {
);
});
});
describe("resolvePromptSilentReplyConversationType", () => {
it("treats direct and dm chat types as direct prompt policy context", () => {
expect(
resolvePromptSilentReplyConversationType({
ctx: buildGetReplyCtx({
ChatType: "dm",
SessionKey: "agent:main:main",
}),
}),
).toBe("direct");
});
it("treats group and channel chat types as group prompt policy context", () => {
expect(
resolvePromptSilentReplyConversationType({
ctx: buildGetReplyGroupCtx({
ChatType: "channel",
}),
}),
).toBe("group");
});
it("does not override a native cross-session target policy with the source chat type", () => {
expect(
resolvePromptSilentReplyConversationType({
ctx: buildGetReplyGroupCtx({
CommandSource: "native",
SessionKey: "agent:main:telegram:group:source",
CommandTargetSessionKey: "agent:main:telegram:direct:target",
ChatType: "group",
}),
}),
).toBeUndefined();
});
it("uses the inbound session key when session context was rewritten to the target", () => {
expect(
resolvePromptSilentReplyConversationType({
ctx: buildGetReplyGroupCtx({
CommandSource: "native",
SessionKey: "agent:main:telegram:direct:target",
CommandTargetSessionKey: "agent:main:telegram:direct:target",
ChatType: "group",
}),
inboundSessionKey: "agent:main:telegram:group:source",
}),
).toBeUndefined();
});
});

View File

@@ -5,6 +5,7 @@ import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js";
import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js";
import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
import {
resolveSessionFilePath,
@@ -12,6 +13,7 @@ import {
} from "../../config/sessions/paths.js";
import { resolveSessionStoreEntry } from "../../config/sessions/store.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { resolveSilentReplySettings } from "../../config/silent-reply.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
@@ -20,6 +22,7 @@ import {
isSubagentSessionKey,
normalizeMainKey,
} from "../../routing/session-key.js";
import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { hasControlCommand } from "../command-detection.js";
@@ -42,7 +45,7 @@ import type { buildCommandContext } from "./commands.js";
import type { InlineDirectives } from "./directive-handling.js";
import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js";
import { resolvePreparedReplyQueueState } from "./get-reply-run-queue.js";
import { buildGroupChatContext, buildGroupIntro } from "./groups.js";
import { buildDirectChatContext, buildGroupChatContext, buildGroupIntro } from "./groups.js";
import { hasInboundMedia } from "./inbound-media.js";
import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js";
import type { createModelSelectionState } from "./model-selection.js";
@@ -62,6 +65,28 @@ import type { TypingController } from "./typing.js";
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
export function resolvePromptSilentReplyConversationType(params: {
ctx: Pick<MsgContext, "ChatType" | "CommandSource" | "CommandTargetSessionKey" | "SessionKey">;
inboundSessionKey?: string;
}): SilentReplyConversationType | undefined {
const sourceSessionKey = params.inboundSessionKey ?? params.ctx.SessionKey;
if (
params.ctx.CommandSource === "native" &&
params.ctx.CommandTargetSessionKey &&
params.ctx.CommandTargetSessionKey !== sourceSessionKey
) {
return undefined;
}
const chatType = normalizeChatType(params.ctx.ChatType);
if (chatType === "direct") {
return "direct";
}
if (chatType === "group" || chatType === "channel") {
return "group";
}
return undefined;
}
export function buildExecOverridePromptHint(params: {
execOverrides?: ExecOverrides;
elevatedLevel: ElevatedLevel;
@@ -239,6 +264,15 @@ export async function runPreparedReply(
ctx,
sessionKey,
});
const silentReplySettings = resolveSilentReplySettings({
cfg,
sessionKey: runtimePolicySessionKey,
surface: sessionCtx.Surface ?? sessionCtx.Provider,
conversationType: resolvePromptSilentReplyConversationType({
ctx: sessionCtx,
inboundSessionKey: ctx.SessionKey,
}),
});
let {
sessionEntry,
resolvedThinkLevel,
@@ -282,6 +316,15 @@ export async function runPreparedReply(
const shouldInjectGroupIntro = Boolean(
isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
);
const directChatContext =
sessionCtx.ChatType === "direct" || sessionCtx.ChatType === "dm"
? buildDirectChatContext({
sessionCtx,
silentReplyPolicy: silentReplySettings.policy,
silentReplyRewrite: silentReplySettings.rewrite,
silentToken: SILENT_REPLY_TOKEN,
})
: "";
// Always include persistent group chat context (provider + reply guidance).
const groupChatContext = isGroupChat ? buildGroupChatContext({ sessionCtx }) : "";
// Behavioral intro (activation mode, lurking, etc.) only on first turn / activation needed
@@ -292,6 +335,8 @@ export async function runPreparedReply(
sessionEntry,
defaultActivation,
silentToken: SILENT_REPLY_TOKEN,
silentReplyPolicy: silentReplySettings.policy,
silentReplyRewrite: silentReplySettings.rewrite,
})
: "";
const groupSystemPrompt = normalizeOptionalString(sessionCtx.GroupSystemPrompt) ?? "";
@@ -301,6 +346,7 @@ export async function runPreparedReply(
);
const extraSystemPromptParts = [
inboundMetaPrompt,
directChatContext,
groupChatContext,
groupIntro,
groupSystemPrompt,
@@ -313,6 +359,7 @@ export async function runPreparedReply(
].filter(Boolean);
// Static parts only (no per-message inbound metadata) for CLI session reuse hashing.
const extraSystemPromptStaticParts = [
directChatContext,
groupChatContext,
groupIntro,
groupSystemPrompt,

View File

@@ -49,6 +49,76 @@ describe("group runtime loading", () => {
vi.doUnmock("./groups.runtime.js");
});
it("builds direct chat context from the resolved silent reply policy", async () => {
const groups = await import("./groups.js");
expect(
groups.buildDirectChatContext({
sessionCtx: { ChatType: "direct", Provider: "telegram" },
silentReplyPolicy: "disallow",
silentReplyRewrite: false,
silentToken: "NO_REPLY",
}),
).toBe(
'You are in a Telegram direct conversation. Your replies are automatically sent to this conversation. Do not use "NO_REPLY" as your final answer in this conversation.',
);
expect(
groups.buildDirectChatContext({
sessionCtx: { ChatType: "direct", Provider: "telegram" },
silentReplyPolicy: "disallow",
silentReplyRewrite: true,
silentToken: "NO_REPLY",
}),
).toContain("so OpenClaw can send a short fallback reply");
expect(
groups.buildDirectChatContext({
sessionCtx: { ChatType: "direct", Provider: "telegram" },
silentReplyPolicy: "allow",
silentToken: "NO_REPLY",
}),
).toContain('reply with exactly "NO_REPLY"');
});
it("gates group silent-token instructions on the resolved silent reply policy", async () => {
const groups = await import("./groups.js");
const allowed = groups.buildGroupIntro({
cfg: {} as OpenClawConfig,
sessionCtx: { Provider: "whatsapp" },
defaultActivation: "always",
silentToken: "NO_REPLY",
silentReplyPolicy: "allow",
});
expect(allowed).toContain('reply with exactly "NO_REPLY"');
expect(allowed).toContain("Otherwise stay silent.");
const disallowed = groups.buildGroupIntro({
cfg: {} as OpenClawConfig,
sessionCtx: { Provider: "whatsapp" },
defaultActivation: "always",
silentToken: "NO_REPLY",
silentReplyPolicy: "disallow",
silentReplyRewrite: false,
});
expect(disallowed).toContain("Activation: always-on");
expect(disallowed).not.toContain("NO_REPLY");
expect(disallowed).not.toContain("Otherwise stay silent.");
const rewritten = groups.buildGroupIntro({
cfg: {} as OpenClawConfig,
sessionCtx: { Provider: "whatsapp" },
defaultActivation: "always",
silentToken: "NO_REPLY",
silentReplyPolicy: "disallow",
silentReplyRewrite: true,
});
expect(rewritten).toContain('reply with exactly "NO_REPLY"');
expect(rewritten).toContain("short fallback reply");
expect(rewritten).not.toContain("Otherwise stay silent.");
});
it("loads the group runtime only when requireMention resolution needs it", async () => {
const groupsRuntimeLoads = vi.fn();
vi.doMock("./groups.runtime.js", () => {

View File

@@ -1,6 +1,7 @@
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SilentReplyPolicy } from "../../shared/silent-reply-policy.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -227,29 +228,59 @@ export function buildGroupChatContext(params: { sessionCtx: TemplateContext }):
return lines.join(" ");
}
export function buildDirectChatContext(params: {
sessionCtx: TemplateContext;
silentReplyPolicy?: SilentReplyPolicy;
silentReplyRewrite?: boolean;
silentToken: string;
}): string {
const providerLabel = resolveProviderLabel(params.sessionCtx.Provider);
const lines: string[] = [];
lines.push(`You are in a ${providerLabel} direct conversation.`);
lines.push("Your replies are automatically sent to this conversation.");
if (params.silentReplyPolicy === "allow") {
lines.push(
`If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent.`,
);
} else if (params.silentReplyRewrite === true) {
lines.push(
`If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw can send a short fallback reply.`,
);
} else {
lines.push(`Do not use "${params.silentToken}" as your final answer in this conversation.`);
}
return lines.join(" ");
}
export function buildGroupIntro(params: {
cfg: OpenClawConfig;
sessionCtx: TemplateContext;
sessionEntry?: SessionEntry;
defaultActivation: "always" | "mention";
silentToken: string;
silentReplyPolicy?: SilentReplyPolicy;
silentReplyRewrite?: boolean;
}): string {
const activation =
normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation;
const canUseSilentReply =
params.silentReplyPolicy !== "disallow" || params.silentReplyRewrite === true;
const activationLine =
activation === "always"
? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const silenceLine =
activation === "always"
? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
activation === "always" && canUseSilentReply
? params.silentReplyPolicy === "allow"
? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
: `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw can send a short fallback reply. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.`
: undefined;
const toolSilenceLine =
activation === "always"
activation === "always" && canUseSilentReply
? `If you only react or otherwise handle the message without a text reply, your final answer must still be exactly "${params.silentToken}". Never say that you are staying quiet, keeping channel noise low, making a context-only note, or sending no channel reply.`
: undefined;
const cautionLine =
activation === "always"
activation === "always" && params.silentReplyPolicy === "allow"
? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent."
: undefined;
const lurkLine =

View File

@@ -1,10 +1,13 @@
import type { TypingCallbacks } from "../../channels/typing.js";
import { resolveSilentReplyPolicy } from "../../config/silent-reply.js";
import { resolveSilentReplySettings } 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 {
resolveSilentReplyRewriteText,
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";
@@ -115,37 +118,56 @@ function normalizeReplyPayloadInternal(
});
}
function shouldPreserveSilentFinalPayload(params: {
function resolveSilentFinalPayload(params: {
kind: ReplyDispatchKind;
payload: ReplyPayload;
silentReplyContext?: ReplyDispatcherOptions["silentReplyContext"];
}): boolean {
}): ReplyPayload | null | undefined {
if (params.kind !== "final") {
return false;
return undefined;
}
if (!isSilentReplyText(params.payload.text, SILENT_REPLY_TOKEN)) {
return false;
return undefined;
}
const context = params.silentReplyContext;
if (!context) {
return false;
return undefined;
}
const resolvedPolicy = resolveSilentReplyPolicy({
const resolvedSettings = resolveSilentReplySettings({
cfg: context.cfg,
sessionKey: context.sessionKey,
surface: context.surface,
conversationType: context.conversationType,
});
const shouldPreserve = resolvedPolicy !== "allow";
if (shouldPreserve) {
if (resolvedSettings.policy === "allow") {
return undefined;
}
if (resolvedSettings.rewrite) {
silentReplyLogger.debug("rewriting exact NO_REPLY final payload before delivery", {
hasSessionKey: Boolean(context.sessionKey),
surface: context.surface,
conversationType: context.conversationType,
resolvedPolicy: resolvedSettings.policy,
});
return {
...params.payload,
text: resolveSilentReplyRewriteText({
seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${params.payload.text ?? ""}`,
}),
};
}
if (!resolvedSettings.rewrite) {
silentReplyLogger.debug("preserving exact NO_REPLY final payload before normalization", {
hasSessionKey: Boolean(context.sessionKey),
surface: context.surface,
conversationType: context.conversationType,
resolvedPolicy,
resolvedPolicy: resolvedSettings.policy,
});
}
return shouldPreserve;
return {
...params.payload,
text: params.payload.text?.trim() || SILENT_REPLY_TOKEN,
};
}
export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher {
@@ -177,23 +199,21 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const originalWasExactSilent = isSilentReplyText(payload.text, SILENT_REPLY_TOKEN);
const normalized = shouldPreserveSilentFinalPayload({
const silentFinalPayload = resolveSilentFinalPayload({
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 }),
});
});
const normalized =
silentFinalPayload ??
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", {

View File

@@ -21,7 +21,7 @@ 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 () => {
it("rewrites exact NO_REPLY final payloads for direct sessions where rewrite is enabled", async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
@@ -48,6 +48,39 @@ describe("createReplyDispatcher", () => {
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true);
await dispatcher.waitForIdle();
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver.mock.calls[0]?.[0]?.text).not.toBe(SILENT_REPLY_TOKEN);
expect(deliver.mock.calls[0]?.[0]?.text).toBeTruthy();
});
it("preserves exact NO_REPLY final payloads for direct sessions where rewrite is disabled", async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: false,
},
},
},
};
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);

View File

@@ -245,6 +245,7 @@ describe("routeReply", () => {
cfg,
sessionKey: "agent:main:main",
policySessionKey: "agent:main:direct:U123",
isGroup: true,
});
expect(res.ok).toBe(true);
@@ -255,6 +256,44 @@ describe("routeReply", () => {
policyKey: "agent:main:direct:U123",
}),
});
const session = mocks.deliverOutboundPayloads.mock.calls.at(-1)?.[0]?.session;
expect(session).not.toEqual(expect.objectContaining({ conversationType: "group" }));
});
it("uses explicit policy conversation type to preserve routed direct silent replies", async () => {
const cfg = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
} as unknown as OpenClawConfig;
const res = await routeReply({
payload: { text: SILENT_REPLY_TOKEN },
channel: "slack",
to: "channel:C123",
cfg,
sessionKey: "agent:main:main",
policySessionKey: "agent:main:main",
policyConversationType: "direct",
});
expect(res.ok).toBe(true);
expectLastDelivery({
payloads: [expect.objectContaining({ text: SILENT_REPLY_TOKEN })],
session: expect.objectContaining({
key: "agent:main:main",
policyKey: "agent:main:main",
conversationType: "direct",
}),
});
});
it("applies responsePrefix when routing", async () => {

View File

@@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
import type { OriginatingChannelType } from "../templating.js";
@@ -48,6 +49,8 @@ export type RouteReplyParams = {
sessionKey?: string;
/** Session key for policy resolution when native-command delivery targets a different session. */
policySessionKey?: string;
/** Explicit conversation type for policy resolution when the policy key is generic. */
policyConversationType?: SilentReplyConversationType;
/** Provider account id (multi-account). */
accountId?: string;
/** Originating sender id for sender-scoped outbound media policy. */
@@ -125,6 +128,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
cfg,
sessionKey: policySessionKey,
surface: channelId ?? String(channel),
conversationType: params.policyConversationType,
}) !== "allow";
const normalized = shouldPreserveSilentPayload
? {
@@ -213,6 +217,9 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
agentId: resolvedAgentId,
sessionKey: params.sessionKey,
policySessionKey: params.policySessionKey,
conversationType: params.policyConversationType,
isGroup:
params.policySessionKey || params.policyConversationType ? undefined : params.isGroup,
requesterSenderId: params.requesterSenderId,
requesterSenderName: params.requesterSenderName,
requesterSenderUsername: params.requesterSenderUsername,

View File

@@ -3,21 +3,14 @@ import { resolveSilentReplyPolicy, resolveSilentReplyRewriteEnabled } from "./si
import type { OpenClawConfig } from "./types.openclaw.js";
describe("silent reply config resolution", () => {
it("uses the default direct/group/internal policy and rewrite flags", () => {
it("uses the default direct/group/internal policy", () => {
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",
@@ -34,17 +27,11 @@ describe("silent reply config resolution", () => {
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,
@@ -52,16 +39,9 @@ describe("silent reply config resolution", () => {
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", () => {
it("lets surface overrides beat the default policy", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
@@ -70,11 +50,6 @@ describe("silent reply config resolution", () => {
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
group: false,
internal: false,
},
},
},
surfaces: {
@@ -82,9 +57,6 @@ describe("silent reply config resolution", () => {
silentReply: {
direct: "allow",
},
silentReplyRewrite: {
direct: false,
},
},
},
};
@@ -96,6 +68,34 @@ describe("silent reply config resolution", () => {
surface: "telegram",
}),
).toBe("allow");
});
it("resolves rewrite defaults and surface overrides by conversation type", () => {
expect(resolveSilentReplyRewriteEnabled({ surface: "webchat" })).toBe(true);
expect(
resolveSilentReplyRewriteEnabled({
sessionKey: "agent:main:telegram:group:123",
surface: "telegram",
}),
).toBe(false);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReplyRewrite: {
direct: true,
},
},
},
surfaces: {
telegram: {
silentReplyRewrite: {
direct: false,
},
},
},
};
expect(
resolveSilentReplyRewriteEnabled({
cfg,

View File

@@ -526,6 +526,7 @@ export const sendHandlers: GatewayRequestHandlers = {
cfg,
agentId: effectiveAgentId,
sessionKey: outboundSessionKey,
conversationType: outboundRoute?.chatType,
});
const results = await deliverOutboundPayloads({
cfg,

View File

@@ -915,7 +915,7 @@ describe("deliverOutboundPayloads", () => {
);
});
it("applies silent-reply policy from the outbound session", async () => {
it("applies silent-reply rewrite policy from the outbound session", async () => {
const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-silent", roomId: "!room" });
const cfg: OpenClawConfig = {
agents: {
@@ -925,9 +925,6 @@ describe("deliverOutboundPayloads", () => {
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
@@ -945,7 +942,7 @@ describe("deliverOutboundPayloads", () => {
});
expect(sendMatrix).toHaveBeenCalledTimes(1);
expect(sendMatrix.mock.calls[0]?.[1]).toEqual(expect.any(String));
expect(sendMatrix.mock.calls[0]?.[1]).toBeTruthy();
expect(sendMatrix.mock.calls[0]?.[1]).not.toBe("NO_REPLY");
});

View File

@@ -730,6 +730,7 @@ async function deliverOutboundPayloadsCore(
cfg,
sessionKey: params.session?.policyKey ?? params.session?.key,
surface: channel,
conversationType: params.session?.conversationType,
});
const accountId = params.accountId;
const deps = params.deps;

View File

@@ -189,7 +189,7 @@ describe("normalizeReplyPayloadsForDelivery", () => {
]);
});
it("rewrites bare silent replies for direct conversations when requested", () => {
it("rewrites bare silent replies for direct conversations where silence is disallowed", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
@@ -198,9 +198,6 @@ describe("normalizeReplyPayloadsForDelivery", () => {
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
@@ -214,7 +211,7 @@ describe("normalizeReplyPayloadsForDelivery", () => {
}),
);
expect(projected).toHaveLength(1);
expect(projected[0]?.text).toEqual(expect.any(String));
expect(projected[0]?.text?.trim()).toBeTruthy();
expect(projected[0]?.text?.trim()).not.toBe("NO_REPLY");
});
@@ -227,9 +224,6 @@ describe("normalizeReplyPayloadsForDelivery", () => {
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
@@ -245,7 +239,7 @@ describe("normalizeReplyPayloadsForDelivery", () => {
).toEqual([]);
});
it("does not add rewrite chatter when visible content is already being delivered", () => {
it("does not add silent-reply chatter when visible content is already being delivered", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
@@ -254,9 +248,6 @@ describe("normalizeReplyPayloadsForDelivery", () => {
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
@@ -281,7 +272,6 @@ describe("normalizeReplyPayloadsForDelivery", () => {
agents: {
defaults: {
silentReply: { direct: "disallow", group: "allow", internal: "allow" },
silentReplyRewrite: { direct: true },
},
},
};
@@ -309,7 +299,7 @@ describe("normalizeReplyPayloadsForDelivery", () => {
}
});
it("falls back to the rewrite path when the query throws", () => {
it("falls back to the visible rewrite path when the query throws", () => {
const previousQuery = registerPendingSpawnedChildrenQuery(() => {
throw new Error("registry unavailable");
});
@@ -317,14 +307,14 @@ describe("normalizeReplyPayloadsForDelivery", () => {
const delivery = planSilent("agent:main:telegram:direct:789");
expect(delivery).toHaveLength(1);
expect(delivery[0]?.text).toBeTruthy();
expect(delivery[0]?.text).not.toMatch(/NO_REPLY/i);
expect(delivery[0]?.text).not.toBe("NO_REPLY");
} finally {
registerPendingSpawnedChildrenQuery(previousQuery);
}
});
});
it("keeps bare NO_REPLY visible when silence is disallowed but rewrite is off", () => {
it("keeps bare NO_REPLY visible when silence is disallowed and rewrite is disabled", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {

View File

@@ -243,18 +243,18 @@ export function createOutboundPayloadPlan(
});
continue;
}
const rewrittenPayload: ReplyPayload = {
const visibleSilentPayload: ReplyPayload = {
...entry.payload,
text: resolveSilentReplyRewriteText({
seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${entry.payload.text ?? ""}`,
}),
};
if (!isRenderablePayload(rewrittenPayload)) {
if (!isRenderablePayload(visibleSilentPayload)) {
continue;
}
plan.push({
payload: rewrittenPayload,
parts: resolveSendableOutboundReplyParts(rewrittenPayload),
payload: visibleSilentPayload,
parts: resolveSendableOutboundReplyParts(visibleSilentPayload),
hasPresentation: entry.hasPresentation,
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,

View File

@@ -115,6 +115,49 @@ describe("buildOutboundSessionContext", () => {
});
});
it("normalizes explicit conversation type for policy resolution", () => {
expect(
buildOutboundSessionContext({
cfg: {} as never,
sessionKey: "agent:main:generic",
conversationType: "channel",
}),
).toEqual({
key: "agent:main:generic",
conversationType: "group",
});
expect(
buildOutboundSessionContext({
cfg: {} as never,
conversationType: "dm",
}),
).toEqual({
conversationType: "direct",
});
});
it("falls back to isGroup when no explicit conversation type is provided", () => {
expect(
buildOutboundSessionContext({
cfg: {} as never,
sessionKey: "agent:main:generic",
isGroup: true,
}),
).toEqual({
key: "agent:main:generic",
conversationType: "group",
});
expect(
buildOutboundSessionContext({
cfg: {} as never,
isGroup: false,
}),
).toEqual({
conversationType: "direct",
});
});
it("returns undefined when all sender and session fields are blank", () => {
expect(
buildOutboundSessionContext({

View File

@@ -1,5 +1,7 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
export type OutboundSessionContext = {
@@ -7,6 +9,8 @@ export type OutboundSessionContext = {
key?: string;
/** Session key used for policy resolution when delivery differs from the control session. */
policyKey?: string;
/** Explicit conversation type for policy resolution when a session key is generic. */
conversationType?: SilentReplyConversationType;
/** Active agent id used for workspace-scoped media roots. */
agentId?: string;
/** Originating account id used for requester-scoped group policy resolution. */
@@ -25,6 +29,8 @@ export function buildOutboundSessionContext(params: {
cfg: OpenClawConfig;
sessionKey?: string | null;
policySessionKey?: string | null;
conversationType?: string | null;
isGroup?: boolean | null;
agentId?: string | null;
requesterAccountId?: string | null;
requesterSenderId?: string | null;
@@ -34,6 +40,17 @@ export function buildOutboundSessionContext(params: {
}): OutboundSessionContext | undefined {
const key = normalizeOptionalString(params.sessionKey);
const policyKey = normalizeOptionalString(params.policySessionKey);
const normalizedChatType = normalizeChatType(params.conversationType ?? undefined);
const conversationType: SilentReplyConversationType | undefined =
normalizedChatType === "group" || normalizedChatType === "channel"
? "group"
: normalizedChatType === "direct"
? "direct"
: params.isGroup === true
? "group"
: params.isGroup === false
? "direct"
: undefined;
const explicitAgentId = normalizeOptionalString(params.agentId);
const requesterAccountId = normalizeOptionalString(params.requesterAccountId);
const requesterSenderId = normalizeOptionalString(params.requesterSenderId);
@@ -47,6 +64,7 @@ export function buildOutboundSessionContext(params: {
if (
!key &&
!policyKey &&
!conversationType &&
!agentId &&
!requesterAccountId &&
!requesterSenderId &&
@@ -59,6 +77,7 @@ export function buildOutboundSessionContext(params: {
return {
...(key ? { key } : {}),
...(policyKey ? { policyKey } : {}),
...(conversationType ? { conversationType } : {}),
...(agentId ? { agentId } : {}),
...(requesterAccountId ? { requesterAccountId } : {}),
...(requesterSenderId ? { requesterSenderId } : {}),

View File

@@ -37,6 +37,37 @@ describe("classifySilentReplyConversationType", () => {
});
});
describe("resolveSilentReplyRewriteFromPolicies", () => {
it("uses defaults 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 settings 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);
});
});
describe("resolveSilentReplyPolicyFromPolicies", () => {
it("uses defaults when no overrides exist", () => {
expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "direct" })).toBe(
@@ -57,34 +88,3 @@ describe("resolveSilentReplyPolicyFromPolicies", () => {
).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

@@ -10,7 +10,11 @@ import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-fil
import { buildEmbeddedSystemPrompt } from "../../../src/agents/pi-embedded-runner/system-prompt.js";
import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js";
import { createStubTool } from "../../../src/agents/test-helpers/pi-tool-stubs.js";
import { buildGroupChatContext, buildGroupIntro } from "../../../src/auto-reply/reply/groups.js";
import {
buildDirectChatContext,
buildGroupChatContext,
buildGroupIntro,
} from "../../../src/auto-reply/reply/groups.js";
import {
buildInboundMetaSystemPrompt,
buildInboundUserContextPrefix,
@@ -118,6 +122,14 @@ function buildAutoReplySystemPrompt(params: {
}) {
const extraSystemPromptParts = [
buildInboundMetaSystemPrompt(params.sessionCtx),
params.sessionCtx.ChatType === "direct" || params.sessionCtx.ChatType === "dm"
? buildDirectChatContext({
sessionCtx: params.sessionCtx,
silentToken: SILENT_REPLY_TOKEN,
silentReplyPolicy: "disallow",
silentReplyRewrite: true,
})
: "",
params.includeGroupChatContext ? buildGroupChatContext({ sessionCtx: params.sessionCtx }) : "",
params.includeGroupIntro
? buildGroupIntro({
@@ -179,7 +191,7 @@ function createDirectScenario(workspaceDir: string): PromptScenario {
OriginatingChannel: "slack",
OriginatingTo: "D123",
AccountId: "A1",
ChatType: "direct",
ChatType: "dm",
SenderId: "U1",
SenderName: "Alice",
Body: "hi",