Skip to content

Commit 97e2f5b

Browse files
fix(auto-reply): honor direct silent empty replies
* fix(auto-reply): allow direct silent empty replies * fix(auto-reply): guard direct silent empty replies
1 parent eb7d89f commit 97e2f5b

4 files changed

Lines changed: 106 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323

2424
### Fixes
2525

26+
- Auto-reply: honor explicit `silentReply.direct: "allow"` for clean empty or reasoning-only direct chat turns while keeping the default direct-chat empty-response guard conservative. Fixes #74409. Thanks @jesuskannolis.
2627
- Ollama: normalize provider-prefixed tool-call names at the native stream boundary so Kimi/Ollama calls such as `functions.exec` dispatch as `exec` instead of missing configured tools. Fixes #74487. Thanks @afurm and @carreipeia.
2728
- Security/audit: resolve configured model aliases before model-tier and small-parameter checks, so alias-based GPT-5/Codex configs no longer report false weak-model warnings. Fixes #74455. Thanks @blaspat.
2829
- CLI/agent: isolate Gateway-timeout embedded fallback runs under explicit `gateway-fallback-*` sessions so accepted Gateway runs cannot race transcript locks or replace the routed conversation session. Fixes #62981. Thanks @HemantSudarshan.

docs/channels/groups.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ Replying to a bot message counts as an implicit mention when the channel support
329329
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
330330
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
331331
- Group chat prompt context carries the resolved silent-reply instruction every turn; workspace files should not duplicate `NO_REPLY` mechanics.
332-
- Groups where silent replies are allowed treat clean empty or reasoning-only model turns as silent, equivalent to `NO_REPLY`. Direct chats still treat empty replies as a failed agent turn.
332+
- Groups where silent replies are allowed treat clean empty or reasoning-only model turns as silent, equivalent to `NO_REPLY`. Direct chats do the same only when direct silent replies are explicitly allowed; otherwise empty replies remain failed agent turns.
333333
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
334334
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
335335

src/auto-reply/reply/get-reply-run.media-only.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ describe("runPreparedReply media-only handling", () => {
302302
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
303303
});
304304

