From f1eef47839d2c42014e2814a90f61e0f6a1f526f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 05:16:55 +0100 Subject: [PATCH] fix(agents): treat empty group replies as silent --- CHANGELOG.md | 1 + docs/channels/groups.md | 1 + .../run.incomplete-turn.test.ts | 133 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 78 ++++++---- .../pi-embedded-runner/run/incomplete-turn.ts | 19 +++ src/agents/pi-embedded-runner/run/params.ts | 6 + src/agents/pi-embedded-runner/types.ts | 1 + src/auto-reply/reply/agent-runner-utils.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + .../reply/get-reply-run.media-only.test.ts | 63 +++++++++ src/auto-reply/reply/get-reply-run.ts | 16 ++- src/auto-reply/reply/groups.test.ts | 34 +++++ src/auto-reply/reply/groups.ts | 27 +++- src/auto-reply/reply/queue/types.ts | 1 + 14 files changed, 349 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3dded7b5f..443246d93dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI. - Providers/Z.AI: map OpenClaw thinking controls to Z.AI's `thinking` payload and add opt-in preserved thinking replay via `params.preserveThinking`, so GLM 5.x can keep prior `reasoning_content` when requested. Fixes #58680. Thanks @xuanmingguo. - Channels/status: keep read-only channel lists on manifest and package metadata by default, loading setup runtime only for explicit fallback callers. Thanks @shakkernerd. - Plugins/onboarding: defer onboarding install-record index writes until the guarded config commit so setup failures cannot leave the plugin index ahead of `openclaw.json`. Thanks @shakkernerd. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index feae2f6e92f..51e0518c134 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -272,6 +272,7 @@ Notes: - Surfaces that provide explicit mentions still pass; patterns are a fallback. - Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group). - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). +- Always-on groups where silent replies are allowed treat a clean empty model reply as silent, equivalent to `NO_REPLY`. Mention-gated groups and direct chats still treat empty replies as a failed agent turn. - Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel). - 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..historyLimit` (or `channels..accounts.*.historyLimit`) for overrides. Set `0` to disable. diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index 23054174fce..8cacbde3814 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -30,6 +30,7 @@ import { STRICT_AGENTIC_BLOCKED_TEXT, resolveReplayInvalidFlag, resolveRunLivenessState, + shouldTreatEmptyAssistantReplyAsSilent, } from "./run/incomplete-turn.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -1151,6 +1152,138 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT).toBe(1); }); + it("treats clean empty assistant turns as silent only when the caller allows it", () => { + const attempt = makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai-codex", + model: "gpt-5.5", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }); + + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toBe(true); + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: false, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt, + }), + ).toBe(false); + }); + + it("does not treat error or side-effect empty turns as silent", () => { + const errorAttempt = makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "error", + provider: "openai-codex", + model: "gpt-5.5", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }); + const sideEffectAttempt = makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + messagingToolSentTexts: ["sent already"], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai-codex", + model: "gpt-5.5", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }); + + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: errorAttempt, + }), + ).toBe(false); + expect( + shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: true, + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: sideEffectAttempt, + }), + ).toBe(false); + }); + + it("returns NO_REPLY without retrying clean empty assistant turns when silence is allowed", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValue( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai-codex", + model: "gpt-5.5", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + allowEmptyAssistantReplyAsSilent: true, + provider: "openai-codex", + model: "gpt-5.5", + runId: "run-empty-assistant-silent", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + expect(result.payloads).toEqual([{ text: "NO_REPLY" }]); + expect(result.meta.terminalReplyKind).toBe("silent-empty"); + expect(result.meta.livenessState).toBe("working"); + }); + + it("keeps retrying and surfacing clean empty assistant turns without the silence flag", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValue( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-empty-assistant-error", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("couldn't generate a response"); + }); + it("detects generic empty Gemini turns without visible text", () => { const retryInstruction = resolveEmptyResponseRetryInstruction({ provider: "google-vertex", diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d1ff60bec63..05be1fb31a6 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -121,6 +121,7 @@ import { STRICT_AGENTIC_BLOCKED_TEXT, resolveReplayInvalidFlag, resolveRunLivenessState, + shouldTreatEmptyAssistantReplyAsSilent, } from "./run/incomplete-turn.js"; import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; @@ -1866,32 +1867,45 @@ export async function runEmbeddedPiAgent( } const payloadCount = payloadsWithToolMedia?.length ?? 0; - const nextPlanningOnlyRetryInstruction = resolvePlanningOnlyRetryInstruction({ - provider, - modelId, - executionContract, - prompt: params.prompt, - aborted, - timedOut, - attempt, - }); - const nextReasoningOnlyRetryInstruction = resolveReasoningOnlyRetryInstruction({ - provider: activeErrorContext.provider, - modelId: activeErrorContext.model, - executionContract, - aborted, - timedOut, - attempt, - }); - const nextEmptyResponseRetryInstruction = resolveEmptyResponseRetryInstruction({ - provider: activeErrorContext.provider, - modelId: activeErrorContext.model, - executionContract, + const emptyAssistantReplyIsSilent = shouldTreatEmptyAssistantReplyAsSilent({ + allowEmptyAssistantReplyAsSilent: params.allowEmptyAssistantReplyAsSilent, payloadCount, aborted, timedOut, attempt, }); + const nextPlanningOnlyRetryInstruction = emptyAssistantReplyIsSilent + ? null + : resolvePlanningOnlyRetryInstruction({ + provider, + modelId, + executionContract, + prompt: params.prompt, + aborted, + timedOut, + attempt, + }); + const nextReasoningOnlyRetryInstruction = emptyAssistantReplyIsSilent + ? null + : resolveReasoningOnlyRetryInstruction({ + provider: activeErrorContext.provider, + modelId: activeErrorContext.model, + executionContract, + aborted, + timedOut, + attempt, + }); + const nextEmptyResponseRetryInstruction = emptyAssistantReplyIsSilent + ? null + : resolveEmptyResponseRetryInstruction({ + provider: activeErrorContext.provider, + modelId: activeErrorContext.model, + executionContract, + payloadCount, + aborted, + timedOut, + attempt, + }); if ( nextPlanningOnlyRetryInstruction && planningOnlyRetryAttempts < maxPlanningOnlyRetryAttempts @@ -1963,12 +1977,14 @@ export async function runEmbeddedPiAgent( ); continue; } - const incompleteTurnText = resolveIncompleteTurnPayloadText({ - payloadCount, - aborted, - timedOut, - attempt, - }); + const incompleteTurnText = emptyAssistantReplyIsSilent + ? null + : resolveIncompleteTurnPayloadText({ + payloadCount, + aborted, + timedOut, + attempt, + }); if (reasoningOnlyRetriesExhausted && !finalAssistantVisibleText) { log.warn( `reasoning-only retries exhausted: runId=${params.runId} sessionId=${params.sessionId} ` + @@ -2213,12 +2229,15 @@ export async function runEmbeddedPiAgent( : attempt.yieldDetected ? "end_turn" : (sessionLastAssistant?.stopReason as string | undefined); + const terminalPayloads = emptyAssistantReplyIsSilent + ? [{ text: SILENT_REPLY_TOKEN }] + : payloadsWithToolMedia; attempt.setTerminalLifecycleMeta?.({ replayInvalid, livenessState, }); return { - payloads: payloadsWithToolMedia?.length ? payloadsWithToolMedia : undefined, + payloads: terminalPayloads?.length ? terminalPayloads : undefined, ...(attempt.diagnosticTrace ? { diagnosticTrace: freezeDiagnosticTraceContext(attempt.diagnosticTrace) } : {}), @@ -2233,6 +2252,9 @@ export async function runEmbeddedPiAgent( replayInvalid, livenessState, agentHarnessResultClassification: attempt.agentHarnessResultClassification, + ...(emptyAssistantReplyIsSilent + ? { terminalReplyKind: "silent-empty" as const } + : {}), // Handle client tool calls (OpenResponses hosted tools) // Propagate the LLM stop reason so callers (lifecycle events, // ACP bridge) can distinguish end_turn from max_tokens. diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index 1038aa9b71e..2cd0a052e0b 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -345,6 +345,25 @@ function shouldSkipPlanningOnlyRetry(params: { ); } +export function shouldTreatEmptyAssistantReplyAsSilent(params: { + allowEmptyAssistantReplyAsSilent?: boolean; + payloadCount: number; + aborted: boolean; + timedOut: boolean; + attempt: IncompleteTurnAttempt; +}): boolean { + if (!params.allowEmptyAssistantReplyAsSilent || shouldSkipPlanningOnlyRetry(params)) { + return false; + } + if (hasCommittedUserVisibleToolDelivery(params.attempt)) { + return false; + } + return isEmptyResponseAssistantTurn({ + payloadCount: params.payloadCount, + attempt: params.attempt, + }); +} + export function resolveReasoningOnlyRetryInstruction(params: { provider?: string; modelId?: string; diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 73a2f003758..4f709d52418 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -142,6 +142,12 @@ export type RunEmbeddedPiAgentParams = { ownerNumbers?: string[]; enforceFinalTag?: boolean; silentExpected?: boolean; + /** + * Treat a clean empty assistant stop as an intentional silent reply. + * Only set when the caller's prompt policy already allows an exact NO_REPLY + * final answer for silence. + */ + allowEmptyAssistantReplyAsSilent?: boolean; authProfileFailurePolicy?: AuthProfileFailurePolicy; /** * Allow a single run attempt even when all auth profiles are in cooldown, diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 385e7d34216..1b38517285b 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -113,6 +113,7 @@ export type EmbeddedPiRunMeta = { replayInvalid?: boolean; livenessState?: EmbeddedRunLivenessState; agentHarnessResultClassification?: "empty" | "reasoning-only" | "planning-only"; + terminalReplyKind?: "silent-empty"; error?: { kind: | "context_overflow" diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 23b273353da..089eab6f70a 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -217,6 +217,7 @@ export function buildEmbeddedRunBaseParams(params: { senderIsOwner: params.run.senderIsOwner, enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider, params.model), silentExpected: params.run.silentExpected, + allowEmptyAssistantReplyAsSilent: params.run.allowEmptyAssistantReplyAsSilent, provider: params.provider, model: params.model, ...params.authProfile, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index a8efcf9a4d6..4f0e79a06b8 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -314,6 +314,7 @@ export function createFollowupRunner(params: { extraSystemPrompt: run.extraSystemPrompt, ownerNumbers: run.ownerNumbers, enforceFinalTag: run.enforceFinalTag, + allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, provider, model, ...authProfile, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 51ef9e95a85..8af6b85d29b 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -77,6 +77,24 @@ vi.mock("./groups.js", () => ({ buildDirectChatContext: vi.fn().mockReturnValue(""), buildGroupIntro: vi.fn().mockReturnValue(""), buildGroupChatContext: vi.fn().mockReturnValue(""), + resolveGroupSilentReplyBehavior: vi.fn( + (params: { + sessionEntry?: SessionEntry; + defaultActivation: "always" | "mention"; + silentReplyPolicy?: "allow" | "disallow"; + silentReplyRewrite?: boolean; + }) => { + const activation = params.sessionEntry?.groupActivation ?? params.defaultActivation; + const canUseSilentReply = + params.silentReplyPolicy !== "disallow" || params.silentReplyRewrite === true; + return { + activation, + canUseSilentReply, + allowEmptyAssistantReplyAsSilent: + activation === "always" && params.silentReplyPolicy === "allow", + }; + }, + ), })); vi.mock("./inbound-meta.js", () => ({ @@ -266,6 +284,51 @@ describe("runPreparedReply media-only handling", () => { ); }); + it("propagates empty-assistant silence only for always-on group runs", async () => { + await runPreparedReply(baseParams()); + + let call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; + expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true); + + await runPreparedReply( + baseParams({ + defaultActivation: "mention", + }), + ); + + call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; + expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false); + }); + + it("does not propagate empty-assistant silence for direct runs", async () => { + await runPreparedReply( + baseParams({ + ctx: { + Body: "", + RawBody: "", + CommandBody: "", + ThreadHistoryBody: "Earlier direct message", + OriginatingChannel: "slack", + OriginatingTo: "D123", + ChatType: "direct", + }, + sessionCtx: { + Body: "", + BodyStripped: "", + ThreadHistoryBody: "Earlier direct message", + MediaPath: "/tmp/input.png", + Provider: "slack", + ChatType: "direct", + OriginatingChannel: "slack", + OriginatingTo: "D123", + }, + }), + ); + + const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; + expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false); + }); + it("allows media-only prompts and preserves thread context in queued followups", async () => { const result = await runPreparedReply(baseParams()); expect(result).toEqual({ text: "ok" }); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 19873755e65..39405ef2852 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -45,7 +45,12 @@ 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 { buildDirectChatContext, buildGroupChatContext, buildGroupIntro } from "./groups.js"; +import { + buildDirectChatContext, + buildGroupChatContext, + buildGroupIntro, + resolveGroupSilentReplyBehavior, +} from "./groups.js"; import { hasInboundMedia } from "./inbound-media.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; import type { createModelSelectionState } from "./model-selection.js"; @@ -346,6 +351,14 @@ export async function runPreparedReply( silentReplyRewrite: silentReplySettings.rewrite, }) : ""; + const allowEmptyAssistantReplyAsSilent = + isGroupChat && + resolveGroupSilentReplyBehavior({ + sessionEntry, + defaultActivation, + silentReplyPolicy: silentReplySettings.policy, + silentReplyRewrite: silentReplySettings.rewrite, + }).allowEmptyAssistantReplyAsSilent; const groupSystemPrompt = normalizeOptionalString(sessionCtx.GroupSystemPrompt) ?? ""; const inboundMetaPrompt = buildInboundMetaSystemPrompt( isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined }, @@ -818,6 +831,7 @@ export async function runPreparedReply( extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined, extraSystemPromptStatic: extraSystemPromptStaticParts.join("\n\n"), skipProviderRuntimeHints: useFastReplyRuntime, + allowEmptyAssistantReplyAsSilent, ...(!useFastReplyRuntime && isReasoningTagProvider(provider, { config: cfg, diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index eaaae80bdaf..d4cf882c7fa 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -124,6 +124,40 @@ describe("group runtime loading", () => { expect(rewritten).not.toContain("Be extremely selective"); }); + it("marks empty assistant replies silent only for always-on groups with silence allowed", async () => { + const groups = await import("./groups.js"); + + expect( + groups.resolveGroupSilentReplyBehavior({ + defaultActivation: "always", + silentReplyPolicy: "allow", + }).allowEmptyAssistantReplyAsSilent, + ).toBe(true); + + expect( + groups.resolveGroupSilentReplyBehavior({ + defaultActivation: "mention", + silentReplyPolicy: "allow", + }).allowEmptyAssistantReplyAsSilent, + ).toBe(false); + + expect( + groups.resolveGroupSilentReplyBehavior({ + sessionEntry: { groupActivation: "mention" } as never, + defaultActivation: "always", + silentReplyPolicy: "allow", + }).allowEmptyAssistantReplyAsSilent, + ).toBe(false); + + expect( + groups.resolveGroupSilentReplyBehavior({ + defaultActivation: "always", + silentReplyPolicy: "disallow", + silentReplyRewrite: true, + }).allowEmptyAssistantReplyAsSilent, + ).toBe(false); + }); + it("loads the group runtime only when requireMention resolution needs it", async () => { const groupsRuntimeLoads = vi.fn(); vi.doMock("./groups.runtime.js", () => { diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 36cf0f0cc13..ddad8d372d6 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -252,6 +252,28 @@ export function buildDirectChatContext(params: { return lines.join(" "); } +export function resolveGroupSilentReplyBehavior(params: { + sessionEntry?: SessionEntry; + defaultActivation: "always" | "mention"; + silentReplyPolicy?: SilentReplyPolicy; + silentReplyRewrite?: boolean; +}): { + activation: "always" | "mention"; + canUseSilentReply: boolean; + allowEmptyAssistantReplyAsSilent: boolean; +} { + const activation = + normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation; + const canUseSilentReply = + params.silentReplyPolicy !== "disallow" || params.silentReplyRewrite === true; + return { + activation, + canUseSilentReply, + allowEmptyAssistantReplyAsSilent: + activation === "always" && params.silentReplyPolicy === "allow", + }; +} + export function buildGroupIntro(params: { cfg: OpenClawConfig; sessionCtx: TemplateContext; @@ -261,10 +283,7 @@ export function buildGroupIntro(params: { silentReplyPolicy?: SilentReplyPolicy; silentReplyRewrite?: boolean; }): string { - const activation = - normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation; - const canUseSilentReply = - params.silentReplyPolicy !== "disallow" || params.silentReplyRewrite === true; + const { activation, canUseSilentReply } = resolveGroupSilentReplyBehavior(params); const activationLine = activation === "always" ? "Activation: always-on (you receive every group message)." diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index cac6bf9cc68..e48427b398c 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -91,6 +91,7 @@ export type FollowupRun = { enforceFinalTag?: boolean; skipProviderRuntimeHints?: boolean; silentExpected?: boolean; + allowEmptyAssistantReplyAsSilent?: boolean; }; };