diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index b6c36682a1f..53affcf2399 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -6,6 +6,10 @@ import { resolveCurrentChannelMessageToolDiscoveryAdapter, __testing as messageActionTesting, } from "../channels/plugins/message-action-discovery.js"; +import { + channelPluginHasNativeApprovalPromptUi, + NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY, +} from "../channels/plugins/native-approval-prompt.js"; import type { ChannelAgentTool, ChannelMessageActionName, @@ -144,9 +148,31 @@ export function resolveChannelMessageToolCapabilities(params: { return []; } const cfg = params.cfg ?? ({} as OpenClawConfig); - return (resolve({ cfg, accountId: params.accountId }) ?? []) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizePromptCapabilities(resolve({ cfg, accountId: params.accountId })); +} + +export function resolveChannelPromptCapabilities(params: { + cfg?: OpenClawConfig; + channel?: string | null; + accountId?: string | null; +}): string[] { + const channelId = normalizeAnyChannelId(params.channel); + if (!channelId) { + return []; + } + const plugin = getChannelPlugin(channelId); + const cfg = params.cfg ?? ({} as OpenClawConfig); + const capabilities = normalizePromptCapabilities( + plugin?.agentPrompt?.messageToolCapabilities?.({ cfg, accountId: params.accountId }), + ); + if (channelPluginHasNativeApprovalPromptUi(plugin)) { + capabilities.push(NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY); + } + return capabilities; +} + +function normalizePromptCapabilities(capabilities?: readonly string[] | null): string[] { + return (capabilities ?? []).map((entry) => entry.trim()).filter(Boolean); } export function resolveChannelReactionGuidance(params: { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 6b14b2d9f93..61853e9e759 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -9,7 +9,6 @@ import { } from "@mariozechner/pi-coding-agent"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { captureCompactionCheckpointSnapshot, @@ -30,7 +29,6 @@ import { transformProviderSystemPrompt, } from "../../plugins/provider-runtime.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; -import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -44,7 +42,6 @@ import { } from "../bootstrap-files.js"; import { listChannelSupportedActions, - resolveChannelMessageToolCapabilities, resolveChannelMessageToolHints, resolveChannelReactionGuidance, } from "../channel-tools.js"; @@ -79,6 +76,7 @@ import { applyPiCompactionSettingsFromConfig } from "../pi-settings.js"; import { createOpenClawCodingTools } from "../pi-tools.js"; import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; +import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { buildAgentRuntimePlan } from "../runtime-plan/build.js"; import type { AgentRuntimePlan } from "../runtime-plan/types.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; @@ -634,35 +632,11 @@ export async function compactEmbeddedPiSessionDirect( runtimePlan.tools.logDiagnostics(effectiveTools, runtimePlanModelContext); const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); - let runtimeCapabilities = runtimeChannel - ? (resolveChannelCapabilities({ - cfg: params.config, - channel: runtimeChannel, - accountId: params.agentAccountId, - }) ?? []) - : undefined; - const promptCapabilities = - runtimeChannel && params.config - ? resolveChannelMessageToolCapabilities({ - cfg: params.config, - channel: runtimeChannel, - accountId: params.agentAccountId, - }) - : []; - if (promptCapabilities.length > 0) { - runtimeCapabilities ??= []; - const seenCapabilities = new Set( - runtimeCapabilities.map((cap) => normalizeOptionalLowercaseString(cap)).filter(Boolean), - ); - for (const capability of promptCapabilities) { - const normalizedCapability = normalizeOptionalLowercaseString(capability); - if (!normalizedCapability || seenCapabilities.has(normalizedCapability)) { - continue; - } - seenCapabilities.add(normalizedCapability); - runtimeCapabilities.push(capability); - } - } + const runtimeCapabilities = collectRuntimeChannelCapabilities({ + cfg: params.config, + channel: runtimeChannel, + accountId: params.agentAccountId, + }); const reactionGuidance = runtimeChannel && params.config ? resolveChannelReactionGuidance({ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 180e381afad..545aa1c5acb 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -9,7 +9,6 @@ import { } from "@mariozechner/pi-coding-agent"; import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; -import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { getRuntimeConfig } from "../../../config/config.js"; import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; import { @@ -36,7 +35,6 @@ import { import { getPluginToolMeta } from "../../../plugins/tools.js"; import { isAcpSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { annotateInterSessionPromptText } from "../../../sessions/input-provenance.js"; -import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; import { buildTrajectoryArtifacts, @@ -71,7 +69,6 @@ import { import { createCacheTrace } from "../../cache-trace.js"; import { listChannelSupportedActions, - resolveChannelMessageToolCapabilities, resolveChannelMessageToolHints, resolveChannelReactionGuidance, } from "../../channel-tools.js"; @@ -122,6 +119,7 @@ import { import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { describeProviderRequestRoutingSummary } from "../../provider-attribution.js"; import { registerProviderStreamForModel } from "../../provider-stream.js"; +import { collectRuntimeChannelCapabilities } from "../../runtime-capabilities.js"; import { logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools, @@ -1006,35 +1004,11 @@ export async function runEmbeddedAttempt( const machineName = await getMachineDisplayName(); const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider); - let runtimeCapabilities = runtimeChannel - ? (resolveChannelCapabilities({ - cfg: params.config, - channel: runtimeChannel, - accountId: params.agentAccountId, - }) ?? []) - : undefined; - const promptCapabilities = - runtimeChannel && params.config - ? resolveChannelMessageToolCapabilities({ - cfg: params.config, - channel: runtimeChannel, - accountId: params.agentAccountId, - }) - : []; - if (promptCapabilities.length > 0) { - runtimeCapabilities ??= []; - const seenCapabilities = new Set( - runtimeCapabilities.map((cap) => normalizeOptionalLowercaseString(cap)).filter(Boolean), - ); - for (const capability of promptCapabilities) { - const normalizedCapability = normalizeOptionalLowercaseString(capability); - if (!normalizedCapability || seenCapabilities.has(normalizedCapability)) { - continue; - } - seenCapabilities.add(normalizedCapability); - runtimeCapabilities.push(capability); - } - } + const runtimeCapabilities = collectRuntimeChannelCapabilities({ + cfg: params.config, + channel: runtimeChannel, + accountId: params.agentAccountId, + }); const reactionGuidance = runtimeChannel && params.config ? resolveChannelReactionGuidance({ diff --git a/src/agents/runtime-capabilities.ts b/src/agents/runtime-capabilities.ts new file mode 100644 index 00000000000..8d439846c80 --- /dev/null +++ b/src/agents/runtime-capabilities.ts @@ -0,0 +1,39 @@ +import { resolveChannelCapabilities } from "../config/channel-capabilities.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { resolveChannelPromptCapabilities } from "./channel-tools.js"; + +export function mergeRuntimeCapabilities( + base?: readonly string[] | null, + additions: readonly string[] = [], +): string[] | undefined { + const merged = [...(base ?? [])]; + const seen = new Set( + merged.map((capability) => normalizeOptionalLowercaseString(capability)).filter(Boolean), + ); + + for (const capability of additions) { + const normalizedCapability = normalizeOptionalLowercaseString(capability); + if (!normalizedCapability || seen.has(normalizedCapability)) { + continue; + } + seen.add(normalizedCapability); + merged.push(capability); + } + + return merged.length > 0 ? merged : undefined; +} + +export function collectRuntimeChannelCapabilities(params: { + cfg?: OpenClawConfig; + channel?: string | null; + accountId?: string | null; +}): string[] | undefined { + if (!params.channel) { + return undefined; + } + return mergeRuntimeCapabilities( + resolveChannelCapabilities(params), + params.cfg ? resolveChannelPromptCapabilities(params) : [], + ); +} diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index cb252c88f94..c7dfb768cad 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -812,6 +812,18 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("do not also send plain chat /approve instructions"); }); + it("suppresses plain chat approval commands for native approval channels", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + runtimeInfo: { + channel: "slack", + }, + }); + + expect(prompt).toContain("rely on native approval card/buttons when they appear"); + expect(prompt).toContain("do not also send plain chat /approve instructions"); + }); + it("keeps approval slug guidance separate from command previews", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 6305385fd48..949b6bdb7e6 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -2,8 +2,10 @@ import { createHmac, createHash } from "node:crypto"; import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -import { resolveChannelApprovalCapability } from "../channels/plugins/approvals.js"; -import { getChannelPlugin } from "../channels/plugins/index.js"; +import { + hasNativeApprovalPromptRuntimeCapability, + isKnownNativeApprovalPromptChannel, +} from "../channels/plugins/native-approval-prompt.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import { buildMemoryPromptSection } from "../plugins/memory-state.js"; import { @@ -141,13 +143,13 @@ function buildHeartbeatSection(params: { isMinimal: boolean; heartbeatPrompt?: s function buildExecApprovalPromptGuidance(params: { runtimeChannel?: string; inlineButtonsEnabled?: boolean; + runtimeCapabilities?: readonly string[]; }) { const runtimeChannel = normalizeOptionalLowercaseString(params.runtimeChannel); const usesNativeApprovalUi = params.inlineButtonsEnabled || - (runtimeChannel - ? Boolean(resolveChannelApprovalCapability(getChannelPlugin(runtimeChannel))?.native) - : false); + hasNativeApprovalPromptRuntimeCapability(params.runtimeCapabilities) || + isKnownNativeApprovalPromptChannel(runtimeChannel); if (usesNativeApprovalUi) { return 'When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible; when needed, copy the exact /approve command from the tool output\'s "Reply with:" line.'; } @@ -769,6 +771,7 @@ export function buildAgentSystemPrompt(params: { buildExecApprovalPromptGuidance({ runtimeChannel: params.runtimeInfo?.channel, inlineButtonsEnabled, + runtimeCapabilities, }), "Never execute /approve through exec or any other shell/tool path; /approve is a user-facing approval command, not a shell command.", "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", diff --git a/src/channels/plugins/native-approval-prompt.ts b/src/channels/plugins/native-approval-prompt.ts new file mode 100644 index 00000000000..20d681479bc --- /dev/null +++ b/src/channels/plugins/native-approval-prompt.ts @@ -0,0 +1,42 @@ +import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { resolveChannelApprovalCapability } from "./approvals.js"; +import type { ChannelPlugin } from "./types.plugin.js"; + +export const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY = "nativeApprovals"; + +const NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY_NORMALIZED = "nativeapprovals"; + +// Keep prompt construction lightweight. Full plugin loading is too expensive on +// prompt-only import paths; plugin-backed checks still cover loaded native +// channels at runtime. +const KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS = new Set([ + "discord", + "matrix", + "qqbot", + "slack", + "telegram", +]); + +export function channelPluginHasNativeApprovalPromptUi( + plugin?: Pick | null, +): boolean { + const capability = resolveChannelApprovalCapability(plugin); + return Boolean(capability?.native || capability?.nativeRuntime); +} + +export function isKnownNativeApprovalPromptChannel(channel?: string | null): boolean { + const normalized = normalizeOptionalLowercaseString(channel); + return Boolean(normalized && KNOWN_NATIVE_APPROVAL_PROMPT_CHANNELS.has(normalized)); +} + +export function hasNativeApprovalPromptRuntimeCapability( + capabilities?: readonly string[] | null, +): boolean { + return Boolean( + capabilities?.some( + (capability) => + normalizeOptionalLowercaseString(capability) === + NATIVE_APPROVAL_PROMPT_RUNTIME_CAPABILITY_NORMALIZED, + ), + ); +} diff --git a/src/config/channel-capabilities.ts b/src/config/channel-capabilities.ts index b7edc354596..16938cc548e 100644 --- a/src/config/channel-capabilities.ts +++ b/src/config/channel-capabilities.ts @@ -1,4 +1,4 @@ -import { normalizeChannelId } from "../channels/plugins/index.js"; +import { normalizeAnyChannelId } from "../channels/registry.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { normalizeAccountId } from "../routing/session-key.js"; import type { OpenClawConfig } from "./config.js"; @@ -49,7 +49,7 @@ export function resolveChannelCapabilities(params: { accountId?: string | null; }): string[] | undefined { const cfg = params.cfg; - const channel = normalizeChannelId(params.channel); + const channel = normalizeAnyChannelId(params.channel); if (!cfg || !channel) { return undefined; }