Skip to content

Commit 8f4cbbb

Browse files
committed
perf(prompt): stabilize channel prompt suffix
1 parent d3683a6 commit 8f4cbbb

6 files changed

Lines changed: 113 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
6262
- 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.
6363
- 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.
6464
- 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.
65+
- 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.
6566
- 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.
6667
- 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.
6768
- Channels/native commands: keep validated native slash command replies visible in group chats while preserving explicit owner allowlists for command authorization. (#73672) Thanks @obviyus.

docs/concepts/system-prompt.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ The prompt is intentionally compact and uses fixed sections:
5353
- **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line).
5454
- **Reasoning**: current visibility level + /reasoning toggle hint.
5555

56+
OpenClaw keeps large stable content, including **Project Context**, above the
57+
internal prompt cache boundary. Volatile channel/session sections such as
58+
Control UI embed guidance, **Messaging**, **Voice**, **Group Chat Context**,
59+
**Reactions**, **Heartbeats**, and **Runtime** are appended below that boundary
60+
so local backends with prefix caches can reuse the stable workspace prefix
61+
across channel turns. Tool descriptions should likewise avoid embedding current
62+
channel names when the accepted schema already carries that runtime detail.
63+
5664
The Tooling section also includes runtime guidance for long-running work:
5765

5866
- use cron for future follow-up (`check back later`, reminders, recurring work)

src/agents/system-prompt.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
33
import { typedCases } from "../test-utils/typed-cases.js";
44
import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js";
5+
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js";
56
import {
67
buildAgentSystemPrompt,
78
buildAgentUserPromptPrefix,
@@ -952,6 +953,41 @@ describe("buildAgentSystemPrompt", () => {
952953
expect(prompt).toContain("## Reactions");
953954
expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode.");
954955
});
956+
957+
it("keeps stable project context before volatile channel guidance for prefix-cache reuse", () => {
958+
const prompt = buildAgentSystemPrompt({
959+
workspaceDir: "/tmp/openclaw",
960+
toolNames: ["message"],
961+
runtimeInfo: {
962+
channel: "telegram",
963+
capabilities: ["inlineButtons"],
964+
canvasRootDir: "/tmp/canvas",
965+
},
966+
contextFiles: [
967+
{
968+
path: "AGENTS.md",
969+
content: "Project rules mention ## Messaging, ## Group Chat Context, and ## Reactions.",
970+
},
971+
],
972+
extraSystemPrompt: "Current group-chat facts",
973+
reactionGuidance: { level: "minimal", channel: "Telegram" },
974+
ttsHint: "Use short voice-friendly replies.",
975+
});
976+
977+
const projectContextPos = prompt.indexOf("# Project Context");
978+
const boundaryPos = prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
979+
const messagingPos = prompt.lastIndexOf("## Messaging");
980+
const groupChatPos = prompt.lastIndexOf("## Group Chat Context");
981+
const reactionsPos = prompt.lastIndexOf("## Reactions");
982+
const voicePos = prompt.lastIndexOf("## Voice (TTS)");
983+
984+
expect(projectContextPos).toBeGreaterThan(-1);
985+
expect(boundaryPos).toBeGreaterThan(projectContextPos);
986+
expect(messagingPos).toBeGreaterThan(boundaryPos);
987+
expect(groupChatPos).toBeGreaterThan(boundaryPos);
988+
expect(reactionsPos).toBeGreaterThan(boundaryPos);
989+
expect(voicePos).toBeGreaterThan(boundaryPos);
990+
});
955991
});
956992

