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
This commit is contained in:
Peter Steinberger
2026-07-27 16:06:33 -04:00
committed by GitHub
parent f3055f64f4
commit 17e42229d2
9 changed files with 107 additions and 23 deletions

View File

@@ -105,6 +105,15 @@ Spoiler markers (`||spoiler||`) are parsed for Signal (mapped to `SPOILER`
style ranges) and Telegram (mapped to `<tg-spoiler>`). Other channels treat
`||...||` as plain text.
## Collapsible details
The Control UI and Telegram accounts with `richMessages: true` render
`<details><summary>Label</summary>` 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

View File

@@ -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 `<details>` 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**.

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
]);
}

View File

@@ -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 `<details>` disclosures. When a reply has optional depth — long derivations, logs, background, worked examples — you may place it inside `<details><summary>Label</summary>` … `</details>` 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",

View File

@@ -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 `<details>` disclosures. When a reply has optional depth — long derivations, logs, background, worked examples — you may place it inside `<details><summary>Label</summary>` … `</details>` 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 }),
);