diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e49556815..197f9f26227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - CLI/model probes: request trusted operator scope for `infer model run --gateway --model ` so Gateway raw model smokes can use one-off provider/model overrides instead of being rejected before provider auth resolution. Fixes #73759. Thanks @chrislro. - CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Refs #63700. Thanks @cedricjanssens. - Model selection: include the rejected provider/model ref and allowlist recovery hint when a stored session override is cleared, so local model selections such as Gemma GGUF variants do not fall back to the default with a generic message. Refs #71069. Thanks @CyberRaccoonTeam. +- Local model prompt caching: keep stable Project Context above volatile channel/session prompt guidance and stop embedding current channel names in the message tool description, so Ollama, MLX, llama.cpp, and other prefix-cache backends avoid avoidable full prompt reprocessing across channel turns. Fixes #40256; supersedes #40296. Thanks @rhclaw and @sriram369. - WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark. - Channels/Telegram: persist native command metadata on target sessions so topic, helper, and ACP-bound slash commands keep their session metadata attached to the routed conversation. (#57548) Thanks @GaosCode. - Channels/native commands: keep validated native slash command replies visible in group chats while preserving explicit owner allowlists for command authorization. (#73672) Thanks @obviyus. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 35abeb9db71..32a3504c046 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -53,6 +53,14 @@ The prompt is intentionally compact and uses fixed sections: - **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line). - **Reasoning**: current visibility level + /reasoning toggle hint. +OpenClaw keeps large stable content, including **Project Context**, above the +internal prompt cache boundary. Volatile channel/session sections such as +Control UI embed guidance, **Messaging**, **Voice**, **Group Chat Context**, +**Reactions**, **Heartbeats**, and **Runtime** are appended below that boundary +so local backends with prefix caches can reuse the stable workspace prefix +across channel turns. Tool descriptions should likewise avoid embedding current +channel names when the accepted schema already carries that runtime detail. + The Tooling section also includes runtime guidance for long-running work: - use cron for future follow-up (`check back later`, reminders, recurring work) diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index a539ed0484c..788a1add1d1 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { typedCases } from "../test-utils/typed-cases.js"; import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import { buildAgentSystemPrompt, buildAgentUserPromptPrefix, @@ -952,6 +953,41 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("## Reactions"); expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode."); }); + + it("keeps stable project context before volatile channel guidance for prefix-cache reuse", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["message"], + runtimeInfo: { + channel: "telegram", + capabilities: ["inlineButtons"], + canvasRootDir: "/tmp/canvas", + }, + contextFiles: [ + { + path: "AGENTS.md", + content: "Project rules mention ## Messaging, ## Group Chat Context, and ## Reactions.", + }, + ], + extraSystemPrompt: "Current group-chat facts", + reactionGuidance: { level: "minimal", channel: "Telegram" }, + ttsHint: "Use short voice-friendly replies.", + }); + + const projectContextPos = prompt.indexOf("# Project Context"); + const boundaryPos = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY); + const messagingPos = prompt.lastIndexOf("## Messaging"); + const groupChatPos = prompt.lastIndexOf("## Group Chat Context"); + const reactionsPos = prompt.lastIndexOf("## Reactions"); + const voicePos = prompt.lastIndexOf("## Voice (TTS)"); + + expect(projectContextPos).toBeGreaterThan(-1); + expect(boundaryPos).toBeGreaterThan(projectContextPos); + expect(messagingPos).toBeGreaterThan(boundaryPos); + expect(groupChatPos).toBeGreaterThan(boundaryPos); + expect(reactionsPos).toBeGreaterThan(boundaryPos); + expect(voicePos).toBeGreaterThan(boundaryPos); + }); }); describe("buildAgentUserPromptPrefix", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index b979a3ab791..0ecc5143139 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -903,46 +903,8 @@ export function buildAgentSystemPrompt(params: { "These user-editable files are loaded by OpenClaw and included below in Project Context.", "", ...buildAssistantOutputDirectivesSection(isMinimal), - ...buildWebchatCanvasSection({ - isMinimal, - runtimeChannel, - canvasRootDir: params.runtimeInfo?.canvasRootDir, - }), - ...buildMessagingSection({ - isMinimal, - availableTools, - messageChannelOptions, - inlineButtonsEnabled, - runtimeChannel, - messageToolHints: params.messageToolHints, - sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, - }), - ...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }), ]; - if (params.reactionGuidance) { - const { level, channel } = params.reactionGuidance; - const guidanceText = - level === "minimal" - ? [ - `Reactions are enabled for ${channel} in MINIMAL mode.`, - "React ONLY when truly relevant:", - "- Acknowledge important user requests or confirmations", - "- Express genuine sentiment (humor, appreciation) sparingly", - "- Avoid reacting to routine messages or your own replies", - "Guideline: at most 1 reaction per 5-10 exchanges.", - ].join("\n") - : [ - `Reactions are enabled for ${channel} in EXTENSIVE mode.`, - "Feel free to react liberally:", - "- Acknowledge messages with appropriate emojis", - "- Express sentiment and personality through reactions", - "- React to interesting content, humor, or notable events", - "- Use reactions to confirm understanding or agreement", - "Guideline: react whenever it feels natural.", - ].join("\n"); - lines.push("## Reactions", guidanceText, ""); - } if (reasoningHint) { lines.push("## Reasoning Format", reasoningHint, ""); } @@ -993,12 +955,55 @@ export function buildAgentSystemPrompt(params: { }), ); + // Channel/session-specific guidance lives below the cache boundary so large + // stable workspace context can remain a byte-identical prefix across turns. + lines.push( + ...buildWebchatCanvasSection({ + isMinimal, + runtimeChannel, + canvasRootDir: params.runtimeInfo?.canvasRootDir, + }), + ...buildMessagingSection({ + isMinimal, + availableTools, + messageChannelOptions, + inlineButtonsEnabled, + runtimeChannel, + messageToolHints: params.messageToolHints, + sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, + }), + ...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }), + ); + if (extraSystemPrompt) { // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context" const contextHeader = promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context"; lines.push(contextHeader, extraSystemPrompt, ""); } + if (params.reactionGuidance) { + const { level, channel } = params.reactionGuidance; + const guidanceText = + level === "minimal" + ? [ + `Reactions are enabled for ${channel} in MINIMAL mode.`, + "React ONLY when truly relevant:", + "- Acknowledge important user requests or confirmations", + "- Express genuine sentiment (humor, appreciation) sparingly", + "- Avoid reacting to routine messages or your own replies", + "Guideline: at most 1 reaction per 5-10 exchanges.", + ].join("\n") + : [ + `Reactions are enabled for ${channel} in EXTENSIVE mode.`, + "Feel free to react liberally:", + "- Acknowledge messages with appropriate emojis", + "- Express sentiment and personality through reactions", + "- React to interesting content, humor, or notable events", + "- Use reactions to confirm understanding or agreement", + "Guideline: react whenever it feels natural.", + ].join("\n"); + lines.push("## Reactions", guidanceText, ""); + } if (providerDynamicSuffix) { lines.push(providerDynamicSuffix, ""); } diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index a05c540aa30..31eee4a1386 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -601,8 +601,10 @@ describe("message tool schema scoping", () => { expect(getActionEnum(getToolProperties(scopedTool))).toContain("react"); expect(getActionEnum(getToolProperties(unscopedTool))).not.toContain("react"); - expect(scopedTool.description).toContain("telegram (react, send)"); - expect(unscopedTool.description).not.toContain("telegram (react, send)"); + expect(scopedTool.description).toContain("Supports actions: react, send."); + expect(unscopedTool.description).toContain("Supports actions: send."); + expect(scopedTool.description).not.toContain("telegram ("); + expect(unscopedTool.description).not.toContain("telegram ("); }); it("routes full discovery context into plugin action discovery", () => { @@ -779,7 +781,7 @@ describe("message tool description", () => { expect(tool.description).not.toContain("leaveGroup"); }); - it("includes other configured channels when currentChannel is set", () => { + it("describes accepted actions without channel-specific wording when currentChannel is set", () => { const signalPlugin = createChannelPlugin({ id: "signal", label: "Signal", @@ -808,11 +810,12 @@ describe("message tool description", () => { currentChannelProvider: "signal", }); - // Current channel actions are listed - expect(tool.description).toContain("Current channel (signal) supports: react, send."); - // Other configured channels are also listed - expect(tool.description).toContain("Other configured channels:"); - expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)"); + expect(tool.description).toContain( + "Supports actions: delete, edit, react, send, topic-create.", + ); + expect(tool.description).not.toContain("Current channel"); + expect(tool.description).not.toContain("Other configured channels"); + expect(tool.description).not.toContain("telegram ("); }); it("does not advertise cross-channel actions whose params are hidden by current-channel schema", () => { @@ -885,10 +888,11 @@ describe("message tool description", () => { currentChannelProvider: "sig", }); - expect(tool.description).toContain("Current channel (signal) supports: react, send."); + expect(tool.description).toContain("Supports actions: react, send."); + expect(tool.description).not.toContain("Current channel"); }); - it("does not include 'Other configured channels' when only one channel is configured", () => { + it("keeps the current-channel description stable when only one channel is configured", () => { setActivePluginRegistry( createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]), ); @@ -898,7 +902,8 @@ describe("message tool description", () => { currentChannelProvider: "bluebubbles", }); - expect(tool.description).toContain("Current channel (bluebubbles) supports:"); + expect(tool.description).toContain("Supports actions:"); + expect(tool.description).not.toContain("Current channel"); expect(tool.description).not.toContain("Other configured channels"); }); @@ -970,7 +975,7 @@ describe("message tool description", () => { config: {} as never, }); - expect(tool.description).toContain("Supports actions: send, broadcast."); + expect(tool.description).toContain("Supports actions: broadcast, send."); }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index bd5ea435f9b..77f507aac67 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -587,49 +587,17 @@ function buildMessageToolDescription(options?: { } : undefined; - // If we have a current channel, show its actions and list other configured channels - if (currentChannel && messageToolDiscoveryParams) { - const channelActions = listChannelSupportedActions( - buildMessageActionDiscoveryInput(messageToolDiscoveryParams, currentChannel), - ); - if (channelActions.length > 0) { - // Always include "send" as a base action - const allActions = new Set(["send", ...channelActions]); - const actionList = Array.from(allActions).toSorted().join(", "); - let desc = `${baseDescription} Current channel (${currentChannel}) supports: ${actionList}.`; - - // Include other configured channels so cron/isolated agents can discover them - const otherChannels: string[] = []; - for (const plugin of listChannelPlugins()) { - if (plugin.id === currentChannel) { - continue; - } - const actions = listCrossChannelSchemaSupportedMessageActions( - buildMessageActionDiscoveryInput(messageToolDiscoveryParams, plugin.id), - ); - if (actions.length > 0) { - const all = new Set(["send", ...actions]); - otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`); - } - } - if (otherChannels.length > 0) { - desc += ` Other configured channels: ${otherChannels.join(", ")}.`; - } - - return appendMessageToolReadHint( - desc, - Array.from(allActions) as Iterable, - ); - } - } - - // Fallback to generic description with all configured actions if (messageToolDiscoveryParams) { - const actions = listAllMessageToolActions(messageToolDiscoveryParams); + const actions = currentChannel + ? resolveMessageToolSchemaActions(messageToolDiscoveryParams) + : listAllMessageToolActions(messageToolDiscoveryParams); if (actions.length > 0) { + const sortedActions = Array.from(new Set(actions)).toSorted() as Array< + ChannelMessageActionName | "send" + >; return appendMessageToolReadHint( - `${baseDescription} Supports actions: ${actions.join(", ")}.`, - actions, + `${baseDescription} Supports actions: ${sortedActions.join(", ")}.`, + sortedActions, ); } }