perf(prompt): stabilize channel prompt suffix

This commit is contained in:
Peter Steinberger
2026-04-29 00:49:49 +01:00
parent d3683a61c5
commit 8f4cbbbe66
6 changed files with 113 additions and 90 deletions

View File

@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- CLI/model probes: request trusted operator scope for `infer model run --gateway --model <provider/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.

View File

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

View File

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

View File

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

View File

@@ -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.");
});
});

View File

@@ -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<ChannelMessageActionName | "send">(["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<ChannelMessageActionName | "send">(["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<ChannelMessageActionName | "send">,
);
}
}
// 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,
);
}
}