diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 72ef38a36716..2c86504e3d73 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"656879cbd7d8c60a593ea053ccb8ba0536d4b5eb5e4ae61755a31d3e18a49c42","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"59fb241bbe47669d5d7f99a945a18d86f318b9af8c4c281bb11f01506c84ebbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index c8eaa76bb235..d8bd1df6bd82 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"72bf26feecd57463e402f8ed15e6b4edba23fc8276ebc2f6ae55d016a4fef381","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"b7195e12bbe582de01faacc9d3a22f5bc30e0210f7b0c03d3b6242b969f7a0be","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index e6555eb6de7e..c916c343013f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"7c1094e554ba440a2204f18658611682a101e12dc3d79c1005ae8f40fd766d52","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"04848d6e3e592c5edf260afa6ab27452770f99ef09e110809fab0813fd1671b0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index b2f89580b410..02b270894ae1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6bad9d034375b1641f89f78f224826127f9e8b74f29cd9086d24ac721e8bd714","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"656e29f955e218c9c35843a876eecd769b722e64fb8ea1849f266fa9a430ffd1","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index b77980e91d7b..b63227e93569 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"74ba73d4b1b1e114886afabfb3942d72e6062b07486cd2d6f910c61d7183dabe","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f57838a5ab613e8e168c74cb309beef813587f5465ff80f18b8171320f579e7a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index cf41cb8284f8..fa5b42274078 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"e71a02577d9098115855236dfb546ca2c2a5d05c855ae0c30d90f1fdcf3fad6a","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"0b81e57bcb56659f98b52d55a1dea6f987bae23500a62d56daf4e86065929ac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 8b06f34ab80d..5e031f0b5935 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"b902346ba2e50c3ebf787aefa8b3415cd65fb87000d42278a6db9ee4960f9aa1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"57eaf2d515736c9a6880da9434dd6456e7744e75ac8a946709d9e5f30b166aed","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 986d3c27c4cb..872ff135b1d1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"c1deb3dd9ab64a993067f7f736ae31b40dee5b96e03e2196c3c482cd911ac0ff","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"bb239ef24687ac9b89335858dff0008f3b861546c242e3af3545fbcae1878309","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 05cfb1cee958..87ac95ea1cca 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"4f8b3a784b00784d7a250b5489bbc20d099f824b8d9ff914dd8d0c93389b07de","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"34a3ec8d182434d7682a6ae2258a4b1797ffc20f62a7a0449ca3055ccc8c69c5","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 727ebf40a932..cf22bb77fc49 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"1a0fef1510e351de8902c038e6cde7915707897dcc999455806e5063cb75af18","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"080a0e85aaff22d1e860781d779254461b05e40e4af84b37197b9ae6281ca566","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index d0bbfd77ae0f..c8116ff80bce 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"3cf394e9cb5f76a7fa44727b7e13adcd8dff1b9d4b5b48e9ac2d0adc06f17a0e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"ecf1ca74a42f003b046e3bf2578bae03c609050c6cb16fa741f763d692937e67","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index a03fc757016b..0cc601906bfe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"1489259884d6df7354edd810aa984442ac3851451ba5991c85f491c99357281a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"a8e907e41641913ebb36b982bb320090197af1408a0196672c15d038883cf1e0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index d0f9cde6da22..4c888362e736 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"af318d3b8a4edec2d40ad123b97e076910c09b7d517cb03588a5f3c20b808f31","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"8d4c89bf2968217111f333c149b51bb6798dd961a62fbafeef1c19f2473e7ab6","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 0329732a7a95..27cdf069b69c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"e04b2dea0c6c27657372b826db2870cd399ed9f13d5d76d006dd71ac60024187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"5772bc63ec00a1de6e76827a9a8f79c1ed61493d07f6c2bb21f50c18a806e187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index b6ec2050baf4..5dc9f0ffad81 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"10bdd5bfc10ab30dad145d36d7a78a052f52960c4f8e534a0ba258f5f777dba5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"993018e34f2e7951c2a0412a4df0cd4be50256da04f3d6a539942c3a8168da24","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 0f7db34d13d5..3d26f79a7fcb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"56937e6aa9f4060b93d632f4271bbaa9ba992a7a2fe4c9a6f4b5a5c90b0a41da","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"079ce3652967694b58caa87ed04cdaba07b3766d82c491faa9a6f974d584e18b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/src/agents/command/prepare.ts b/src/agents/command/prepare.ts index 7924c0700430..f664926a3a49 100644 --- a/src/agents/command/prepare.ts +++ b/src/agents/command/prepare.ts @@ -1,8 +1,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - isSyntheticSourceReplyTurn, - resolveSourceReplyDeliveryMode, -} from "../../auto-reply/reply/source-reply-delivery-mode.js"; +import { resolveSessionStableReplyMode } from "../../auto-reply/reply/session-stable-reply-mode.js"; +import { isSyntheticSourceReplyTurn } from "../../auto-reply/reply/source-reply-delivery-mode.js"; import { formatThinkingLevels, normalizeThinkLevel, @@ -31,10 +29,6 @@ import { resolveAgentHarnessSessionContextError, } from "../../sessions/agent-harness-session-key.js"; import { resolveUserPath } from "../../utils.js"; -import { - sessionDeliveryChannel, - sessionDeliveryOrigin, -} from "../../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel, resolveMessageChannel } from "../../utils/message-channel.js"; import { resolveAgentRuntimeConfig } from "../agent-runtime-config.js"; import { @@ -45,7 +39,6 @@ import { resolveAgentWorkspaceDir, } from "../agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { selectAgentHarness } from "../harness/selection.js"; import { AGENT_LANE_SUBAGENT } from "../lanes.js"; import type { ModelManifestNormalizationContext } from "../model-ref-shared.js"; import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js"; @@ -345,42 +338,15 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti isHeartbeat: commandOpts.bootstrapContextRunKind === "heartbeat", }) ) { - // Lifecycle turns keep their effective delivery mode, but CLI reuse belongs - // to the existing session's normal source-reply policy. - const stableReplyContext = { - CommandAuthorized: false, - ChatType: sessionEntryRaw.chatType, - Provider: sessionDeliveryOrigin(sessionEntryRaw)?.provider, - Surface: sessionDeliveryChannel(sessionEntryRaw), - InputProvenance: commandOpts.inputProvenance, - }; - const stableProvider = sessionEntryRaw.modelProvider ?? configuredModel.provider; - const stableModel = sessionEntryRaw.model ?? configuredModel.model; - const stableRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider: stableProvider, - modelId: stableModel, - agentId: sessionAgentId, - sessionKey, - sessionEntry: sessionEntryRaw, - }); - const harness = selectAgentHarness({ - provider: stableProvider, - modelId: stableModel, - config: cfg, - agentId: sessionAgentId, - sessionKey, - agentHarnessRuntimeOverride: stableRuntime, - }); - const defaultVisibleReplies = - harness.deliveryDefaults?.visibleReplies ?? harness.deliveryDefaults?.sourceVisibleReplies; commandOpts = { ...commandOpts, cliSessionBindingFacts: { - sourceReplyDeliveryMode: resolveSourceReplyDeliveryMode({ + sourceReplyDeliveryMode: resolveSessionStableReplyMode({ cfg, - ctx: stableReplyContext, - defaultVisibleReplies, + ctx: { CommandAuthorized: false }, + sessionEntry: sessionEntryRaw, + sessionAgentId, + sessionKey, }), }, }; diff --git a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts index 46939198b5ca..c318fa2dba58 100644 --- a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts +++ b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts @@ -24,7 +24,6 @@ import { loadSessionStoreEntry, resolveSessionStorePathCore, } from "./dispatch-from-config.runtime.js"; -import type { DispatchFromConfigParams } from "./dispatch-from-config.types.js"; import { resolveStoredModelOverride } from "./stored-model-override.js"; type HarnessSourceVisibleRepliesDefault = "automatic" | "message_tool"; @@ -98,7 +97,7 @@ function resolveHarnessDefaultParentSessionKey(params: { } export function resolveTurnModelOverride( - replyOptions: DispatchFromConfigParams["replyOptions"], + replyOptions: { isHeartbeat?: boolean; heartbeatModelOverride?: string } | undefined, ): string | undefined { if (replyOptions?.isHeartbeat !== true) { return undefined; @@ -205,7 +204,47 @@ function resolveModelOverrideCandidate(params: { })?.ref; } -export function resolveHarnessSourceVisibleRepliesDefault(params: { +/** + * Resolves the configured visible-replies mode plus the guarded harness + * default. One owner for dispatch and synthetic-turn binding facts: both must + * derive the same session-stable delivery mode or CLI session bindings + * ping-pong across turn kinds (#121485). + */ +export function resolveVisibleRepliesPolicy(params: { + cfg: OpenClawConfig; + chatType?: string; + ctx: FinalizedMsgContext; + entry?: SessionEntry; + sessionAgentId: string; + sessionKey?: string; + sessionStore?: Record; + turnModelOverride?: string; +}): { + configuredVisibleReplies?: "automatic" | "message_tool"; + harnessDefaultVisibleReplies?: "automatic" | "message_tool"; +} { + const configuredVisibleReplies = + params.chatType === "group" || params.chatType === "channel" + ? (params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies) + : params.cfg.messages?.visibleReplies; + const harnessDefaultVisibleReplies = + configuredVisibleReplies === undefined && + params.chatType !== "group" && + params.chatType !== "channel" + ? resolveHarnessSourceVisibleRepliesDefault({ + cfg: params.cfg, + ctx: params.ctx, + entry: params.entry, + sessionAgentId: params.sessionAgentId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + turnModelOverride: params.turnModelOverride, + }) + : undefined; + return { configuredVisibleReplies, harnessDefaultVisibleReplies }; +} + +function resolveHarnessSourceVisibleRepliesDefault(params: { cfg: OpenClawConfig; ctx: FinalizedMsgContext; entry?: SessionEntry; diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts index 275e0236b185..fc399771a3f3 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts @@ -35,8 +35,8 @@ import { } from "./dispatch-from-config.context.js"; import type { PluginBindingTranscriptOwner } from "./dispatch-from-config.events.js"; import { - resolveHarnessSourceVisibleRepliesDefault, resolveTurnModelOverride, + resolveVisibleRepliesPolicy, } from "./dispatch-from-config.harness-defaults.js"; import { extendPreparedDispatchState } from "./dispatch-from-config.phase-state.js"; import type { PrepareDispatchDeliveryReadyState } from "./dispatch-from-config.prepare-delivery.js"; @@ -46,6 +46,7 @@ import { emitMessageReceivedHooks as emitSharedMessageReceivedHooks } from "./me import { resolveOriginMessageProvider } from "./origin-routing.js"; import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js"; import { isDuplicateRestartRecoverySource } from "./restart-recovery-claim.js"; +import { resolveStableMessageToolAvailability } from "./session-stable-reply-mode.js"; import { isExplicitSourceReplyCommand, isUnauthorizedTextSlashCommand, @@ -222,22 +223,16 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli ? cfg.surfaces?.[silentReplySurface]?.silentReply : undefined, }) === "allow"; - const configuredVisibleReplies = - chatType === "group" || chatType === "channel" - ? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies) - : cfg.messages?.visibleReplies; - const harnessDefaultVisibleReplies = - configuredVisibleReplies === undefined && chatType !== "group" && chatType !== "channel" - ? resolveHarnessSourceVisibleRepliesDefault({ - cfg, - ctx, - entry: sessionStoreEntry.entry, - sessionAgentId, - sessionKey: acpDispatchSessionKey, - sessionStore: sessionStoreEntry.store, - turnModelOverride: resolveTurnModelOverride(params.replyOptions), - }) - : undefined; + const { configuredVisibleReplies, harnessDefaultVisibleReplies } = resolveVisibleRepliesPolicy({ + cfg, + chatType, + ctx, + entry: sessionStoreEntry.entry, + sessionAgentId, + sessionKey: acpDispatchSessionKey, + sessionStore: sessionStoreEntry.store, + turnModelOverride: resolveTurnModelOverride(params.replyOptions), + }); const effectiveVisibleReplies = configuredVisibleReplies ?? harnessDefaultVisibleReplies; const prefersMessageToolDelivery = params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" || @@ -299,6 +294,20 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli subagentPolicy, inheritedToolPolicy, ]); + // The stable mode's tool-only downgrade must be sender-independent, or a + // sender-scoped message denial hashes a different binding policy than the + // sender-less synthetic turns on the same session. Only tool-only candidates + // can downgrade, so skip the second policy pass otherwise. + const sessionStableMessageToolAvailable = + effectiveVisibleReplies === "message_tool" + ? resolveStableMessageToolAvailability({ + cfg, + ctx, + sessionEntry: sessionStoreEntry.entry, + sessionAgentId, + sessionKey: acpDispatchSessionKey, + }) + : undefined; const sourceReplyPolicyParams = { cfg, ctx, @@ -308,6 +317,7 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli explicitSuppressTyping: params.replyOptions?.suppressTyping === true, shouldSuppressTyping: state.shouldSuppressTyping, messageToolAvailable, + sessionStableMessageToolAvailable, isHeartbeat: params.replyOptions?.isHeartbeat, } as const; let sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 36346eda876b..40971b686ff7 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -24,6 +24,7 @@ import { resolveEnvelopeFormatOptions } from "../envelope.js"; import { normalizeThinkLevel } from "../thinking.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { applySessionHints } from "./body.js"; +import { resolveTurnModelOverride } from "./dispatch-from-config.harness-defaults.js"; import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js"; import { buildExecOverridePromptHint, @@ -49,7 +50,11 @@ import { resolveBareResetBootstrapFileAccess, resolveBareSessionResetPromptState, } from "./session-reset-prompt.js"; -import { isExplicitSourceReplyCommand } from "./source-reply-delivery-mode.js"; +import { resolveSessionStableReplyMode } from "./session-stable-reply-mode.js"; +import { + isExplicitSourceReplyCommand, + isSyntheticSourceReplyTurn, +} from "./source-reply-delivery-mode.js"; import { shouldApplyStartupContext, buildSessionStartupContextPrelude } from "./startup-context.js"; import { resolveTypingMode } from "./typing-mode.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; @@ -107,8 +112,34 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { isHeartbeat, }); const inboundEventKind = promptSessionCtx.InboundEventKind; - const { sourceReplyDeliveryMode, sessionPromptSourceReplyDeliveryMode } = - resolvePromptSourceReplyMode({ promptSessionCtx, opts }); + const { sourceReplyDeliveryMode, injectedSessionStableMode } = resolvePromptSourceReplyMode({ + promptSessionCtx, + opts, + }); + // Direct resolver callers (heartbeat wakes, system events) skip dispatch's + // stable-mode injection; resolve the same session-stable fact here so their + // binding facts and messageToolPolicyHash match dispatched chat turns — + // otherwise chat<->heartbeat transitions ping-pong the CLI session (#121485). + // Synthetic turns must not fall back to their effective turn mode: a + // response-tool heartbeat's message_tool_only is per-turn enforcement, not + // session policy, and hashing it recreates the ping-pong. + const isSyntheticTurn = isSyntheticSourceReplyTurn({ + inputProvenance: promptSessionCtx.InputProvenance, + isHeartbeat, + }); + const sessionPromptSourceReplyDeliveryMode = + injectedSessionStableMode ?? + (isSyntheticTurn && sessionEntry + ? resolveSessionStableReplyMode({ + cfg, + ctx: { ...promptSessionCtx, CommandAuthorized: false }, + sessionEntry, + sessionAgentId: agentId, + sessionKey, + sessionStore, + turnModelOverride: resolveTurnModelOverride(opts), + }) + : sourceReplyDeliveryMode); const silentReplyConversationType = resolvePromptSilentReplyConversationType({ ctx: promptSessionCtx, inboundSessionKey: ctx.SessionKey, diff --git a/src/auto-reply/reply/get-reply-run-source-mode.ts b/src/auto-reply/reply/get-reply-run-source-mode.ts index a2c80a39dd41..d99736ca5e50 100644 --- a/src/auto-reply/reply/get-reply-run-source-mode.ts +++ b/src/auto-reply/reply/get-reply-run-source-mode.ts @@ -2,6 +2,11 @@ import type { TemplateContext } from "../templating.js"; import type { InternalGetReplyOptions } from "./get-reply-run.types.js"; import { isInternalSourceReplyChannel } from "./source-reply-delivery-mode.js"; +/** + * Resolves the turn's effective source-reply mode and surfaces dispatch's + * injected session-stable mode separately, so the caller owns the synthetic + * fallback in one place instead of un-mixing the two afterwards. + */ export function resolvePromptSourceReplyMode(params: { promptSessionCtx: TemplateContext; opts?: InternalGetReplyOptions; @@ -15,7 +20,6 @@ export function resolvePromptSourceReplyMode(params: { : params.opts?.sourceReplyDeliveryMode; return { sourceReplyDeliveryMode, - sessionPromptSourceReplyDeliveryMode: - params.opts?.sessionPromptSourceReplyDeliveryMode ?? sourceReplyDeliveryMode, + injectedSessionStableMode: params.opts?.sessionPromptSourceReplyDeliveryMode, }; } 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 96c19fcb83c1..148f8a38cbbd 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 @@ -3090,6 +3090,16 @@ describe("runPreparedReply media-only handling", () => { sourceReplyDeliveryMode ?? "automatic", ].join(":"), ); + // The direct-caller heartbeat run below resolves the stable mode from + // config instead of injected opts; keep both sources agreeing per case. + const caseCfg = { + session: {}, + channels: {}, + agents: { defaults: {} }, + ...(stableMode === "message_tool_only" + ? { messages: { visibleReplies: "message_tool" as const } } + : {}), + }; const sessionEntry: SessionEntry = { sessionId: "session-telegram-group", updatedAt: 1, @@ -3107,6 +3117,7 @@ describe("runPreparedReply media-only handling", () => { }; await runPrepared({ + cfg: caseCfg, opts: { sourceReplyDeliveryMode: "message_tool_only", sessionPromptSourceReplyDeliveryMode: stableMode, @@ -3125,6 +3136,7 @@ describe("runPreparedReply media-only handling", () => { }, }); await runPrepared({ + cfg: caseCfg, opts: { sourceReplyDeliveryMode: stableMode, sessionPromptSourceReplyDeliveryMode: stableMode, @@ -3142,6 +3154,7 @@ describe("runPreparedReply media-only handling", () => { }, }); await runPrepared({ + cfg: caseCfg, opts: { isHeartbeat: true, sourceReplyDeliveryMode: stableMode, @@ -3160,10 +3173,50 @@ describe("runPreparedReply media-only handling", () => { Provider: "cron-event", }, }); + // Production heartbeat wakes call the reply resolver directly, without + // dispatch's injected delivery modes; their binding facts must still + // match dispatched turns or the CLI session ping-pongs (#121485). + await runPrepared({ + cfg: caseCfg, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); + // Response-tool heartbeats carry an effective message_tool_only turn + // mode; that is per-turn enforcement and must not become the session + // policy fact, or these heartbeats keep ping-ponging the binding. + await runPrepared({ + cfg: caseCfg, + opts: { isHeartbeat: true, sourceReplyDeliveryMode: "message_tool_only" }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); const roomEventRun = requireRunReplyAgentCall(0).followupRun.run; const primaryRun = requireRunReplyAgentCall(1).followupRun.run; const heartbeatRun = requireRunReplyAgentCall(2).followupRun.run; + const directHeartbeatRun = requireRunReplyAgentCall(3).followupRun.run; + const responseToolHeartbeatRun = requireRunReplyAgentCall(4).followupRun.run; expect(roomEventRun.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(primaryRun.sourceReplyDeliveryMode).toBe(stableMode); expect(heartbeatRun.sourceReplyDeliveryMode).toBe(stableMode); @@ -3180,9 +3233,101 @@ describe("runPreparedReply media-only handling", () => { }); expect(primaryRun.cliSessionBindingFacts).toEqual(roomEventRun.cliSessionBindingFacts); expect(heartbeatRun.cliSessionBindingFacts).toEqual(roomEventRun.cliSessionBindingFacts); + expect(directHeartbeatRun.cliSessionBindingFacts).toEqual( + roomEventRun.cliSessionBindingFacts, + ); + expect(responseToolHeartbeatRun.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(responseToolHeartbeatRun.cliSessionBindingFacts).toEqual( + roomEventRun.cliSessionBindingFacts, + ); }, ); + it("resolves origin-less sessions as internal for synthetic stable facts", async () => { + vi.mocked(buildDirectChatContext).mockReturnValue("direct-context"); + // An entry with no persisted delivery origin has only ever been driven + // internally; the wake provider ("heartbeat") must not leak into the + // stable context as a non-internal surface or the fact diverges from + // dispatch's live webchat turns. + const sessionEntry: SessionEntry = { + sessionId: "session-internal", + updatedAt: 1, + systemSent: true, + chatType: "direct", + }; + + await runPrepared({ + cfg: { session: {}, channels: {}, agents: { defaults: {} } }, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:main", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + ChatType: "direct", + }, + }); + + const run = requireRunReplyAgentCall(0).followupRun.run; + expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + }); + + it("downgrades the synthetic stable mode when the message tool is policy-denied", async () => { + vi.mocked(buildGroupChatContext).mockImplementation(({ sourceReplyDeliveryMode }) => + ["group", sourceReplyDeliveryMode ?? "automatic"].join(":"), + ); + const sessionEntry: SessionEntry = { + sessionId: "session-telegram-group", + updatedAt: 1, + systemSent: true, + chatType: "group", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-100123" }, + origin: { + provider: "telegram", + surface: "telegram", + chatType: "group", + to: "-100123", + }, + }), + }; + + // Tool-only delivery configured, but the message tool is denied: dispatch + // downgrades its stable mode to automatic, so the synthetic fallback must + // record automatic too or the binding hashes diverge again. + await runPrepared({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + messages: { visibleReplies: "message_tool" as const }, + tools: { deny: ["message"] }, + }, + opts: { isHeartbeat: true }, + isNewSession: false, + systemSent: true, + sessionEntry, + ctx: { + ...createInboundBody("scheduled wake"), + Provider: "heartbeat", + SessionKey: "agent:main:telegram:-100123", + }, + sessionCtx: { + ...createSessionBody("scheduled wake"), + Provider: "heartbeat", + }, + }); + + const run = requireRunReplyAgentCall(0).followupRun.run; + expect(run.cliSessionBindingFacts?.sourceReplyDeliveryMode).toBe("automatic"); + }); + it("keeps per-message room-event metadata out of CLI binding facts", async () => { vi.mocked(buildGroupChatContext).mockImplementation(({ sessionCtx, sourceReplyDeliveryMode }) => [ diff --git a/src/auto-reply/reply/session-stable-reply-mode.ts b/src/auto-reply/reply/session-stable-reply-mode.ts new file mode 100644 index 000000000000..a7ed993dfef8 --- /dev/null +++ b/src/auto-reply/reply/session-stable-reply-mode.ts @@ -0,0 +1,182 @@ +// Session-stable source-reply mode for synthetic turns (heartbeat wakes, +// system events, inter-session announcements) that reach the reply resolver +// without dispatch's injected delivery-mode facts. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + resolveEffectiveToolPolicy, + resolveGroupToolPolicy, + resolveInheritedToolPolicyForSession, + resolveSubagentToolPolicyForSession, +} from "../../agents/agent-tools.policy.js"; +import { + isSubagentEnvelopeSession, + resolveSubagentCapabilityStore, +} from "../../agents/subagents/spawn/subagent-capabilities.js"; +import { isToolAllowedByPolicies } from "../../agents/tool-policy-match.js"; +import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../agents/tool-policy.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { resolveGroupSessionKey } from "../../config/sessions/group.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + deliveryContextFromSession, + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; +import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; +import type { FinalizedMsgContext } from "../templating.js"; +import { resolveVisibleRepliesPolicy } from "./dispatch-from-config.harness-defaults.js"; +import { isSystemEventProvider } from "./effective-reply-route.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; +import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js"; + +/** + * Resolves the session's stable source-reply mode the way dispatch does, from + * a synthetic turn's restored context plus persisted session facts. Synthetic + * turns keep their effective delivery mode, but CLI session reuse belongs to + * the session's normal source-reply policy — every turn kind must derive the + * same messageToolPolicyHash, or chat and heartbeat turns ping-pong the CLI + * binding on each transition (#121485). + */ +export function resolveSessionStableReplyMode(params: { + cfg: OpenClawConfig; + ctx: FinalizedMsgContext; + sessionEntry: SessionEntry; + sessionAgentId: string; + sessionKey?: string; + sessionStore?: Record; + turnModelOverride?: string; +}): SourceReplyDeliveryMode { + const { cfg, ctx, sessionEntry } = params; + const chatType = + normalizeChatType(ctx.ChatType) ?? normalizeChatType(sessionEntry.chatType) ?? undefined; + // System-event provider strings ("heartbeat", "cron-event") are wake + // plumbing, not the session's surface; an entry with no persisted delivery + // origin has only ever been driven internally, so it must resolve the same + // internal-channel branch dispatch's live webchat turns do. + const stableReplyContext = { + CommandAuthorized: false, + ChatType: chatType, + Provider: + resolveStableChannelFact(ctx.Provider) ?? + sessionDeliveryOrigin(sessionEntry)?.provider ?? + INTERNAL_MESSAGE_CHANNEL, + Surface: resolveStableChannelFact(ctx.Surface) ?? sessionDeliveryChannel(sessionEntry), + ExplicitDeliverRoute: ctx.ExplicitDeliverRoute, + }; + const { harnessDefaultVisibleReplies } = resolveVisibleRepliesPolicy({ + cfg, + chatType, + ctx, + entry: sessionEntry, + sessionAgentId: params.sessionAgentId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + turnModelOverride: params.turnModelOverride, + }); + const candidateMode = resolveSourceReplyDeliveryMode({ + cfg, + ctx: stableReplyContext, + defaultVisibleReplies: harnessDefaultVisibleReplies, + }); + if (candidateMode !== "message_tool_only") { + return candidateMode; + } + // Dispatch downgrades tool-only delivery to automatic when the message tool + // is policy-denied (source-reply-delivery-mode.ts availability gate); with a + // stable ctx that is the boolean's only effect, so apply it directly rather + // than re-deriving the whole mode. Sender fields are deliberately absent: + // session-stable policy cannot vary by sender. + return resolveStableMessageToolAvailability(params) ? candidateMode : "automatic"; +} + +/** Strips system-event wake providers so only real channel surfaces remain. */ +function resolveStableChannelFact(value: string | undefined): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized && !isSystemEventProvider(normalized) ? normalized : undefined; +} + +/** + * Sender-independent message-tool availability for the session-stable mode. + * One owner for dispatch's stable-mode downgrade and synthetic-turn binding + * facts: sender-scoped denials apply to the sender's turn, never to the + * session policy every turn kind must hash identically (#121485). + */ +export function resolveStableMessageToolAvailability(params: { + cfg: OpenClawConfig; + ctx: FinalizedMsgContext; + sessionEntry?: SessionEntry; + sessionAgentId: string; + sessionKey?: string; +}): boolean { + const { cfg, ctx, sessionEntry } = params; + const { + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + profile, + providerProfile, + profileAlsoAllow, + providerProfileAlsoAllow, + } = resolveEffectiveToolPolicy({ + config: cfg, + sessionKey: params.sessionKey, + agentId: params.sessionAgentId, + }); + // Tool-only delivery force-allows the message tool at the profile layer + // (dispatch's runtimeProfileAlsoAllow); only outer deny layers can make it + // unavailable. + const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [ + ...(profileAlsoAllow ?? []), + "message", + ]); + const providerProfilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(providerProfile), [ + ...(providerProfileAlsoAllow ?? []), + "message", + ]); + // Direct callers (command prepare, synthetic wakes) may carry a bare ctx; + // fall back to the persisted session facts dispatch sees on live turns, or + // group/account-scoped policies resolve differently per producer. + const groupPolicy = resolveGroupToolPolicy({ + config: cfg, + sessionKey: params.sessionKey, + messageProvider: resolveOriginMessageProvider({ + originatingChannel: + ctx.OriginatingChannel ?? (sessionEntry ? sessionDeliveryChannel(sessionEntry) : undefined), + provider: + resolveStableChannelFact(ctx.Provider ?? ctx.Surface) ?? + (sessionEntry ? sessionDeliveryOrigin(sessionEntry)?.provider : undefined), + }), + groupId: resolveGroupSessionKey(ctx)?.id ?? sessionEntry?.groupId, + groupChannel: + normalizeOptionalString(ctx.GroupChannel) ?? + normalizeOptionalString(ctx.GroupSubject) ?? + normalizeOptionalString(sessionEntry?.groupChannel) ?? + normalizeOptionalString(sessionEntry?.subject), + groupSpace: normalizeOptionalString(ctx.GroupSpace), + accountId: + ctx.AccountId ?? + (sessionEntry ? deliveryContextFromSession(sessionEntry)?.accountId : undefined), + }); + const subagentStore = resolveSubagentCapabilityStore(params.sessionKey, { cfg }); + const subagentPolicy = + params.sessionKey && isSubagentEnvelopeSession(params.sessionKey, { cfg, store: subagentStore }) + ? resolveSubagentToolPolicyForSession(cfg, params.sessionKey, { store: subagentStore }) + : undefined; + const inheritedToolPolicy = resolveInheritedToolPolicyForSession(cfg, params.sessionKey, { + store: subagentStore, + }); + return isToolAllowedByPolicies("message", [ + profilePolicy, + providerProfilePolicy, + globalProviderPolicy, + agentProviderPolicy, + globalPolicy, + agentPolicy, + groupPolicy, + subagentPolicy, + inheritedToolPolicy, + ]); +} diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index 20feeb74c77e..e9ef1dfc980e 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -436,6 +436,40 @@ describe("resolveSourceReplyVisibilityPolicy", () => { }, ); + it("keeps the stable mode tool-only under a sender-scoped message denial", () => { + // A sender-scoped denial downgrades the sender's effective delivery, but + // the session-stable mode feeds CLI binding facts shared by sender-less + // synthetic turns; downgrading it too splits the policy hash and resets + // the CLI session on chat<->heartbeat transitions. + expectPolicyFields( + resolveSourceReplyVisibilityPolicy({ + cfg: globalToolOnlyReplyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + messageToolAvailable: false, + sessionStableMessageToolAvailable: true, + }), + { + sourceReplyDeliveryMode: "automatic", + sessionStableSourceReplyDeliveryMode: "message_tool_only", + }, + ); + // Without a sender-independent verdict, the stable mode still follows the + // turn's availability (session-wide denials downgrade both). + expectPolicyFields( + resolveSourceReplyVisibilityPolicy({ + cfg: globalToolOnlyReplyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + messageToolAvailable: false, + }), + { + sourceReplyDeliveryMode: "automatic", + sessionStableSourceReplyDeliveryMode: "automatic", + }, + ); + }); + it("suppresses automatic source delivery for opted-in message-tool group turns without suppressing typing", () => { expectPolicyFields( resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 72979c828adc..e476f03b2b88 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -153,6 +153,13 @@ export function resolveSourceReplyVisibilityPolicy(params: { explicitSuppressTyping?: boolean; shouldSuppressTyping?: boolean; messageToolAvailable?: boolean; + /** + * Sender-independent availability for the session-stable mode. The stable + * mode feeds CLI binding facts shared by every turn kind, so a sender-scoped + * message-tool denial must not downgrade it while sender-less synthetic + * turns resolve tool-only — that hash split resets the CLI session (#121485). + */ + sessionStableMessageToolAvailable?: boolean; defaultVisibleReplies?: "automatic" | "message_tool"; isHeartbeat?: boolean; }): SourceReplyVisibilityPolicy { @@ -175,7 +182,8 @@ export function resolveSourceReplyVisibilityPolicy(params: { : resolveSourceReplyDeliveryMode({ cfg: params.cfg, ctx: toSessionStableDeliveryModeContext(params.ctx), - messageToolAvailable: params.messageToolAvailable, + messageToolAvailable: + params.sessionStableMessageToolAvailable ?? params.messageToolAvailable, defaultVisibleReplies: params.defaultVisibleReplies, }); const sendPolicyDenied = params.sendPolicy === "deny";