mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1083,6 +1083,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "imessage",
|
||||
policyConversationType: "direct",
|
||||
to: "imessage:+15550001111",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -526,6 +526,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
cfg,
|
||||
agentId: effectiveAgentId,
|
||||
sessionKey: outboundSessionKey,
|
||||
conversationType: outboundRoute?.chatType,
|
||||
});
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user