305-
it("does not propagate empty-assistant silence for direct runs", async () => {
305+
it("keeps empty-assistant silence disabled for direct runs by default", async () => {
306306
await runPreparedReply(
307307
baseParams({
308308
ctx: {
@@ -331,6 +331,85 @@ describe("runPreparedReply media-only handling", () => {
331331
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false);
332332
});
333333

334+
it.each(["direct", "dm"] as const)(
335+
"propagates empty-assistant silence for %s runs with explicit direct silent replies",
336+
async (chatType) => {
337+
await runPreparedReply(
338+
baseParams({
339+
ctx: {
340+
Body: "",
341+
RawBody: "",
342+
CommandBody: "",
343+
ThreadHistoryBody: "Earlier direct message",
344+
OriginatingChannel: "slack",
345+
OriginatingTo: "D123",
346+
ChatType: chatType,
347+
},
348+
sessionCtx: {
349+
Body: "",
350+
BodyStripped: "",
351+
ThreadHistoryBody: "Earlier direct message",
352+
MediaPath: "/tmp/input.png",
353+
Provider: "slack",
354+
ChatType: chatType,
355+
OriginatingChannel: "slack",
356+
OriginatingTo: "D123",
357+
},
358+
cfg: {
359+
session: {},
360+
channels: {},
361+
agents: {
362+
defaults: {
363+
silentReply: {
364+
direct: "allow",
365+
},
366+
},
367+
},
368+
},
369+
}),
370+
);
371+
372+
const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0];
373+
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
374+
},
375+
);
376+
377+
it("does not borrow target-session silence for native commands sent from direct chats", async () => {
378+
await runPreparedReply(
379+
baseParams({
380+
sessionKey: "agent:main:telegram:group:target",
381+
ctx: {
382+
Body: "",
383+
RawBody: "",
384+
CommandBody: "",
385+
ThreadHistoryBody: "Earlier direct message",
386+
OriginatingChannel: "telegram",
387+
OriginatingTo: "D123",
388+
ChatType: "direct",
389+
CommandSource: "native",
390+
SessionKey: "agent:main:telegram:direct:source",
391+
CommandTargetSessionKey: "agent:main:telegram:group:target",
392+
},
393+
sessionCtx: {
394+
Body: "",
395+
BodyStripped: "",
396+
ThreadHistoryBody: "Earlier direct message",
397+
MediaPath: "/tmp/input.png",
398+
Provider: "telegram",
399+
ChatType: "direct",
400+
OriginatingChannel: "telegram",
401+
OriginatingTo: "D123",
402+
CommandSource: "native",
403+
SessionKey: "agent:main:telegram:direct:source",
404+
CommandTargetSessionKey: "agent:main:telegram:group:target",
405+
},
406+
}),
407+
);
408+
409+
const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0];
410+
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false);
411+
});
412+
334413
it("allows media-only prompts and preserves thread context in queued followups", async () => {
335414
const result = await runPreparedReply(baseParams());
336415
expect(result).toEqual({ text: "ok" });

src/auto-reply/reply/get-reply-run.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -400,14 +400,15 @@ export async function runPreparedReply(
400400
ctx,
401401
isHeartbeat,
402402
});
403+
const silentReplyConversationType = resolvePromptSilentReplyConversationType({
404+
ctx: promptSessionCtx,
405+
inboundSessionKey: ctx.SessionKey,
406+
});
403407
const silentReplySettings = resolveSilentReplySettings({
404408
cfg,
405409
sessionKey: runtimePolicySessionKey,
406410
surface: promptSessionCtx.Surface ?? promptSessionCtx.Provider,
407-
conversationType: resolvePromptSilentReplyConversationType({
408-
ctx: promptSessionCtx,
409-
inboundSessionKey: ctx.SessionKey,
410-
}),
411+
conversationType: silentReplyConversationType,
411412
});
412413
const useFastReplyRuntime = shouldUseReplyFastTestRuntime({
413414
cfg,
@@ -425,6 +426,7 @@ export async function runPreparedReply(
425426
const isFirstTurnInSession = isNewSession || !currentSystemSent;
426427
const isGroupChat =
427428
promptSessionCtx.ChatType === "group" || promptSessionCtx.ChatType === "channel";
429+
const isDirectChat = promptSessionCtx.ChatType === "direct" || promptSessionCtx.ChatType === "dm";
428430
const wasMentioned = ctx.WasMentioned === true;
429431
const { typingPolicy, suppressTyping } = resolveRunTypingPolicy({
430432
requestedPolicy: opts?.typingPolicy,
@@ -444,15 +446,14 @@ export async function runPreparedReply(
444446
const shouldInjectGroupIntro = Boolean(
445447
isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
446448
);
447-
const directChatContext =
448-
promptSessionCtx.ChatType === "direct" || promptSessionCtx.ChatType === "dm"
449-
? buildDirectChatContext({
450-
sessionCtx: promptSessionCtx,
451-
silentReplyPolicy: silentReplySettings.policy,
452-
silentReplyRewrite: silentReplySettings.rewrite,
453-
silentToken: SILENT_REPLY_TOKEN,
454-
})
455-
: "";
449+
const directChatContext = isDirectChat
450+
? buildDirectChatContext({
451+
sessionCtx: promptSessionCtx,
452+
silentReplyPolicy: silentReplySettings.policy,
453+
silentReplyRewrite: silentReplySettings.rewrite,
454+
silentToken: SILENT_REPLY_TOKEN,
455+
})
456+
: "";
456457
// Always include persistent group chat context (provider + reply guidance).
457458
const groupChatContext = isGroupChat
458459
? buildGroupChatContext({
@@ -476,13 +477,16 @@ export async function runPreparedReply(
476477
})
477478
: "";
478479
const allowEmptyAssistantReplyAsSilent =
479-
isGroupChat &&
480-
resolveGroupSilentReplyBehavior({
481-
sessionEntry,
482-
defaultActivation,
483-
silentReplyPolicy: silentReplySettings.policy,
484-
silentReplyRewrite: silentReplySettings.rewrite,
485-
}).allowEmptyAssistantReplyAsSilent;
480+
(isDirectChat &&
481+
silentReplyConversationType === "direct" &&
482+
silentReplySettings.policy === "allow") ||
483+
(isGroupChat &&
484+
resolveGroupSilentReplyBehavior({
485+
sessionEntry,
486+
defaultActivation,
487+
silentReplyPolicy: silentReplySettings.policy,
488+
silentReplyRewrite: silentReplySettings.rewrite,
489+
}).allowEmptyAssistantReplyAsSilent);
486490
const groupSystemPrompt = normalizeOptionalString(promptSessionCtx.GroupSystemPrompt) ?? "";
487491
const inboundMetaPrompt = buildInboundMetaSystemPrompt(
488492
isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined },

0 commit comments

Comments
 (0)