From 17e42229d24a40b30c09dcf26899080a55fe05ab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 16:06:33 -0400 Subject: [PATCH] feat: teach models to use collapsible details where supported (#114706) * feat: teach models to use collapsible details where supported * fix: accept nullable accountId in telegram rich-messages helper * docs: regenerate docs map for collapsible details heading * fix(ci): remove duplicate Codex prewarm test route --- docs/concepts/markdown-formatting.md | 9 ++++++ docs/concepts/system-prompt.md | 5 +-- docs/docs_map.md | 1 + .../src/channel-actions.contract.test.ts | 25 ++++++++------- extensions/telegram/src/channel.ts | 14 +++++--- src/agents/runtime-capabilities.test.ts | 8 +++++ src/agents/runtime-capabilities.ts | 17 ++++++---- src/agents/system-prompt.test.ts | 32 +++++++++++++++++++ src/agents/system-prompt.ts | 19 +++++++++++ 9 files changed, 107 insertions(+), 23 deletions(-) diff --git a/docs/concepts/markdown-formatting.md b/docs/concepts/markdown-formatting.md index 3be301790c58..117a1bfb7f0e 100644 --- a/docs/concepts/markdown-formatting.md +++ b/docs/concepts/markdown-formatting.md @@ -105,6 +105,15 @@ Spoiler markers (`||spoiler||`) are parsed for Signal (mapped to `SPOILER` style ranges) and Telegram (mapped to ``). Other channels treat `||...||` as plain text. +## Collapsible details + +The Control UI and Telegram accounts with `richMessages: true` render +`
Label` disclosures as native collapsible sections. +OpenClaw tells the model about this option only when the current reply surface +supports it. Other channels, including Telegram accounts without rich messages, +flatten each disclosure to `**Summary**` followed by the visible body so no +content is hidden or lost. + ## Adding or updating a channel formatter 1. **Parse once** with `markdownToIR(...)`, passing channel-appropriate diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 5fbb806fd228..a3f0b07573f4 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -43,11 +43,12 @@ The prompt is compact, with fixed sections: - **Sandbox** (when enabled): sandboxed runtime, sandbox paths, elevated-exec availability. - **Current Date & Time**: time zone only (cache-stable; the live clock comes from `session_status`). - **Assistant Output Directives**: compact attachment, voice-note, and reply-tag syntax. +- **Collapsible Details** (when supported): teaches the model to keep optional depth in `
` disclosures while leaving the primary answer and required actions visible. - **Heartbeats**: heartbeat prompt and ack behavior, when heartbeats are enabled for the default agent. - **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line). - **Reasoning**: current visibility level plus the `/reasoning` toggle hint. -Large stable content (including **Project Context**) stays above the internal prompt cache boundary. Volatile per-turn sections (Control UI embed guidance, **Messaging**, **Voice**, **Group Chat Context**, **Reactions**, **Heartbeats**, **Runtime**) are appended below that boundary so local backends with prefix caches can reuse the stable workspace prefix across channel turns. Tool descriptions should avoid embedding current channel names when the accepted schema already carries that runtime detail. +Large stable content (including **Project Context**) stays above the internal prompt cache boundary. Volatile per-turn sections (Control UI embed guidance, **Messaging**, **Collapsible Details**, **Voice**, **Group Chat Context**, **Reactions**, **Heartbeats**, **Runtime**) are appended below that boundary so local backends with prefix caches can reuse the stable workspace prefix across channel turns. Tool descriptions should avoid embedding current channel names when the accepted schema already carries that runtime detail. Tooling also carries long-running-work guidance: @@ -71,7 +72,7 @@ On channels with native approval cards/buttons, the prompt tells the agent to re OpenClaw renders smaller system prompts for sub-agents. The runtime sets a `promptMode` per run (not user-facing config): - `full` (default): all sections above. -- `minimal`: used for sub-agents; omits the memory prompt section (bundled as **Memory Recall**), **OpenClaw Self-Update**, **Model Aliases**, **User Identity**, **Assistant Output Directives**, **Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, **Safety**, **Skills** (when supplied), Workspace, Sandbox, Current Date & Time (when known), Runtime, and injected context stay available. +- `minimal`: used for sub-agents; omits the memory prompt section (bundled as **Memory Recall**), **OpenClaw Self-Update**, **Model Aliases**, **User Identity**, **Assistant Output Directives**, **Messaging**, **Collapsible Details**, **Silent Replies**, and **Heartbeats**. Tooling, **Safety**, **Skills** (when supplied), Workspace, Sandbox, Current Date & Time (when known), Runtime, and injected context stay available. - `none`: returns only the base identity line. Under `promptMode=minimal`, extra injected prompts are labeled **Subagent Context** instead of **Group Chat Context**. diff --git a/docs/docs_map.md b/docs/docs_map.md index a21496df4e16..1271d400e790 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2594,6 +2594,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Chunking rules - H2: Link policy - H2: Spoilers + - H2: Collapsible details - H2: Adding or updating a channel formatter - H2: Common gotchas - H2: Related diff --git a/extensions/telegram/src/channel-actions.contract.test.ts b/extensions/telegram/src/channel-actions.contract.test.ts index b453b7c563fc..7e4258ab0193 100644 --- a/extensions/telegram/src/channel-actions.contract.test.ts +++ b/extensions/telegram/src/channel-actions.contract.test.ts @@ -77,20 +77,23 @@ describe("telegram actions contract", () => { }, ); - it("does not advertise a richText message-tool capability", () => { - const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({ - cfg: { - channels: { - telegram: { - botToken: "test-token-placeholder", - richMessages: true, + it("advertises markdown details only for rich-message accounts", () => { + const cfg = { + channels: { + telegram: { + accounts: { + rich: { botToken: "rich-token-placeholder", richMessages: true }, + plain: { botToken: "plain-token-placeholder", richMessages: false }, }, }, - } as OpenClawConfig, - }); + }, + } as OpenClawConfig; + const capabilitiesFor = (accountId: string) => + telegramPlugin.agentPrompt?.messageToolCapabilities?.({ cfg, accountId }); - expect(capabilities).toContain("inlineButtons"); - expect(capabilities).not.toContain("richText"); + expect(capabilitiesFor("rich")).toContain("markdownDetails"); + expect(capabilitiesFor("rich")).not.toContain("richText"); + expect(capabilitiesFor("plain")).not.toContain("markdownDetails"); }); it("advertises inline buttons when legacy Telegram capabilities are empty", () => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 5975b1fbed4b..0c1abce51060 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -116,6 +116,11 @@ function resolveTelegramProbe() { ); } +function isTelegramRichMessagesEnabled(cfg: OpenClawConfig, accountId?: string | null): boolean { + const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg); + return mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true; +} + async function readStartupBotInfoCache(params: { accountId: string; token: string; @@ -816,14 +821,15 @@ export const telegramPlugin = createChatChannelPlugin({ cfg, accountId: accountId ?? undefined, }); - return inlineButtonsScope === "off" ? [] : ["inlineButtons"]; + return [ + ...(inlineButtonsScope === "off" ? [] : ["inlineButtons"]), + ...(isTelegramRichMessagesEnabled(cfg, accountId) ? ["markdownDetails"] : []), + ]; }, // Authoring contract lives here so every runtime (including native Codex) // sees it via inbound-meta response_format; core system-prompt no longer owns it. inboundFormattingHints: ({ cfg, accountId }) => { - const selectedAccountId = accountId ?? resolveDefaultTelegramAccountId(cfg); - const richMessages = - mergeTelegramAccountConfig(cfg, selectedAccountId).richMessages === true; + const richMessages = isTelegramRichMessagesEnabled(cfg, accountId); if (richMessages) { return { text_markup: "markdown_telegram_rich", diff --git a/src/agents/runtime-capabilities.test.ts b/src/agents/runtime-capabilities.test.ts index 34cd27cca1f7..3c811a8ae01f 100644 --- a/src/agents/runtime-capabilities.test.ts +++ b/src/agents/runtime-capabilities.test.ts @@ -3,6 +3,14 @@ import { describe, expect, it } from "vitest"; import { collectRuntimeChannelCapabilities } from "./runtime-capabilities.js"; describe("collectRuntimeChannelCapabilities", () => { + it("advertises markdown details for internal webchat", () => { + expect(collectRuntimeChannelCapabilities({ channel: "webchat" })).toEqual(["markdownDetails"]); + }); + + it("does not advertise markdown details for a plugin-less non-webchat channel", () => { + expect(collectRuntimeChannelCapabilities({ channel: "heartbeat" })).toBeUndefined(); + }); + it("adds thread-bound spawn capabilities when the channel account allows unified spawns", () => { const capabilities = collectRuntimeChannelCapabilities({ channel: "discord", diff --git a/src/agents/runtime-capabilities.ts b/src/agents/runtime-capabilities.ts index df91cf1bd916..9f261d16754b 100644 --- a/src/agents/runtime-capabilities.ts +++ b/src/agents/runtime-capabilities.ts @@ -12,6 +12,7 @@ import { } from "../channels/thread-bindings-policy.js"; import { resolveChannelCapabilities } from "../config/channel-capabilities.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; import { resolveChannelPromptCapabilities } from "./channel-tools.js"; const THREAD_BOUND_SUBAGENT_SPAWN_CAPABILITY = "threadbound-subagent-spawn"; @@ -45,6 +46,10 @@ export function collectRuntimeChannelCapabilities(params: { if (!params.channel) { return undefined; } + // Control UI renders disclosures natively in its markdown pipeline. + // This capability is core-owned because webchat has no channel plugin. + const internalChannelCapabilities = + params.channel === INTERNAL_MESSAGE_CHANNEL ? ["markdownDetails"] : []; const threadSpawnCapabilities: string[] = []; if (params.cfg && supportsAutomaticThreadBindingSpawn(params.channel)) { for (const [kind, capability] of [ @@ -63,10 +68,10 @@ export function collectRuntimeChannelCapabilities(params: { } } } - return mergeRuntimeCapabilities( - resolveChannelCapabilities(params), - params.cfg - ? [...resolveChannelPromptCapabilities(params), ...threadSpawnCapabilities] - : threadSpawnCapabilities, - ); + const channelPromptCapabilities = params.cfg ? resolveChannelPromptCapabilities(params) : []; + return mergeRuntimeCapabilities(resolveChannelCapabilities(params), [ + ...channelPromptCapabilities, + ...internalChannelCapabilities, + ...threadSpawnCapabilities, + ]); } diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index eddc45c09a7c..89a695d6aea8 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1165,6 +1165,38 @@ describe("buildAgentSystemPrompt", () => { expect(telegramPrompt).toContain("final text normally routes to source"); }); + it("adds collapsible-details guidance only for supported full prompts", () => { + const supportedPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + runtimeInfo: { channel: "telegram", capabilities: ["markdownDetails"] }, + }); + const unsupportedPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + runtimeInfo: { channel: "discord", capabilities: [] }, + }); + const sameChannelUnsupportedPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + runtimeInfo: { channel: "telegram", capabilities: [] }, + }); + const minimalPrompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + promptMode: "minimal", + runtimeInfo: { channel: "telegram", capabilities: ["markdownDetails"] }, + }); + + expect(supportedPrompt).toContain("## Collapsible Details"); + expect(supportedPrompt).toContain( + "This surface renders `
` disclosures. When a reply has optional depth — long derivations, logs, background, worked examples — you may place it inside `
Label` … `
` written on their own lines.", + ); + expect(supportedPrompt).toContain("Never hide the actual answer behind a disclosure."); + expect(unsupportedPrompt).not.toContain("## Collapsible Details"); + expect(minimalPrompt).not.toContain("## Collapsible Details"); + + const stablePrefix = (prompt: string) => + prompt.slice(0, prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY)); + expect(stablePrefix(supportedPrompt)).toBe(stablePrefix(sameChannelUnsupportedPrompt)); + }); + it("describes source replies without the message tool", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 3e8a366c368d..3f054482c36e 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -598,6 +598,21 @@ function buildMessagingSection(params: { ]; } +function buildCollapsibleDetailsSection(params: { + isMinimal: boolean; + collapsibleDetailsSupported: boolean; +}) { + if (params.isMinimal || !params.collapsibleDetailsSupported) { + return []; + } + return [ + "## Collapsible Details", + "This surface renders `
` disclosures. When a reply has optional depth — long derivations, logs, background, worked examples — you may place it inside `
Label` … `
` written on their own lines.", + "Keep the primary answer, and anything the user must act on, outside the block. Never hide the actual answer behind a disclosure.", + "", + ]; +} + function buildMessageChannelOptions(runtimeChannel?: string): string | undefined { const deliverableChannels: readonly string[] = listDeliverableMessageChannels(); if (deliverableChannels.length <= 1) { @@ -969,6 +984,7 @@ export function buildAgentSystemPrompt(params: { const runtimeCapabilities = runtimeInfo?.capabilities ?? []; const runtimeCapabilitiesLower = new Set(normalizeStringEntriesLower(runtimeCapabilities)); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); + const collapsibleDetailsSupported = runtimeCapabilitiesLower.has("markdowndetails"); const threadBoundAcpSpawnEnabled = runtimeCapabilitiesLower.has("threadbound-acp-spawn"); const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; @@ -1360,6 +1376,9 @@ export function buildAgentSystemPrompt(params: { requireExplicitMessageTarget: params.requireExplicitMessageTarget, silentReplyPromptMode, }), + // Capability-gated reply guidance stays below the cache boundary so channel changes + // cannot alter the byte-identical stable prefix shared across sessions. + ...buildCollapsibleDetailsSection({ isMinimal, collapsibleDetailsSupported }), ...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }), );