mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(auto-reply): restore ambient room event silence (#126527)
Restore intentional silence for undirected room events while retaining visible-answer recovery for mentions, commands, and ordinary requests. Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -111,6 +111,7 @@ export function buildEmbeddedRunBaseParams(params: {
|
||||
enforceFinalTag,
|
||||
silentExpected: params.run.silentExpected,
|
||||
allowEmptyAssistantReplyAsSilent: params.run.allowEmptyAssistantReplyAsSilent,
|
||||
terminalReplyExpectation: params.run.terminalReplyExpectation,
|
||||
silentReplyPromptMode: params.run.silentReplyPromptMode,
|
||||
sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode,
|
||||
clientCaps: params.run.clientCaps,
|
||||
|
||||
@@ -148,6 +148,7 @@ describe("agent-runner-utils", () => {
|
||||
enforceFinalTag: true,
|
||||
cwd: "/tmp/task-repo",
|
||||
taskSuggestionDeliveryMode: "gateway",
|
||||
terminalReplyExpectation: "optional",
|
||||
trustedInternalHandoff: {
|
||||
kind: "subagent-completion",
|
||||
sourceSessionKey: "agent:child",
|
||||
@@ -202,6 +203,7 @@ describe("agent-runner-utils", () => {
|
||||
expect(resolved.runId).toBe("run-1");
|
||||
expect(resolved.promptCacheKey).toBe("webchat-cache-key");
|
||||
expect(resolved.taskSuggestionDeliveryMode).toBe("gateway");
|
||||
expect(resolved.terminalReplyExpectation).toBe("optional");
|
||||
});
|
||||
|
||||
it("threads prompt cache affinity through embedded execution params", () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js";
|
||||
import { isDuplicateRestartRecoverySource } from "./restart-recovery-claim.js";
|
||||
import { resolveStableMessageToolAvailability } from "./session-stable-reply-mode.js";
|
||||
import {
|
||||
isDirectedSourceReplyTurn,
|
||||
isExplicitSourceReplyCommand,
|
||||
isUnauthorizedTextSlashCommand,
|
||||
resolveSourceReplyVisibilityPolicy,
|
||||
@@ -378,18 +379,7 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli
|
||||
const explicitCommandTurnCtx = isExplicitSourceReplyCommand(ctx, cfg);
|
||||
const unauthorizedTextSlashSourceReplyCtx =
|
||||
(chatType === "group" || chatType === "channel") && isUnauthorizedTextSlashCommand(ctx);
|
||||
// The no-visible-reply fallback exists for a user who asked and got nothing.
|
||||
// Only positively directed turns qualify: direct chats, explicit mentions
|
||||
// (channels fold reply-to-bot into WasMentioned), and command turns. Ambient
|
||||
// group chatter, room events, and turns whose classification facts were lost
|
||||
// upstream (queued followups, rebuilt contexts) can never draw a visible
|
||||
// failure notice, even when silence policy is disallow. A command turn is the
|
||||
// one directed room_event (mirrors the room_event source-reply suppression
|
||||
// bypass below); every other room_event stays undirected regardless of a
|
||||
// stray WasMentioned/direct classification.
|
||||
const noVisibleReplyFallbackDirected =
|
||||
explicitCommandTurnCtx ||
|
||||
(ctx.InboundEventKind !== "room_event" && (chatType === "direct" || ctx.WasMentioned === true));
|
||||
const noVisibleReplyFallbackDirected = isDirectedSourceReplyTurn(ctx, cfg, chatType === "direct");
|
||||
const shouldDeliverPluginBindingReply =
|
||||
!suppressAutomaticSourceDelivery ||
|
||||
explicitCommandTurnCtx ||
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
} from "./session-reset-prompt.js";
|
||||
import { resolveSessionStableReplyMode } from "./session-stable-reply-mode.js";
|
||||
import {
|
||||
isExplicitSourceReplyCommand,
|
||||
isDirectedSourceReplyTurn,
|
||||
isSyntheticSourceReplyTurn,
|
||||
} from "./source-reply-delivery-mode.js";
|
||||
import { shouldApplyStartupContext, buildSessionStartupContextPrelude } from "./startup-context.js";
|
||||
@@ -223,11 +223,18 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
|
||||
sourceConversationContextByMode[sessionPromptSourceReplyDeliveryMode ?? "automatic"];
|
||||
// Claude CLI fixes the system prompt at session creation; group intro must stay session-stable.
|
||||
const groupIntro = isGroupChat ? buildGroupIntro({ sessionEntry, defaultActivation }) : "";
|
||||
const isDirectedTurn =
|
||||
isExplicitSourceReplyCommand(ctx, cfg) ||
|
||||
(inboundEventKind !== "room_event" && (isDirectChat || ctx.WasMentioned === true));
|
||||
const isDirectedTurn = isDirectedSourceReplyTurn(ctx, cfg, isDirectChat, inboundEventKind);
|
||||
const isAmbientRoomEvent = inboundEventKind === "room_event" && !isDirectedTurn;
|
||||
const allowEmptyAssistantReplyAsSilent =
|
||||
isGroupChat && !isDirectedTurn && silentReplySettings.policy === "allow";
|
||||
isGroupChat &&
|
||||
!isDirectedTurn &&
|
||||
(isAmbientRoomEvent || silentReplySettings.policy === "allow");
|
||||
// Heartbeats retain the embedded runner's trigger-owned optional default.
|
||||
const terminalReplyExpectation = isHeartbeat
|
||||
? undefined
|
||||
: isAmbientRoomEvent
|
||||
? "optional"
|
||||
: "required";
|
||||
const groupSystemPrompt = normalizeOptionalString(promptSessionCtx.GroupSystemPrompt) ?? "";
|
||||
const inboundMetaPrompt = buildInboundMetaSystemPrompt(
|
||||
isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined },
|
||||
@@ -476,6 +483,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
|
||||
getInboundContext: () => ({ activeGoalContext, inboundUserContext }),
|
||||
refreshInboundContextAfterAdmissionWait,
|
||||
allowEmptyAssistantReplyAsSilent,
|
||||
terminalReplyExpectation,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
|
||||
shouldInjectGroupIntro,
|
||||
typingMode,
|
||||
allowEmptyAssistantReplyAsSilent,
|
||||
terminalReplyExpectation,
|
||||
} = context;
|
||||
const {
|
||||
ctx,
|
||||
@@ -497,6 +498,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
|
||||
cliSessionBindingFacts,
|
||||
skipProviderRuntimeHints: useFastReplyRuntime,
|
||||
allowEmptyAssistantReplyAsSilent,
|
||||
terminalReplyExpectation,
|
||||
suppressTranscriptOnlyAssistantPersistence: isRoomEvent,
|
||||
...(opts?.skillWorkshopProposalRevision
|
||||
? {
|
||||
|
||||
@@ -622,6 +622,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
|
||||
let call = requireLastRunReplyAgentCall();
|
||||
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
|
||||
expect(call?.followupRun.run.terminalReplyExpectation).toBe("required");
|
||||
|
||||
await runPrepared({
|
||||
defaultActivation: "mention",
|
||||
@@ -629,6 +630,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
|
||||
call = requireLastRunReplyAgentCall();
|
||||
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
|
||||
expect(call?.followupRun.run.terminalReplyExpectation).toBe("required");
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -658,9 +660,10 @@ describe("runPreparedReply media-only handling", () => {
|
||||
|
||||
const call = requireLastRunReplyAgentCall();
|
||||
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false);
|
||||
expect(call?.followupRun.run.terminalReplyExpectation).toBe("required");
|
||||
});
|
||||
|
||||
it("keeps empty-assistant silence available for ambient room events", async () => {
|
||||
it("keeps empty-assistant silence optional for ambient room events", async () => {
|
||||
const defaults = baseParams();
|
||||
await runPrepared({
|
||||
ctx: {
|
||||
@@ -673,10 +676,20 @@ describe("runPreparedReply media-only handling", () => {
|
||||
InboundEventKind: "room_event",
|
||||
WasMentioned: true,
|
||||
},
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
silentReply: { group: "disallow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const call = requireLastRunReplyAgentCall();
|
||||
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
|
||||
expect(call.followupRun.run).toMatchObject({
|
||||
allowEmptyAssistantReplyAsSilent: true,
|
||||
terminalReplyExpectation: "optional",
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates runtime thinking metadata before trusting static provider support", async () => {
|
||||
@@ -2420,6 +2433,7 @@ describe("runPreparedReply media-only handling", () => {
|
||||
expect(call?.shouldSteer).toBe(false);
|
||||
expect(call?.shouldFollowup).toBe(true);
|
||||
expect(call?.isActive).toBe(true);
|
||||
expect(call?.followupRun.run.terminalReplyExpectation).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -280,6 +280,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
|
||||
execution.skipProviderRuntimeHints === true,
|
||||
execution.silentExpected === true,
|
||||
execution.allowEmptyAssistantReplyAsSilent === true,
|
||||
execution.terminalReplyExpectation ?? "",
|
||||
execution.suppressNextUserMessagePersistence === true,
|
||||
execution.suppressTranscriptOnlyAssistantPersistence === true,
|
||||
execution.blockReplyBreak,
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema
|
||||
import type { AutoFallbackPrimaryProbe } from "../../../agents/agent-scope.js";
|
||||
import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
|
||||
import type { CliSessionBindingFacts } from "../../../agents/cli-runner/types.js";
|
||||
import type { CurrentInboundPromptContext } from "../../../agents/embedded-agent-runner/run/params.js";
|
||||
import type {
|
||||
CurrentInboundPromptContext,
|
||||
RunEmbeddedAgentParams,
|
||||
} from "../../../agents/embedded-agent-runner/run/params.js";
|
||||
import type { ModelFallbackRouteResolution } from "../../../agents/model-fallback.types.js";
|
||||
import type { ScheduledToolPolicyContext } from "../../../agents/scheduled-tool-policy.js";
|
||||
import type { TrustedSubagentCompletionHandoff } from "../../../agents/subagents/announce/subagent-announce-handoff.js";
|
||||
@@ -230,6 +233,7 @@ export type FollowupRun = {
|
||||
skipProviderRuntimeHints?: boolean;
|
||||
silentExpected?: boolean;
|
||||
allowEmptyAssistantReplyAsSilent?: boolean;
|
||||
terminalReplyExpectation?: RunEmbeddedAgentParams["terminalReplyExpectation"];
|
||||
suppressNextUserMessagePersistence?: boolean;
|
||||
suppressTranscriptOnlyAssistantPersistence?: boolean;
|
||||
/** Gateway-private optimistic-concurrency constraint for an operator-requested proposal revision. */
|
||||
|
||||
@@ -21,6 +21,7 @@ export type SourceReplyDeliveryModeContext = {
|
||||
CommandSource?: "text" | "native";
|
||||
CommandTurn?: CommandTurnContext;
|
||||
BotUsername?: string;
|
||||
WasMentioned?: boolean;
|
||||
InputProvenance?: InputProvenance;
|
||||
};
|
||||
|
||||
@@ -43,6 +44,22 @@ export function isExplicitSourceReplyCommand(
|
||||
return isExplicitCommandTurnContext(ctx, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Room events remain ambient despite stale mention/direct facts. Explicit commands stay directed
|
||||
* because their parsed command context is authoritative.
|
||||
*/
|
||||
export function isDirectedSourceReplyTurn(
|
||||
ctx: SourceReplyDeliveryModeContext,
|
||||
cfg: OpenClawConfig,
|
||||
isDirectChat: boolean,
|
||||
inboundEventKind = ctx.InboundEventKind,
|
||||
): boolean {
|
||||
return (
|
||||
isExplicitSourceReplyCommand(ctx, cfg) ||
|
||||
(inboundEventKind !== "room_event" && (isDirectChat || ctx.WasMentioned === true))
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns true for text slash commands that lack authorization metadata. */
|
||||
export function isUnauthorizedTextSlashCommand(ctx: SourceReplyDeliveryModeContext): boolean {
|
||||
const commandTurn = resolveCommandTurnContext(ctx);
|
||||
|
||||
Reference in New Issue
Block a user