diff --git a/CHANGELOG.md b/CHANGELOG.md index bef80cb4b9f..422aed5e376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Control UI: contain the standalone iOS PWA viewport with safe-area-aware document locking, so Add-to-Home-Screen launches cannot scroll past the device bounds. Refs #76072. Thanks @kvncrw. - Agents/restart recovery: match cleaned transcript locks by exact transcript lock paths plus the canonical session fallback, so interrupted main sessions using topic-suffixed transcripts resume after gateway restart. Refs #76052. Thanks @anyech. +- Agents/runtime: cache the stable system-prompt prefix and reuse prompt-report tool schema stats during dispatch prep, reducing repeated CPU work before streaming starts. Fixes #75999; supersedes #76061. Thanks @zackchiutw and @STLI69. - Telegram/native commands: pass persisted session files into plugin commands for topic-bound sessions, so `/codex bind` works from Telegram forum topics. Refs #75845 and #76049. Thanks @MatthewSchleder. - Security audit/plugins: ignore plugin install backup, disabled, and dependency debris directories when enumerating installed plugin roots, avoiding false-positive findings for `.openclaw-install-backups` after plugin updates. Fixes #75456. - Telegram: honor runtime conversation bindings for native slash commands in bound top-level groups, so commands like `/status@bot` route to the active non-`main` session instead of falling back to the default route. Fixes #75405; supersedes #75558. Thanks @ziptbm and @yfge. diff --git a/src/agents/system-prompt-report.test.ts b/src/agents/system-prompt-report.test.ts index 3576216fdfb..0a98a032a9b 100644 --- a/src/agents/system-prompt-report.test.ts +++ b/src/agents/system-prompt-report.test.ts @@ -123,4 +123,25 @@ describe("buildSystemPromptReport", () => { expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe("trimmed".length); }); + + it("does not count injected files as project context when the rendered prompt omits them", () => { + const file = makeBootstrapFile({ + path: "/tmp/workspace/AGENTS.md", + content: "raw bootstrap context", + }); + const report = buildSystemPromptReport({ + source: "run", + generatedAt: 0, + bootstrapMaxChars: 20_000, + systemPrompt: "custom override", + bootstrapFiles: [file], + injectedFiles: [{ path: "/tmp/workspace/AGENTS.md", content: "rendered context" }], + skillsPrompt: "", + tools: [], + }); + + expect(report.systemPrompt.chars).toBe("custom override".length); + expect(report.systemPrompt.projectContextChars).toBe(0); + expect(report.systemPrompt.nonProjectContextChars).toBe("custom override".length); + }); }); diff --git a/src/agents/system-prompt-report.ts b/src/agents/system-prompt-report.ts index 11beac36f51..21a1085b571 100644 --- a/src/agents/system-prompt-report.ts +++ b/src/agents/system-prompt-report.ts @@ -4,20 +4,21 @@ import { buildBootstrapInjectionStats } from "./bootstrap-budget.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; -function extractBetween( - input: string, - startMarker: string, - endMarker: string, -): { text: string; found: boolean } { +type ToolReportEntry = SessionSystemPromptReport["tools"]["entries"][number]; + +const toolReportEntryCache = new WeakMap(); +const toolSchemaStatsCache = new WeakMap< + object, + Pick +>(); + +function extractBetween(input: string, startMarker: string, endMarker: string): string { const start = input.indexOf(startMarker); if (start === -1) { - return { text: "", found: false }; + return ""; } const end = input.indexOf(endMarker, start + startMarker.length); - if (end === -1) { - return { text: input.slice(start), found: true }; - } - return { text: input.slice(start, end), found: true }; + return end === -1 ? input.slice(start) : input.slice(start, end); } function parseSkillBlocks(skillsPrompt: string): Array<{ name: string; blockChars: number }> { @@ -36,36 +37,57 @@ function parseSkillBlocks(skillsPrompt: string): Array<{ name: string; blockChar .filter((b) => b.blockChars > 0); } -function buildToolsEntries(tools: AgentTool[]): SessionSystemPromptReport["tools"]["entries"] { - return tools.map((tool) => { - const name = tool.name; - const summary = tool.description?.trim() || tool.label?.trim() || ""; - const summaryChars = summary.length; - const schemaChars = (() => { - if (!tool.parameters || typeof tool.parameters !== "object") { - return 0; - } +function buildToolSchemaStats( + parameters: AgentTool["parameters"], +): Pick { + if (!parameters || typeof parameters !== "object") { + return { schemaChars: 0, propertiesCount: null }; + } + const cached = toolSchemaStatsCache.get(parameters); + if (cached) { + return cached; + } + const stats = { + schemaChars: (() => { try { - return JSON.stringify(tool.parameters).length; + return JSON.stringify(parameters).length; } catch { return 0; } - })(); - const propertiesCount = (() => { - const schema = - tool.parameters && typeof tool.parameters === "object" - ? (tool.parameters as Record) - : null; - const props = schema && typeof schema.properties === "object" ? schema.properties : null; + })(), + propertiesCount: (() => { + const schema = parameters as Record; + const props = typeof schema.properties === "object" ? schema.properties : null; if (!props || typeof props !== "object") { return null; } return Object.keys(props as Record).length; - })(); - return { name, summaryChars, schemaChars, propertiesCount }; + })(), + }; + toolSchemaStatsCache.set(parameters, stats); + return stats; +} + +function buildToolsEntries(tools: AgentTool[]): SessionSystemPromptReport["tools"]["entries"] { + return tools.map((tool) => { + const cached = toolReportEntryCache.get(tool); + if (cached) { + return cached; + } + const name = tool.name; + const summary = tool.description?.trim() || tool.label?.trim() || ""; + const summaryChars = summary.length; + const schemaStats = buildToolSchemaStats(tool.parameters); + const entry = { name, summaryChars, ...schemaStats }; + toolReportEntryCache.set(tool, entry); + return entry; }); } +function measureRenderedProjectContextChars(systemPrompt: string): number { + return extractBetween(systemPrompt, "\n# Project Context\n", "\n## Silent Replies\n").length; +} + export function buildSystemPromptReport(params: { source: SessionSystemPromptReport["source"]; generatedAt: number; @@ -84,13 +106,8 @@ export function buildSystemPromptReport(params: { skillsPrompt: string; tools: AgentTool[]; }): SessionSystemPromptReport { - const systemPrompt = params.systemPrompt.trim(); - const projectContext = extractBetween( - systemPrompt, - "\n# Project Context\n", - "\n## Silent Replies\n", - ); - const projectContextChars = projectContext.text.length; + const systemPromptChars = params.systemPrompt.length; + const projectContextChars = measureRenderedProjectContextChars(params.systemPrompt); const toolsEntries = buildToolsEntries(params.tools); const toolsSchemaChars = toolsEntries.reduce((sum, t) => sum + (t.schemaChars ?? 0), 0); const skillsEntries = parseSkillBlocks(params.skillsPrompt); @@ -108,9 +125,9 @@ export function buildSystemPromptReport(params: { ...(params.bootstrapTruncation ? { bootstrapTruncation: params.bootstrapTruncation } : {}), sandbox: params.sandbox, systemPrompt: { - chars: systemPrompt.length, + chars: systemPromptChars, projectContextChars, - nonProjectContextChars: Math.max(0, systemPrompt.length - projectContextChars), + nonProjectContextChars: Math.max(0, systemPromptChars - projectContextChars), }, injectedWorkspaceFiles: buildBootstrapInjectionStats({ bootstrapFiles: params.bootstrapFiles, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 8fc3086c03f..0a628d123a5 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -57,6 +57,40 @@ const CONTEXT_FILE_ORDER = new Map([ const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set(["heartbeat.md"]); const DEFAULT_HEARTBEAT_PROMPT_CONTEXT_BLOCK = "Default heartbeat prompt:\n`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`"; +const SYSTEM_PROMPT_STABLE_PREFIX_CACHE_LIMIT = 64; + +type StablePromptPrefixCacheEntry = { + value: string; +}; + +const stablePromptPrefixCache = new Map(); + +function cacheStablePromptPrefix(key: string, build: () => string): string { + const cached = stablePromptPrefixCache.get(key); + if (cached) { + stablePromptPrefixCache.delete(key); + stablePromptPrefixCache.set(key, cached); + return cached.value; + } + + const value = build(); + stablePromptPrefixCache.set(key, { value }); + while (stablePromptPrefixCache.size > SYSTEM_PROMPT_STABLE_PREFIX_CACHE_LIMIT) { + const oldestKey = stablePromptPrefixCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + stablePromptPrefixCache.delete(oldestKey); + } + return value; +} + +function hashStablePromptInput(value: unknown): string { + const hash = createHash("sha256"); + hash.update(JSON.stringify(value)); + return hash.digest("hex"); +} + function normalizeContextFilePath(pathValue: string): string { return pathValue.trim().replace(/\\/g, "/"); } @@ -721,215 +755,6 @@ export function buildAgentSystemPrompt(params: { return "You are a personal assistant running inside OpenClaw."; } - const lines = [ - "You are a personal assistant running inside OpenClaw.", - "", - "## Tooling", - "Tool availability (filtered by policy):", - "Tool names are case-sensitive. Call tools exactly as listed.", - toolLines.length > 0 - ? toolLines.join("\n") - : [ - "Pi lists the standard tools above. This runtime enables:", - "- grep: search file contents for patterns", - "- find: find files by glob pattern", - "- ls: list directory contents", - "- apply_patch: apply multi-file patches", - `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, - `- ${processToolName}: manage background exec sessions`, - "- browser: control OpenClaw's dedicated browser", - "- canvas: present/eval/snapshot the Canvas", - "- nodes: list/describe/notify/camera/screen on paired nodes", - "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", - "- sessions_list: list sessions", - "- sessions_history: fetch session history", - "- sessions_send: send to another session", - "- subagents: list/steer/kill sub-agent runs", - '- session_status: show usage/time/model state and answer "what model are we using?"', - ].join("\n"), - "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", - `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, - "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.", - 'Sub-agents start isolated by default. Use `sessions_spawn` with `context:"fork"` only when the child needs the current transcript context; otherwise omit `context` or use `context:"isolated"`.', - ...nativeCommandGuidanceLines, - ...(acpHarnessSpawnAllowed - ? [ - 'For requests like "do this in claude code/cursor/gemini/opencode" or similar ACP harnesses, treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.', - ...(runtimeChannel === "discord" && threadBoundAcpSpawnEnabled - ? [ - 'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.', - ] - : []), - "Set `agentId` explicitly unless `acp.defaultAgent` is configured, and do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows.", - ...(threadBoundAcpSpawnEnabled - ? [ - 'For ACP harness thread spawns, do not call `message` with `action=thread-create`; use `sessions_spawn` (`runtime: "acp"`, `thread: true`) as the single thread creation path.', - ] - : []), - ] - : []), - "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", - "", - ...buildOverridablePromptSection({ - override: providerSectionOverrides.interaction_style, - fallback: [], - }), - ...buildOverridablePromptSection({ - override: providerSectionOverrides.tool_call_style, - fallback: [ - "## Tool Call Style", - "Default: do not narrate routine, low-risk tool calls (just call the tool).", - "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", - "Keep narration brief and value-dense; avoid repeating obvious steps.", - "Use plain human language for narration unless in a technical context.", - "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", - buildExecApprovalPromptGuidance({ - runtimeChannel: params.runtimeInfo?.channel, - inlineButtonsEnabled, - runtimeCapabilities, - }), - "Never execute /approve through exec or any other shell/tool path; /approve is a user-facing approval command, not a shell command.", - "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", - "When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run, but keep command/script previews separate from the /approve command and never substitute the shell command/script for the approval id or slug.", - "", - ], - }), - ...buildOverridablePromptSection({ - override: providerSectionOverrides.execution_bias, - fallback: buildExecutionBiasSection({ - isMinimal, - }), - }), - ...buildOverridablePromptSection({ - override: providerStablePrefix, - fallback: [], - }), - ...safetySection, - "## OpenClaw CLI Quick Reference", - "OpenClaw is controlled via subcommands. Do not invent commands.", - "For config changes, use the first-class `gateway` tool (`config.schema.lookup`, `config.get`, `config.patch`, `config.apply`) instead of editing config through exec; the gateway tool hot-reloads config when possible and uses a safe restart only when required.", - "Use the `gateway` tool action `restart` for Gateway restarts. Only use CLI service lifecycle commands when the user explicitly asks for them.", - "Gateway service lifecycle quick reference:", - "- openclaw gateway status", - "- openclaw gateway restart", - "Operator-only, explicit user request:", - "- openclaw gateway start", - "- openclaw gateway stop", - "Do not chain `openclaw gateway stop` and `openclaw gateway start` as a restart substitute.", - "If unsure, ask the user to run `openclaw help` (or `openclaw gateway --help`) and paste the output.", - "", - ...skillsSection, - ...memorySection, - // Skip self-update for subagent/none modes - hasGateway && !isMinimal ? "## OpenClaw Self-Update" : "", - hasGateway && !isMinimal - ? [ - "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", - "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", - "Use config.schema.lookup with a specific dot path to inspect only the relevant config subtree before making config changes or answering config-field questions; avoid guessing field names/types.", - "Actions: config.schema.lookup, config.get, config.patch (partial update, merges with existing), config.apply (validate + write full config), update.run (update deps or git, then restart). Config writes hot-reload when possible and use a safe restart only when required.", - "After restart, OpenClaw pings the last active session automatically.", - ].join("\n") - : "", - hasGateway && !isMinimal ? "" : "", - "", - // Skip model aliases for subagent/none modes - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal - ? "## Model Aliases" - : "", - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal - ? "Prefer aliases when specifying model overrides; full provider/model is also accepted." - : "", - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal - ? params.modelAliasLines.join("\n") - : "", - params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "", - userTimezone - ? "If you need the current date, time, or day of week, run session_status (📊 session_status)." - : "", - "## Workspace", - `Your working directory is: ${displayWorkspaceDir}`, - workspaceGuidance, - ...workspaceNotes, - "", - ...docsSection, - params.sandboxInfo?.enabled ? "## Sandbox" : "", - params.sandboxInfo?.enabled - ? [ - "You are running in a sandboxed runtime (tools execute in Docker).", - "Some tools may be unavailable due to sandbox policy.", - "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", - hasSessionsSpawn && acpEnabled - ? 'ACP harness spawns are blocked from sandboxed sessions (`sessions_spawn` with `runtime: "acp"`). Use `runtime: "subagent"` instead.' - : "", - params.sandboxInfo.containerWorkspaceDir - ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` - : "", - params.sandboxInfo.workspaceDir - ? `Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` - : "", - params.sandboxInfo.workspaceAccess - ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ - params.sandboxInfo.agentWorkspaceMount - ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` - : "" - }` - : "", - params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", - params.sandboxInfo.hostBrowserAllowed === true - ? "Host browser control: allowed." - : params.sandboxInfo.hostBrowserAllowed === false - ? "Host browser control: blocked." - : "", - elevated?.allowed - ? "Elevated exec is available for this session." - : elevated - ? "Elevated exec is unavailable for this session." - : "", - elevated?.allowed && elevated.fullAccessAvailable - ? "User can toggle with /elevated on|off|ask|full." - : "", - elevated?.allowed && !elevated.fullAccessAvailable - ? "User can toggle with /elevated on|off|ask." - : "", - elevated?.allowed && elevated.fullAccessAvailable - ? "You may also send /elevated on|off|ask|full when needed." - : "", - elevated?.allowed && !elevated.fullAccessAvailable - ? "You may also send /elevated on|off|ask when needed." - : "", - elevated?.fullAccessAvailable === false - ? `Auto-approved /elevated full is unavailable here (${fullAccessBlockedReasonLabel}).` - : "", - elevated?.allowed && elevated.fullAccessAvailable - ? `Current elevated level: ${elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).` - : elevated?.allowed - ? `Current elevated level: ${elevated.defaultLevel} (full auto-approval unavailable here; use ask/on instead).` - : elevated - ? "Current elevated level: off (elevated exec unavailable)." - : "", - elevated && !elevated.allowed - ? "Do not tell the user to switch to /elevated full in this session." - : "", - ] - .filter(Boolean) - .join("\n") - : "", - params.sandboxInfo?.enabled ? "" : "", - ...buildUserIdentitySection(ownerLine, isMinimal), - ...buildTimeSection({ - userTimezone, - }), - "## Workspace Files (injected)", - "These user-editable files are loaded by OpenClaw and included below in Project Context.", - "", - ...buildAssistantOutputDirectivesSection(isMinimal), - ]; - - if (reasoningHint) { - lines.push("## Reasoning Format", reasoningHint, ""); - } - const contextFiles = params.contextFiles ?? []; const validContextFiles = contextFiles.filter( (file) => typeof file.path === "string" && file.path.trim().length > 0, @@ -937,36 +762,279 @@ export function buildAgentSystemPrompt(params: { const orderedContextFiles = sortContextFilesForPrompt(validContextFiles); const stableContextFiles = orderedContextFiles.filter((file) => !isDynamicContextFile(file.path)); const dynamicContextFiles = orderedContextFiles.filter((file) => isDynamicContextFile(file.path)); - lines.push( - ...buildProjectContextSection({ - files: stableContextFiles, - heading: "# Project Context", - dynamic: false, - }), - ); + const stablePrefixCacheKey = hashStablePromptInput({ + workspaceDir: params.workspaceDir, + promptMode, + toolLines, + hasGateway, + readToolName, + execToolName, + processToolName, + nativeCommandGuidanceLines, + providerSectionOverrides, + providerStablePrefix, + ownerLine, + reasoningHint, + reasoningLevel, + userTimezone, + runtimeChannel, + runtimeCapabilities, + inlineButtonsEnabled, + threadBoundAcpSpawnEnabled, + sourceMessageToolOnly, + silentReplyPromptMode, + sandboxInfo: params.sandboxInfo, + displayWorkspaceDir, + workspaceGuidance, + workspaceNotes, + docsPath: params.docsPath, + sourcePath: params.sourcePath, + skillsPrompt, + modelAliasLines: params.modelAliasLines, + includeMemorySection: params.includeMemorySection, + memoryCitationsMode: params.memoryCitationsMode, + memorySection, + acpEnabled, + stableContextFiles, + }); + const stablePrefix = cacheStablePromptPrefix(stablePrefixCacheKey, () => { + const lines = [ + "You are a personal assistant running inside OpenClaw.", + "", + "## Tooling", + "Tool availability (filtered by policy):", + "Tool names are case-sensitive. Call tools exactly as listed.", + toolLines.length > 0 + ? toolLines.join("\n") + : [ + "Pi lists the standard tools above. This runtime enables:", + "- grep: search file contents for patterns", + "- find: find files by glob pattern", + "- ls: list directory contents", + "- apply_patch: apply multi-file patches", + `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, + `- ${processToolName}: manage background exec sessions`, + "- browser: control OpenClaw's dedicated browser", + "- canvas: present/eval/snapshot the Canvas", + "- nodes: list/describe/notify/camera/screen on paired nodes", + "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", + "- sessions_list: list sessions", + "- sessions_history: fetch session history", + "- sessions_send: send to another session", + "- subagents: list/steer/kill sub-agent runs", + '- session_status: show usage/time/model state and answer "what model are we using?"', + ].join("\n"), + "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", + `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, + "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.", + 'Sub-agents start isolated by default. Use `sessions_spawn` with `context:"fork"` only when the child needs the current transcript context; otherwise omit `context` or use `context:"isolated"`.', + ...nativeCommandGuidanceLines, + ...(acpHarnessSpawnAllowed + ? [ + 'For requests like "do this in claude code/cursor/gemini/opencode" or similar ACP harnesses, treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.', + ...(runtimeChannel === "discord" && threadBoundAcpSpawnEnabled + ? [ + 'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.', + ] + : []), + "Set `agentId` explicitly unless `acp.defaultAgent` is configured, and do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows.", + ...(threadBoundAcpSpawnEnabled + ? [ + 'For ACP harness thread spawns, do not call `message` with `action=thread-create`; use `sessions_spawn` (`runtime: "acp"`, `thread: true`) as the single thread creation path.', + ] + : []), + ] + : []), + "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).", + "", + ...buildOverridablePromptSection({ + override: providerSectionOverrides.interaction_style, + fallback: [], + }), + ...buildOverridablePromptSection({ + override: providerSectionOverrides.tool_call_style, + fallback: [ + "## Tool Call Style", + "Default: do not narrate routine, low-risk tool calls (just call the tool).", + "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", + "Keep narration brief and value-dense; avoid repeating obvious steps.", + "Use plain human language for narration unless in a technical context.", + "When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.", + buildExecApprovalPromptGuidance({ + runtimeChannel: params.runtimeInfo?.channel, + inlineButtonsEnabled, + runtimeCapabilities, + }), + "Never execute /approve through exec or any other shell/tool path; /approve is a user-facing approval command, not a shell command.", + "Treat allow-once as single-command only: if another elevated command needs approval, request a fresh /approve and do not claim prior approval covered it.", + "When approvals are required, preserve and show the full command/script exactly as provided (including chained operators like &&, ||, |, ;, or multiline shells) so the user can approve what will actually run, but keep command/script previews separate from the /approve command and never substitute the shell command/script for the approval id or slug.", + "", + ], + }), + ...buildOverridablePromptSection({ + override: providerSectionOverrides.execution_bias, + fallback: buildExecutionBiasSection({ + isMinimal, + }), + }), + ...buildOverridablePromptSection({ + override: providerStablePrefix, + fallback: [], + }), + ...safetySection, + "## OpenClaw CLI Quick Reference", + "OpenClaw is controlled via subcommands. Do not invent commands.", + "For config changes, use the first-class `gateway` tool (`config.schema.lookup`, `config.get`, `config.patch`, `config.apply`) instead of editing config through exec; the gateway tool hot-reloads config when possible and uses a safe restart only when required.", + "Use the `gateway` tool action `restart` for Gateway restarts. Only use CLI service lifecycle commands when the user explicitly asks for them.", + "Gateway service lifecycle quick reference:", + "- openclaw gateway status", + "- openclaw gateway restart", + "Operator-only, explicit user request:", + "- openclaw gateway start", + "- openclaw gateway stop", + "Do not chain `openclaw gateway stop` and `openclaw gateway start` as a restart substitute.", + "If unsure, ask the user to run `openclaw help` (or `openclaw gateway --help`) and paste the output.", + "", + ...skillsSection, + ...memorySection, + hasGateway && !isMinimal ? "## OpenClaw Self-Update" : "", + hasGateway && !isMinimal + ? [ + "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", + "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", + "Use config.schema.lookup with a specific dot path to inspect only the relevant config subtree before making config changes or answering config-field questions; avoid guessing field names/types.", + "Actions: config.schema.lookup, config.get, config.patch (partial update, merges with existing), config.apply (validate + write full config), update.run (update deps or git, then restart). Config writes hot-reload when possible and use a safe restart only when required.", + "After restart, OpenClaw pings the last active session automatically.", + ].join("\n") + : "", + hasGateway && !isMinimal ? "" : "", + "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? "## Model Aliases" + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? "Prefer aliases when specifying model overrides; full provider/model is also accepted." + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal + ? params.modelAliasLines.join("\n") + : "", + params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "", + userTimezone + ? "If you need the current date, time, or day of week, run session_status (📊 session_status)." + : "", + "## Workspace", + `Your working directory is: ${displayWorkspaceDir}`, + workspaceGuidance, + ...workspaceNotes, + "", + ...docsSection, + params.sandboxInfo?.enabled ? "## Sandbox" : "", + params.sandboxInfo?.enabled + ? [ + "You are running in a sandboxed runtime (tools execute in Docker).", + "Some tools may be unavailable due to sandbox policy.", + "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", + hasSessionsSpawn && acpEnabled + ? 'ACP harness spawns are blocked from sandboxed sessions (`sessions_spawn` with `runtime: "acp"`). Use `runtime: "subagent"` instead.' + : "", + params.sandboxInfo.containerWorkspaceDir + ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}` + : "", + params.sandboxInfo.workspaceDir + ? `Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}` + : "", + params.sandboxInfo.workspaceAccess + ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ + params.sandboxInfo.agentWorkspaceMount + ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})` + : "" + }` + : "", + params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "", + params.sandboxInfo.hostBrowserAllowed === true + ? "Host browser control: allowed." + : params.sandboxInfo.hostBrowserAllowed === false + ? "Host browser control: blocked." + : "", + elevated?.allowed + ? "Elevated exec is available for this session." + : elevated + ? "Elevated exec is unavailable for this session." + : "", + elevated?.allowed && elevated.fullAccessAvailable + ? "User can toggle with /elevated on|off|ask|full." + : "", + elevated?.allowed && !elevated.fullAccessAvailable + ? "User can toggle with /elevated on|off|ask." + : "", + elevated?.allowed && elevated.fullAccessAvailable + ? "You may also send /elevated on|off|ask|full when needed." + : "", + elevated?.allowed && !elevated.fullAccessAvailable + ? "You may also send /elevated on|off|ask when needed." + : "", + elevated?.fullAccessAvailable === false + ? `Auto-approved /elevated full is unavailable here (${fullAccessBlockedReasonLabel}).` + : "", + elevated?.allowed && elevated.fullAccessAvailable + ? `Current elevated level: ${elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).` + : elevated?.allowed + ? `Current elevated level: ${elevated.defaultLevel} (full auto-approval unavailable here; use ask/on instead).` + : elevated + ? "Current elevated level: off (elevated exec unavailable)." + : "", + elevated && !elevated.allowed + ? "Do not tell the user to switch to /elevated full in this session." + : "", + ] + .filter(Boolean) + .join("\n") + : "", + params.sandboxInfo?.enabled ? "" : "", + ...buildUserIdentitySection(ownerLine, isMinimal), + ...buildTimeSection({ + userTimezone, + }), + "## Workspace Files (injected)", + "These user-editable files are loaded by OpenClaw and included below in Project Context.", + "", + ...buildAssistantOutputDirectivesSection(isMinimal), + ]; + + if (reasoningHint) { + lines.push("## Reasoning Format", reasoningHint, ""); + } - // Skip silent replies for subagent/none modes - if (!isMinimal && silentReplyPromptMode !== "none") { lines.push( - "## Silent Replies", - `When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`, - "", - "⚠️ Rules:", - "- It must be your ENTIRE message — nothing else", - `- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`, - "- Never wrap it in markdown or code blocks", - "", - `❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`, - `❌ Wrong: "${SILENT_REPLY_TOKEN}"`, - `✅ Right: ${SILENT_REPLY_TOKEN}`, - "", + ...buildProjectContextSection({ + files: stableContextFiles, + heading: "# Project Context", + dynamic: false, + }), ); - } - // Keep large stable prompt context above this seam so Anthropic-family - // transports can reuse it across labs and turns. Dynamic group/session - // additions and volatile project context below it are the primary cache invalidators. - lines.push(SYSTEM_PROMPT_CACHE_BOUNDARY); + if (!isMinimal && silentReplyPromptMode !== "none") { + lines.push( + "## Silent Replies", + `When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`, + "", + "⚠️ Rules:", + "- It must be your ENTIRE message — nothing else", + `- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`, + "- Never wrap it in markdown or code blocks", + "", + `❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`, + `❌ Wrong: "${SILENT_REPLY_TOKEN}"`, + `✅ Right: ${SILENT_REPLY_TOKEN}`, + "", + ); + } + + lines.push(SYSTEM_PROMPT_CACHE_BOUNDARY); + return lines.filter(Boolean).join("\n"); + }); + + const lines = [stablePrefix]; lines.push( ...buildProjectContextSection({