957993
describe("buildAgentUserPromptPrefix", () => {

src/agents/system-prompt.ts

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -903,46 +903,8 @@ export function buildAgentSystemPrompt(params: {
903903
"These user-editable files are loaded by OpenClaw and included below in Project Context.",
904904
"",
905905
...buildAssistantOutputDirectivesSection(isMinimal),
906-
...buildWebchatCanvasSection({
907-
isMinimal,
908-
runtimeChannel,
909-
canvasRootDir: params.runtimeInfo?.canvasRootDir,
910-
}),
911-
...buildMessagingSection({
912-
isMinimal,
913-
availableTools,
914-
messageChannelOptions,
915-
inlineButtonsEnabled,
916-
runtimeChannel,
917-
messageToolHints: params.messageToolHints,
918-
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
919-
}),
920-
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
921906
];
922907

923-
if (params.reactionGuidance) {
924-
const { level, channel } = params.reactionGuidance;
925-
const guidanceText =
926-
level === "minimal"
927-
? [
928-
`Reactions are enabled for ${channel} in MINIMAL mode.`,
929-
"React ONLY when truly relevant:",
930-
"- Acknowledge important user requests or confirmations",
931-
"- Express genuine sentiment (humor, appreciation) sparingly",
932-
"- Avoid reacting to routine messages or your own replies",
933-
"Guideline: at most 1 reaction per 5-10 exchanges.",
934-
].join("\n")
935-
: [
936-
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
937-
"Feel free to react liberally:",
938-
"- Acknowledge messages with appropriate emojis",
939-
"- Express sentiment and personality through reactions",
940-
"- React to interesting content, humor, or notable events",
941-
"- Use reactions to confirm understanding or agreement",
942-
"Guideline: react whenever it feels natural.",
943-
].join("\n");
944-
lines.push("## Reactions", guidanceText, "");
945-
}
946908
if (reasoningHint) {
947909
lines.push("## Reasoning Format", reasoningHint, "");
948910
}
@@ -993,12 +955,55 @@ export function buildAgentSystemPrompt(params: {
993955
}),
994956
);
995957

958+
// Channel/session-specific guidance lives below the cache boundary so large
959+
// stable workspace context can remain a byte-identical prefix across turns.
960+
lines.push(
961+
...buildWebchatCanvasSection({
962+
isMinimal,
963+
runtimeChannel,
964+
canvasRootDir: params.runtimeInfo?.canvasRootDir,
965+
}),
966+
...buildMessagingSection({
967+
isMinimal,
968+
availableTools,
969+
messageChannelOptions,
970+
inlineButtonsEnabled,
971+
runtimeChannel,
972+
messageToolHints: params.messageToolHints,
973+
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
974+
}),
975+
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
976+
);
977+
996978
if (extraSystemPrompt) {
997979
// Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context"
998980
const contextHeader =
999981
promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context";
1000982
lines.push(contextHeader, extraSystemPrompt, "");
1001983
}
984+
if (params.reactionGuidance) {
985+
const { level, channel } = params.reactionGuidance;
986+
const guidanceText =
987+
level === "minimal"
988+
? [
989+
`Reactions are enabled for ${channel} in MINIMAL mode.`,
990+
"React ONLY when truly relevant:",
991+
"- Acknowledge important user requests or confirmations",
992+
"- Express genuine sentiment (humor, appreciation) sparingly",
993+
"- Avoid reacting to routine messages or your own replies",
994+
"Guideline: at most 1 reaction per 5-10 exchanges.",
995+
].join("\n")
996+
: [
997+
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
998+
"Feel free to react liberally:",
999+
"- Acknowledge messages with appropriate emojis",
1000+
"- Express sentiment and personality through reactions",
1001+
"- React to interesting content, humor, or notable events",
1002+
"- Use reactions to confirm understanding or agreement",
1003+
"Guideline: react whenever it feels natural.",
1004+
].join("\n");
1005+
lines.push("## Reactions", guidanceText, "");
1006+
}
10021007
if (providerDynamicSuffix) {
10031008
lines.push(providerDynamicSuffix, "");
10041009
}

src/agents/tools/message-tool.test.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -601,8 +601,10 @@ describe("message tool schema scoping", () => {
601601

602602
expect(getActionEnum(getToolProperties(scopedTool))).toContain("react");
603603
expect(getActionEnum(getToolProperties(unscopedTool))).not.toContain("react");
604-
expect(scopedTool.description).toContain("telegram (react, send)");
605-
expect(unscopedTool.description).not.toContain("telegram (react, send)");
604+
expect(scopedTool.description).toContain("Supports actions: react, send.");
605+
expect(unscopedTool.description).toContain("Supports actions: send.");
606+
expect(scopedTool.description).not.toContain("telegram (");
607+
expect(unscopedTool.description).not.toContain("telegram (");
606608
});
607609

608610
it("routes full discovery context into plugin action discovery", () => {
@@ -779,7 +781,7 @@ describe("message tool description", () => {
779781
expect(tool.description).not.toContain("leaveGroup");
780782
});
781783

782-
it("includes other configured channels when currentChannel is set", () => {
784+
it("describes accepted actions without channel-specific wording when currentChannel is set", () => {
783785
const signalPlugin = createChannelPlugin({
784786
id: "signal",
785787
label: "Signal",
@@ -808,11 +810,12 @@ describe("message tool description", () => {
808810
currentChannelProvider: "signal",
809811
});
810812

811-
// Current channel actions are listed
812-
expect(tool.description).toContain("Current channel (signal) supports: react, send.");
813-
// Other configured channels are also listed
814-
expect(tool.description).toContain("Other configured channels:");
815-
expect(tool.description).toContain("telegram (delete, edit, react, send, topic-create)");
813+
expect(tool.description).toContain(
814+
"Supports actions: delete, edit, react, send, topic-create.",
815+
);
816+
expect(tool.description).not.toContain("Current channel");
817+
expect(tool.description).not.toContain("Other configured channels");
818+
expect(tool.description).not.toContain("telegram (");
816819
});
817820

818821
it("does not advertise cross-channel actions whose params are hidden by current-channel schema", () => {
@@ -885,10 +888,11 @@ describe("message tool description", () => {
885888
currentChannelProvider: "sig",
886889
});
887890

888-
expect(tool.description).toContain("Current channel (signal) supports: react, send.");
891+
expect(tool.description).toContain("Supports actions: react, send.");
892+
expect(tool.description).not.toContain("Current channel");
889893
});
890894

891-
it("does not include 'Other configured channels' when only one channel is configured", () => {
895+
it("keeps the current-channel description stable when only one channel is configured", () => {
892896
setActivePluginRegistry(
893897
createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]),
894898
);
@@ -898,7 +902,8 @@ describe("message tool description", () => {
898902
currentChannelProvider: "bluebubbles",
899903
});
900904

901-
expect(tool.description).toContain("Current channel (bluebubbles) supports:");
905+
expect(tool.description).toContain("Supports actions:");
906+
expect(tool.description).not.toContain("Current channel");
902907
expect(tool.description).not.toContain("Other configured channels");
903908
});
904909

@@ -970,7 +975,7 @@ describe("message tool description", () => {
970975
config: {} as never,
971976
});
972977

973-
expect(tool.description).toContain("Supports actions: send, broadcast.");
978+
expect(tool.description).toContain("Supports actions: broadcast, send.");
974979
});
975980
});
976981

src/agents/tools/message-tool.ts

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -587,49 +587,17 @@ function buildMessageToolDescription(options?: {
587587
}
588588
: undefined;
589589

590-
// If we have a current channel, show its actions and list other configured channels
591-
if (currentChannel && messageToolDiscoveryParams) {
592-
const channelActions = listChannelSupportedActions(
593-
buildMessageActionDiscoveryInput(messageToolDiscoveryParams, currentChannel),
594-
);
595-
if (channelActions.length > 0) {
596-
// Always include "send" as a base action
597-
const allActions = new Set<ChannelMessageActionName | "send">(["send", ...channelActions]);
598-
const actionList = Array.from(allActions).toSorted().join(", ");
599-
let desc = `${baseDescription} Current channel (${currentChannel}) supports: ${actionList}.`;
600-
601-
// Include other configured channels so cron/isolated agents can discover them
602-
const otherChannels: string[] = [];
603-
for (const plugin of listChannelPlugins()) {
604-
if (plugin.id === currentChannel) {
605-
continue;
606-
}
607-
const actions = listCrossChannelSchemaSupportedMessageActions(
608-
buildMessageActionDiscoveryInput(messageToolDiscoveryParams, plugin.id),
609-
);
610-
if (actions.length > 0) {
611-
const all = new Set<ChannelMessageActionName | "send">(["send", ...actions]);
612-
otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`);
613-
}
614-
}
615-
if (otherChannels.length > 0) {
616-
desc += ` Other configured channels: ${otherChannels.join(", ")}.`;
617-
}
618-
619-
return appendMessageToolReadHint(
620-
desc,
621-
Array.from(allActions) as Iterable<ChannelMessageActionName | "send">,
622-
);
623-
}
624-
}
625-
626-
// Fallback to generic description with all configured actions
627590
if (messageToolDiscoveryParams) {
628-
const actions = listAllMessageToolActions(messageToolDiscoveryParams);
591+
const actions = currentChannel
592+
? resolveMessageToolSchemaActions(messageToolDiscoveryParams)
593+
: listAllMessageToolActions(messageToolDiscoveryParams);
629594
if (actions.length > 0) {
595+
const sortedActions = Array.from(new Set(actions)).toSorted() as Array<
596+
ChannelMessageActionName | "send"
597+
>;
630598
return appendMessageToolReadHint(
631-
`${baseDescription} Supports actions: ${actions.join(", ")}.`,
632-
actions,
599+
`${baseDescription} Supports actions: ${sortedActions.join(", ")}.`,
600+
sortedActions,
633601
);
634602
}
635603
}

0 commit comments

Comments
 (0)