From a3b2fdf7d607107ae10d17e8a502502f9d642f98 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 07:33:16 +0100 Subject: [PATCH] feat(agents): add prompt override and heartbeat controls --- docs/.generated/config-baseline.sha256 | 4 +- package.json | 1 + scripts/anthropic-prompt-probe.ts | 662 ++++++++++++++++++ src/agents/agent-scope.ts | 2 + src/agents/bootstrap-files.test.ts | 45 ++ src/agents/bootstrap-files.ts | 42 +- src/agents/cli-runner.test-support.ts | 2 - src/agents/cli-runner/prepare.ts | 45 +- src/agents/heartbeat-system-prompt.test.ts | 74 ++ src/agents/heartbeat-system-prompt.ts | 38 + src/agents/pi-embedded-runner/compact.ts | 70 +- .../run/attempt.prompt-helpers.ts | 16 +- src/agents/pi-embedded-runner/run/attempt.ts | 75 +- src/agents/system-prompt-override.test.ts | 46 ++ src/agents/system-prompt-override.ts | 27 + .../heartbeat-config-honor.inventory.test.ts | 1 + src/config/schema.base.generated.ts | 22 + src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/types.agent-defaults.ts | 4 + src/config/types.agents.ts | 2 + src/config/zod-schema.agent-defaults.ts | 1 + src/config/zod-schema.agent-runtime.ts | 2 + .../heartbeat-config-honor.inventory.ts | 15 + 24 files changed, 1113 insertions(+), 89 deletions(-) create mode 100644 scripts/anthropic-prompt-probe.ts create mode 100644 src/agents/heartbeat-system-prompt.test.ts create mode 100644 src/agents/heartbeat-system-prompt.ts create mode 100644 src/agents/system-prompt-override.test.ts create mode 100644 src/agents/system-prompt-override.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index ebda608ef3b..92b778baab8 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -6ad199bff1771839d1ab1129c2bb27ff583cf5a2e60a5603fa87b8a34c0856d0 config-baseline.json -cd556f8c976e535c710b5273c895bc5763650d67090e30dedc82cf227b2034d6 config-baseline.core.json +64ff922efc6146d867f3858141772094a8a72cba99a8fd61878551175dd8c822 config-baseline.json +5d0ce975352ff2b03077f6d71e9fe99ab0f0b118da0f72d47dc989c83f13d668 config-baseline.core.json d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json 1891bcb68d80ab8b7546a2946b5a9d82b18c3e92ffd2c834d15928e73fa11564 config-baseline.plugin.json diff --git a/package.json b/package.json index 8e7f9154dd4..08b28a44eb2 100644 --- a/package.json +++ b/package.json @@ -1133,6 +1133,7 @@ "prepack": "node --import tsx scripts/openclaw-prepack.ts", "prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", "prepush:ci": "bash scripts/prepush-ci.sh", + "probe:anthropic:prompt": "node --import tsx scripts/anthropic-prompt-probe.ts", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", diff --git a/scripts/anthropic-prompt-probe.ts b/scripts/anthropic-prompt-probe.ts new file mode 100644 index 00000000000..3e943bc5d0f --- /dev/null +++ b/scripts/anthropic-prompt-probe.ts @@ -0,0 +1,662 @@ +import { spawn } from "node:child_process"; +// Live prompt probe for Anthropic setup-token and Claude CLI prompt-path debugging. +// Usage: +// OPENCLAW_PROMPT_TRANSPORT=direct|gateway +// OPENCLAW_PROMPT_MODE=extra|override +// OPENCLAW_PROMPT_TEXT='...' +// OPENCLAW_PROMPT_CAPTURE=1 +// pnpm probe:anthropic:prompt +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { resolveOpenClawAgentDir } from "../src/agents/agent-paths.js"; +import { ensureAuthProfileStore, type AuthProfileCredential } from "../src/agents/auth-profiles.js"; +import { normalizeProviderId } from "../src/agents/model-selection.js"; +import { validateAnthropicSetupToken } from "../src/commands/auth-token.js"; +import { callGateway } from "../src/gateway/call.js"; +import { extractPayloadText } from "../src/gateway/test-helpers.agent-results.js"; +import { getFreePortBlockWithPermissionFallback } from "../src/test-utils/ports.js"; + +const TRANSPORT = process.env.OPENCLAW_PROMPT_TRANSPORT?.trim() === "direct" ? "direct" : "gateway"; +const GATEWAY_PROMPT_MODE = + process.env.OPENCLAW_PROMPT_MODE?.trim() === "override" ? "override" : "extra"; +const PROMPT_TEXT = process.env.OPENCLAW_PROMPT_TEXT?.trim() ?? ""; +const PROMPT_LIST_JSON = process.env.OPENCLAW_PROMPT_LIST_JSON?.trim() ?? ""; +const USER_PROMPT = process.env.OPENCLAW_USER_PROMPT?.trim() || "is clawd here?"; +const ENABLE_CAPTURE = process.env.OPENCLAW_PROMPT_CAPTURE === "1"; +const INCLUDE_RAW = process.env.OPENCLAW_PROMPT_INCLUDE_RAW === "1"; +const CLAUDE_BIN = process.env.CLAUDE_BIN?.trim() || "claude"; +const NODE_BIN = process.env.OPENCLAW_NODE_BIN?.trim() || process.execPath; +const TIMEOUT_MS = Number(process.env.OPENCLAW_PROMPT_TIMEOUT_MS ?? "45000"); +const GATEWAY_TIMEOUT_MS = Number(process.env.OPENCLAW_PROMPT_GATEWAY_TIMEOUT_MS ?? "120000"); +const SETUP_TOKEN_RAW = process.env.OPENCLAW_LIVE_SETUP_TOKEN?.trim() ?? ""; +const SETUP_TOKEN_VALUE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE?.trim() ?? ""; +const SETUP_TOKEN_PROFILE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? ""; +const DIRECT_CLAUDE_ARGS = ["-p", "--append-system-prompt"]; + +if (!PROMPT_TEXT && !PROMPT_LIST_JSON) { + throw new Error("missing OPENCLAW_PROMPT_TEXT or OPENCLAW_PROMPT_LIST_JSON"); +} + +type CaptureSummary = { + url?: string; + authScheme?: string; + xApp?: string; + anthropicBeta?: string; + systemBlockCount: number; + systemBlocks: Array<{ index: number; bytes: number; preview: string }>; + containsPromptExact: boolean; + bodyContainsPromptExact: boolean; + userBytes?: number; + userPreview?: string; + rawBody?: string; +}; + +type PromptResult = { + prompt: string; + ok: boolean; + transport: "direct" | "gateway"; + promptMode?: "extra" | "override"; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + status?: string; + text?: string; + stdout?: string; + stderr?: string; + error?: string; + matchedExtraUsage400: boolean; + capture?: CaptureSummary; + tmpDir: string; +}; + +type ProxyCapture = { + url?: string; + authHeader?: string; + xApp?: string; + anthropicBeta?: string; + systemTexts: string[]; + userText?: string; + rawBody?: string; +}; + +type TokenSource = { + profileId: string; + token: string; +}; + +function toHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value.join(", ") : value; +} + +function summarizeText(text: string, max = 120): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= max) { + return normalized; + } + return `${normalized.slice(0, max - 1)}…`; +} + +function summarizeCapture( + capture: ProxyCapture | undefined, + prompt: string, +): CaptureSummary | undefined { + if (!capture) { + return undefined; + } + return { + url: capture.url, + authScheme: capture.authHeader?.split(/\s+/, 1)[0], + xApp: capture.xApp, + anthropicBeta: capture.anthropicBeta, + systemBlockCount: capture.systemTexts.length, + systemBlocks: capture.systemTexts.map((entry, index) => ({ + index, + bytes: Buffer.byteLength(entry, "utf8"), + preview: summarizeText(entry), + })), + containsPromptExact: capture.systemTexts.includes(prompt), + bodyContainsPromptExact: capture.rawBody?.includes(prompt) ?? false, + userBytes: capture.userText ? Buffer.byteLength(capture.userText, "utf8") : undefined, + userPreview: capture.userText ? summarizeText(capture.userText) : undefined, + rawBody: INCLUDE_RAW ? capture.rawBody : undefined, + }; +} + +function matchesExtraUsage400(...parts: Array): boolean { + return parts + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .includes("third-party apps now draw from your extra usage"); +} + +function isSetupToken(value: string): boolean { + return value.startsWith("sk-ant-oat01-"); +} + +function listSetupTokenProfiles(store: { + profiles: Record; +}): Array<{ id: string; token: string }> { + return Object.entries(store.profiles) + .filter(([, cred]) => { + if (cred.type !== "token") { + return false; + } + if (normalizeProviderId(cred.provider) !== "anthropic") { + return false; + } + return isSetupToken(cred.token ?? ""); + }) + .map(([id, cred]) => ({ id, token: cred.token ?? "" })); +} + +function pickSetupTokenProfile(candidates: Array<{ id: string; token: string }>): { + id: string; + token: string; +} | null { + const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"]; + for (const id of preferred) { + const match = candidates.find((entry) => entry.id === id); + if (match) { + return match; + } + } + return candidates[0] ?? null; +} + +function validateSetupToken(value: string): string { + const error = validateAnthropicSetupToken(value); + if (error) { + throw new Error(`invalid setup-token: ${error}`); + } + return value; +} + +function resolveSetupTokenSource(): TokenSource { + const explicitToken = + (SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE; + if (explicitToken) { + return { + profileId: "anthropic:default", + token: validateSetupToken(explicitToken), + }; + } + + const agentDir = resolveOpenClawAgentDir(); + const store = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + const candidates = listSetupTokenProfiles(store); + if (SETUP_TOKEN_PROFILE) { + const match = candidates.find((entry) => entry.id === SETUP_TOKEN_PROFILE); + if (!match) { + throw new Error(`setup-token profile not found: ${SETUP_TOKEN_PROFILE}`); + } + return { profileId: match.id, token: validateSetupToken(match.token) }; + } + const match = pickSetupTokenProfile(candidates); + if (!match) { + throw new Error( + "no Anthropics setup-token profile found; set OPENCLAW_LIVE_SETUP_TOKEN_VALUE or OPENCLAW_LIVE_SETUP_TOKEN_PROFILE", + ); + } + return { profileId: match.id, token: validateSetupToken(match.token) }; +} + +async function sleep(ms: number): Promise { + return await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + fallback: () => T, +): Promise { + return await Promise.race([promise, sleep(timeoutMs).then(() => fallback())]); +} + +async function readRequestBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +function extractProxyCapture(rawBody: string, req: http.IncomingMessage): ProxyCapture { + let parsed: { + system?: Array<{ text?: string }>; + messages?: Array<{ role?: string; content?: unknown }>; + } | null = null; + try { + parsed = JSON.parse(rawBody) as typeof parsed; + } catch { + parsed = null; + } + const systemTexts = Array.isArray(parsed?.system) + ? parsed.system + .map((entry) => (typeof entry?.text === "string" ? entry.text : "")) + .filter(Boolean) + : []; + const userText = Array.isArray(parsed?.messages) + ? parsed.messages + .filter((entry) => entry?.role === "user") + .flatMap((entry) => { + const content = entry?.content; + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content + .map((item) => + item && typeof item === "object" && "text" in item && typeof item.text === "string" + ? item.text + : "", + ) + .filter(Boolean); + }) + .join("\n") + : undefined; + return { + url: req.url ?? undefined, + authHeader: toHeaderValue(req.headers.authorization), + xApp: toHeaderValue(req.headers["x-app"]), + anthropicBeta: toHeaderValue(req.headers["anthropic-beta"]), + systemTexts, + userText, + rawBody, + }; +} + +async function startAnthropicProxy(params: { port: number; upstreamBaseUrl: string }) { + let lastCapture: ProxyCapture | undefined; + const sockets = new Set(); + const server = http.createServer(async (req, res) => { + try { + const method = req.method ?? "GET"; + const requestBody = await readRequestBody(req); + const rawBody = requestBody.toString("utf8"); + lastCapture = extractProxyCapture(rawBody, req); + + const upstreamUrl = new URL(req.url ?? "/", params.upstreamBaseUrl).toString(); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue; + } + const lower = key.toLowerCase(); + if (lower === "host" || lower === "content-length") { + continue; + } + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + const upstreamRes = await fetch(upstreamUrl, { + method, + headers, + body: + method === "GET" || method === "HEAD" || requestBody.byteLength === 0 + ? undefined + : requestBody, + duplex: "half", + }); + const responseHeaders: Record = {}; + for (const [key, value] of upstreamRes.headers.entries()) { + const lower = key.toLowerCase(); + if ( + lower === "content-length" || + lower === "content-encoding" || + lower === "transfer-encoding" || + lower === "connection" || + lower === "keep-alive" + ) { + continue; + } + responseHeaders[key] = value; + } + res.writeHead(upstreamRes.status, responseHeaders); + if (upstreamRes.body) { + for await (const chunk of upstreamRes.body) { + res.write(Buffer.from(chunk)); + } + } + res.end(); + } catch (error) { + res.writeHead(502, { "content-type": "text/plain; charset=utf-8" }); + res.end(`proxy error: ${String(error)}`); + } + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(params.port, "127.0.0.1", () => resolve()); + }); + return { + getLastCapture() { + return lastCapture; + }, + async stop() { + for (const socket of sockets) { + socket.destroy(); + } + await withTimeout( + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + 1_000, + () => undefined, + ); + }, + }; +} + +async function getFreePort(): Promise { + return await getFreePortBlockWithPermissionFallback({ + offsets: [0, 1, 2, 4], + fallbackBase: 44_000, + }); +} + +async function runDirectPrompt(prompt: string): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-direct-prompt-probe-")); + const proxyPort = ENABLE_CAPTURE ? await getFreePort() : undefined; + const proxy = + ENABLE_CAPTURE && proxyPort + ? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" }) + : undefined; + + const stdout: string[] = []; + const stderr: string[] = []; + const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], { + cwd: process.cwd(), + env: { + ...process.env, + ...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}), + ANTHROPIC_API_KEY: "", + ANTHROPIC_API_KEY_OLD: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.on("data", (chunk) => stdout.push(String(chunk))); + child.stderr.on("data", (chunk) => stderr.push(String(chunk))); + const exit = await withTimeout( + new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })); + }), + TIMEOUT_MS, + () => { + child.kill("SIGKILL"); + return { code: null, signal: "SIGKILL" as NodeJS.Signals }; + }, + ); + await proxy?.stop().catch(() => {}); + const joinedStdout = stdout.join(""); + const joinedStderr = stderr.join(""); + return { + prompt, + ok: exit.code === 0 && !matchesExtraUsage400(joinedStdout, joinedStderr), + transport: "direct", + exitCode: exit.code, + signal: exit.signal, + stdout: joinedStdout.trim() || undefined, + stderr: joinedStderr.trim() || undefined, + matchedExtraUsage400: matchesExtraUsage400(joinedStdout, joinedStderr), + capture: summarizeCapture(proxy?.getLastCapture(), prompt), + tmpDir, + }; +} + +async function startGatewayProcess(params: { + port: number; + gatewayToken: string; + configPath: string; + stateDir: string; + agentDir: string; + bundledPluginsDir: string; + logPath: string; +}) { + const logFile = await fs.open(params.logPath, "a"); + const child = spawn( + NODE_BIN, + ["openclaw.mjs", "gateway", "--port", String(params.port), "--bind", "loopback", "--force"], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_CONFIG_PATH: params.configPath, + OPENCLAW_STATE_DIR: params.stateDir, + OPENCLAW_AGENT_DIR: params.agentDir, + OPENCLAW_GATEWAY_TOKEN: params.gatewayToken, + OPENCLAW_SKIP_CHANNELS: "1", + OPENCLAW_SKIP_GMAIL_WATCHER: "1", + OPENCLAW_SKIP_CANVAS_HOST: "1", + OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1", + OPENCLAW_DISABLE_BONJOUR: "1", + OPENCLAW_SKIP_CRON: "1", + OPENCLAW_TEST_MINIMAL_GATEWAY: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: params.bundledPluginsDir, + ANTHROPIC_API_KEY: "", + ANTHROPIC_API_KEY_OLD: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stdout.on("data", (chunk) => void logFile.appendFile(chunk)); + child.stderr.on("data", (chunk) => void logFile.appendFile(chunk)); + return { + async stop() { + if (!child.killed) { + child.kill("SIGINT"); + } + const exited = await withTimeout( + new Promise((resolve) => child.once("exit", () => resolve(true))), + 1_500, + () => false, + ); + if (!exited && !child.killed) { + child.kill("SIGKILL"); + } + await logFile.close(); + }, + }; +} + +async function waitForGatewayReady(url: string, token: string): Promise { + const deadline = Date.now() + 45_000; + let lastError = "gateway start timeout"; + while (Date.now() < deadline) { + try { + await callGateway({ url, token, method: "health", timeoutMs: 5_000 }); + return; + } catch (error) { + lastError = String(error); + await sleep(500); + } + } + throw new Error(lastError); +} + +async function readLogTail(logPath: string): Promise { + const raw = await fs.readFile(logPath, "utf8").catch(() => ""); + return raw.split(/\r?\n/).slice(-40).join("\n").trim(); +} + +async function runGatewayPrompt(prompt: string): Promise { + const tokenSource = resolveSetupTokenSource(); + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-prompt-probe-")); + const stateDir = path.join(tmpDir, "state"); + const agentDir = path.join(stateDir, "agents", "main", "agent"); + const bundledPluginsDir = path.join(tmpDir, "bundled-plugins-empty"); + const configPath = path.join(tmpDir, "openclaw.json"); + const logPath = path.join(tmpDir, "gateway.log"); + const gatewayToken = `gw-${randomUUID()}`; + const port = await getFreePort(); + const proxyPort = ENABLE_CAPTURE ? await getFreePort() : undefined; + const proxy = + ENABLE_CAPTURE && proxyPort + ? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" }) + : undefined; + + await fs.mkdir(agentDir, { recursive: true }); + await fs.mkdir(bundledPluginsDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + gateway: { + mode: "local", + controlUi: { enabled: false }, + tailscale: { mode: "off" }, + }, + discovery: { + mdns: { mode: "off" }, + wideArea: { enabled: false }, + }, + ...(proxyPort + ? { + models: { + providers: { + anthropic: { + baseUrl: `http://127.0.0.1:${proxyPort}`, + api: "anthropic-messages", + models: [], + }, + }, + }, + } + : {}), + auth: { + profiles: { [tokenSource.profileId]: { provider: "anthropic", mode: "token" } }, + order: { anthropic: [tokenSource.profileId] }, + }, + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + heartbeat: { + includeSystemPromptSection: false, + }, + ...(GATEWAY_PROMPT_MODE === "override" ? { systemPromptOverride: prompt } : {}), + }, + }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify( + { + version: 1, + profiles: { + [tokenSource.profileId]: { + type: "token", + provider: "anthropic", + token: tokenSource.token, + }, + }, + }, + null, + 2, + )}\n`, + ); + + const gateway = await startGatewayProcess({ + port, + gatewayToken, + configPath, + stateDir, + agentDir, + bundledPluginsDir, + logPath, + }); + try { + const url = `ws://127.0.0.1:${port}`; + await waitForGatewayReady(url, gatewayToken); + const agentRes = await callGateway<{ runId?: string }>({ + url, + token: gatewayToken, + method: "agent", + params: { + sessionKey: `agent:main:prompt-probe-${randomUUID()}`, + idempotencyKey: `idem-${randomUUID()}`, + message: "Reply with exactly: PROMPT PROBE OK.", + ...(GATEWAY_PROMPT_MODE === "extra" ? { extraSystemPrompt: prompt } : {}), + deliver: false, + }, + timeoutMs: 15_000, + clientName: "cli", + mode: "cli", + }); + if (typeof agentRes.runId !== "string" || agentRes.runId.trim().length === 0) { + return { + prompt, + ok: false, + transport: "gateway", + promptMode: GATEWAY_PROMPT_MODE, + error: `missing runId: ${JSON.stringify(agentRes)}`, + matchedExtraUsage400: false, + capture: summarizeCapture(proxy?.getLastCapture(), prompt), + tmpDir, + }; + } + const waitRes = await callGateway<{ status?: string; error?: string; payloads?: unknown[] }>({ + url, + token: gatewayToken, + method: "agent.wait", + params: { runId: agentRes.runId, timeoutMs: GATEWAY_TIMEOUT_MS }, + timeoutMs: GATEWAY_TIMEOUT_MS + 10_000, + clientName: "cli", + mode: "cli", + }); + const text = extractPayloadText(waitRes); + const logTail = await readLogTail(logPath); + const matched400 = matchesExtraUsage400(waitRes.error, logTail, JSON.stringify(waitRes)); + return { + prompt, + ok: waitRes.status === "ok" && !matched400, + transport: "gateway", + promptMode: GATEWAY_PROMPT_MODE, + status: waitRes.status, + text: text || undefined, + error: waitRes.status === "ok" ? undefined : waitRes.error || logTail || "agent.wait failed", + matchedExtraUsage400: matched400, + capture: summarizeCapture(proxy?.getLastCapture(), prompt), + tmpDir, + }; + } finally { + await gateway.stop().catch(() => {}); + await proxy?.stop().catch(() => {}); + } +} + +async function main() { + const prompts = PROMPT_LIST_JSON ? (JSON.parse(PROMPT_LIST_JSON) as string[]) : [PROMPT_TEXT]; + const results: PromptResult[] = []; + for (const prompt of prompts) { + results.push( + TRANSPORT === "direct" ? await runDirectPrompt(prompt) : await runGatewayPrompt(prompt), + ); + } + console.log( + JSON.stringify( + { + transport: TRANSPORT, + ...(TRANSPORT === "gateway" ? { promptMode: GATEWAY_PROMPT_MODE } : {}), + capture: ENABLE_CAPTURE, + results, + }, + null, + 2, + ), + ); +} + +await main(); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index de4b2f07d65..5912b1776e5 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -37,6 +37,7 @@ type ResolvedAgentConfig = { name?: string; workspace?: string; agentDir?: string; + systemPromptOverride?: AgentEntry["systemPromptOverride"]; model?: AgentEntry["model"]; thinkingDefault?: AgentEntry["thinkingDefault"]; verboseDefault?: AgentDefaultsConfig["verboseDefault"]; @@ -141,6 +142,7 @@ export function resolveAgentConfig( name: readStringValue(entry.name), workspace: readStringValue(entry.workspace), agentDir: readStringValue(entry.agentDir), + systemPromptOverride: readStringValue(entry.systemPromptOverride), model: typeof entry.model === "string" || (entry.model && typeof entry.model === "object") ? entry.model diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index ab0204a1669..e3a19743cb1 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -132,6 +132,51 @@ describe("resolveBootstrapContextForRun", () => { expect(files).toEqual([]); }); + + it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat prompt section is disabled", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); + await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "repo rules", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + config: { + agents: { + defaults: { + heartbeat: { + includeSystemPromptSection: false, + }, + }, + list: [{ id: "main" }], + }, + }, + }); + + expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(false); + expect(files.some((file) => file.name === "AGENTS.md")).toBe(true); + }); + + it("keeps HEARTBEAT.md for actual heartbeat runs even when the prompt section is disabled", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + runKind: "heartbeat", + config: { + agents: { + defaults: { + heartbeat: { + includeSystemPromptSection: false, + }, + }, + list: [{ id: "main" }], + }, + }, + }); + + expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(true); + }); }); describe("hasCompletedBootstrapTurn", () => { diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 0b72de3e3bb..401531e0cc8 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentContextInjection } from "../config/types.agent-defaults.js"; +import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js"; import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js"; import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; @@ -10,6 +11,7 @@ import { resolveBootstrapTotalMaxChars, } from "./pi-embedded-helpers.js"; import { + DEFAULT_HEARTBEAT_FILENAME, filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles, type WorkspaceBootstrapFile, @@ -142,6 +144,40 @@ function applyContextModeFilter(params: { return []; } +function shouldExcludeHeartbeatBootstrapFile(params: { + config?: OpenClawConfig; + sessionKey?: string; + sessionId?: string; + agentId?: string; + runKind?: BootstrapContextRunKind; +}): boolean { + if (!params.config || params.runKind === "heartbeat") { + return false; + } + const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey ?? params.sessionId, + config: params.config, + agentId: params.agentId, + }); + if (sessionAgentId !== defaultAgentId) { + return false; + } + const defaults = params.config.agents?.defaults?.heartbeat; + const overrides = resolveAgentConfig(params.config, sessionAgentId)?.heartbeat; + const merged = !defaults && !overrides ? overrides : { ...defaults, ...overrides }; + return merged?.includeSystemPromptSection === false; +} + +function filterHeartbeatBootstrapFile( + files: WorkspaceBootstrapFile[], + excludeHeartbeatBootstrapFile: boolean, +): WorkspaceBootstrapFile[] { + if (!excludeHeartbeatBootstrapFile) { + return files; + } + return files.filter((file) => file.name !== DEFAULT_HEARTBEAT_FILENAME); +} + export async function resolveBootstrapFilesForRun(params: { workspaceDir: string; config?: OpenClawConfig; @@ -152,6 +188,7 @@ export async function resolveBootstrapFilesForRun(params: { contextMode?: BootstrapContextMode; runKind?: BootstrapContextRunKind; }): Promise { + const excludeHeartbeatBootstrapFile = shouldExcludeHeartbeatBootstrapFile(params); const sessionKey = params.sessionKey ?? params.sessionId; const rawFiles = params.sessionKey ? await getOrLoadBootstrapFiles({ @@ -173,7 +210,10 @@ export async function resolveBootstrapFilesForRun(params: { sessionId: params.sessionId, agentId: params.agentId, }); - return sanitizeBootstrapFiles(updated, params.warn); + return sanitizeBootstrapFiles( + filterHeartbeatBootstrapFile(updated, excludeHeartbeatBootstrapFile), + params.warn, + ); } export async function resolveBootstrapContextForRun(params: { diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index 59e44301895..d4a1662811a 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -68,7 +68,6 @@ setCliRunnerExecuteTestDeps({ setCliRunnerPrepareTestDeps({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, - resolveHeartbeatPrompt: async () => "", resolveOpenClawDocsPath: async () => null, }); @@ -375,7 +374,6 @@ export function restoreCliRunnerPrepareTestDeps() { setCliRunnerPrepareTestDeps({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, - resolveHeartbeatPrompt: async () => "", resolveOpenClawDocsPath: async () => null, }); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index dc0090b1369..2be4501f414 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -16,11 +16,13 @@ import { import { resolveCliAuthEpoch } from "../cli-auth-epoch.js"; import { resolveCliBackendConfig } from "../cli-backends.js"; import { hashCliSessionText, resolveCliSessionReuse } from "../cli-session.js"; +import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, } from "../pi-embedded-helpers.js"; +import { resolveSystemPromptOverride } from "../system-prompt-override.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { prepareCliBundleMcpConfig } from "./bundle-mcp.js"; @@ -33,9 +35,6 @@ const prepareDeps = { resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl, getActiveMcpLoopbackRuntime, createMcpLoopbackServerConfig, - resolveHeartbeatPrompt: async ( - prompt: Parameters[0], - ) => (await import("../../auto-reply/heartbeat.js")).resolveHeartbeatPrompt(prompt), resolveOpenClawDocsPath: async ( params: Parameters[0], ) => (await import("../docs-path.js")).resolveOpenClawDocsPath(params), @@ -148,29 +147,35 @@ export async function prepareCliRunContext( `cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`, ); } - const heartbeatPrompt = - sessionAgentId === defaultAgentId - ? await prepareDeps.resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) - : undefined; + const heartbeatPrompt = resolveHeartbeatPromptForSystemPrompt({ + config: params.config, + agentId: sessionAgentId, + defaultAgentId, + }); const docsPath = await prepareDeps.resolveOpenClawDocsPath({ workspaceDir, argv1: process.argv[1], cwd: process.cwd(), moduleUrl: import.meta.url, }); - const systemPrompt = buildSystemPrompt({ - workspaceDir, - config: params.config, - defaultThinkLevel: params.thinkLevel, - extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - heartbeatPrompt, - docsPath: docsPath ?? undefined, - tools: [], - contextFiles, - modelDisplay, - agentId: sessionAgentId, - }); + const systemPrompt = + resolveSystemPromptOverride({ + config: params.config, + agentId: sessionAgentId, + }) ?? + buildSystemPrompt({ + workspaceDir, + config: params.config, + defaultThinkLevel: params.thinkLevel, + extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + heartbeatPrompt, + docsPath: docsPath ?? undefined, + tools: [], + contextFiles, + modelDisplay, + agentId: sessionAgentId, + }); const systemPromptReport = buildSystemPromptReport({ source: "run", generatedAt: Date.now(), diff --git a/src/agents/heartbeat-system-prompt.test.ts b/src/agents/heartbeat-system-prompt.test.ts new file mode 100644 index 00000000000..973cdae8d5e --- /dev/null +++ b/src/agents/heartbeat-system-prompt.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { resolveHeartbeatPromptForSystemPrompt } from "./heartbeat-system-prompt.js"; + +describe("resolveHeartbeatPromptForSystemPrompt", () => { + it("omits the heartbeat section when disabled in defaults", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + defaults: { + heartbeat: { + includeSystemPromptSection: false, + }, + }, + }, + }, + agentId: "main", + defaultAgentId: "main", + }), + ).toBeUndefined(); + }); + + it("honors default-agent overrides for the prompt text", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + defaults: { + heartbeat: { + prompt: "Default prompt", + }, + }, + list: [ + { + id: "main", + heartbeat: { + prompt: " Ops check ", + }, + }, + ], + }, + }, + agentId: "main", + defaultAgentId: "main", + }), + ).toBe("Ops check"); + }); + + it("does not inject the heartbeat section for non-default agents", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + defaults: { + heartbeat: { + prompt: "Default prompt", + }, + }, + list: [ + { + id: "ops", + heartbeat: { + prompt: "Ops prompt", + }, + }, + ], + }, + }, + agentId: "ops", + defaultAgentId: "main", + }), + ).toBeUndefined(); + }); +}); diff --git a/src/agents/heartbeat-system-prompt.ts b/src/agents/heartbeat-system-prompt.ts new file mode 100644 index 00000000000..6a271a3f7e2 --- /dev/null +++ b/src/agents/heartbeat-system-prompt.ts @@ -0,0 +1,38 @@ +import { resolveHeartbeatPrompt as resolveHeartbeatPromptText } from "../auto-reply/heartbeat.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; +import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; + +type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; + +function resolveHeartbeatConfigForSystemPrompt( + config?: OpenClawConfig, + agentId?: string, +): HeartbeatConfig | undefined { + const defaults = config?.agents?.defaults?.heartbeat; + if (!config || !agentId) { + return defaults; + } + const overrides = resolveAgentConfig(config, agentId)?.heartbeat; + if (!defaults && !overrides) { + return overrides; + } + return { ...defaults, ...overrides }; +} + +export function resolveHeartbeatPromptForSystemPrompt(params: { + config?: OpenClawConfig; + agentId?: string; + defaultAgentId?: string; +}): string | undefined { + const defaultAgentId = params.defaultAgentId ?? resolveDefaultAgentId(params.config ?? {}); + const agentId = params.agentId ?? defaultAgentId; + if (!agentId || agentId !== defaultAgentId) { + return undefined; + } + const heartbeat = resolveHeartbeatConfigForSystemPrompt(params.config, agentId); + if (heartbeat?.includeSystemPromptSection === false) { + return undefined; + } + return resolveHeartbeatPromptText(heartbeat?.prompt); +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ec842f0882b..7c8f31548f8 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,7 +7,6 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; -import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -56,6 +55,7 @@ import { resolveContextWindowInfo } from "../context-window-guard.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; +import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, @@ -92,6 +92,7 @@ import { resolveSkillsPromptForRun, type SkillSnapshot, } from "../skills.js"; +import { resolveSystemPromptOverride } from "../system-prompt-override.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js"; import { @@ -707,7 +708,6 @@ export async function compactEmbeddedPiSessionDirect( const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); - const isDefaultAgent = sessionAgentId === defaultAgentId; const promptMode = isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) ? "minimal" @@ -738,36 +738,42 @@ export async function compactEmbeddedPiSessionDirect( }); const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => createSystemPromptOverride( - buildEmbeddedSystemPrompt({ - workspaceDir: effectiveWorkspace, - defaultThinkLevel, - reasoningLevel: params.reasoningLevel ?? "off", - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - ownerDisplay: ownerDisplay.ownerDisplay, - ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, - reasoningTagHint, - heartbeatPrompt: isDefaultAgent - ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) - : undefined, - skillsPrompt, - docsPath: docsPath ?? undefined, - ttsHint, - promptMode, - acpEnabled: params.config?.acp?.enabled !== false, - runtimeInfo, - reactionGuidance, - messageToolHints, - sandboxInfo, - tools: effectiveTools, - modelAliasLines: buildModelAliasLines(params.config), - userTimezone, - userTime, - userTimeFormat, - contextFiles, - memoryCitationsMode: params.config?.memory?.citations, - promptContribution, - }), + resolveSystemPromptOverride({ + config: params.config, + agentId: sessionAgentId, + }) ?? + buildEmbeddedSystemPrompt({ + workspaceDir: effectiveWorkspace, + defaultThinkLevel, + reasoningLevel: params.reasoningLevel ?? "off", + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + ownerDisplay: ownerDisplay.ownerDisplay, + ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, + reasoningTagHint, + heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({ + config: params.config, + agentId: sessionAgentId, + defaultAgentId, + }), + skillsPrompt, + docsPath: docsPath ?? undefined, + ttsHint, + promptMode, + acpEnabled: params.config?.acp?.enabled !== false, + runtimeInfo, + reactionGuidance, + messageToolHints, + sandboxInfo, + tools: effectiveTools, + modelAliasLines: buildModelAliasLines(params.config), + userTimezone, + userTime, + userTimeFormat, + contextFiles, + memoryCitationsMode: params.config?.memory?.citations, + promptContribution, + }), ); const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config); diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts index 50b34bc07ad..2faf11ba28d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts @@ -6,6 +6,7 @@ import type { } from "../../../plugins/types.js"; import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; +import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { buildActiveMusicGenerationTaskPromptContextForSession } from "../../music-generation-task-status.js"; import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; @@ -92,10 +93,23 @@ export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "f } export function shouldInjectHeartbeatPrompt(params: { + config?: OpenClawConfig; + agentId?: string; + defaultAgentId?: string; isDefaultAgent: boolean; trigger?: EmbeddedRunAttemptParams["trigger"]; }): boolean { - return params.isDefaultAgent && shouldInjectHeartbeatPromptForTrigger(params.trigger); + return ( + params.isDefaultAgent && + shouldInjectHeartbeatPromptForTrigger(params.trigger) && + Boolean( + resolveHeartbeatPromptForSystemPrompt({ + config: params.config, + agentId: params.agentId, + defaultAgentId: params.defaultAgentId, + }), + ) + ); } export function shouldWarnOnOrphanedUserRepair( diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 83ba25e4b4d..ad6061b94bd 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,7 +7,6 @@ import { SessionManager, } from "@mariozechner/pi-coding-agent"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; -import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js"; @@ -58,6 +57,7 @@ import { import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { resolveOpenClawDocsPath } from "../../docs-path.js"; import { isTimeoutError } from "../../failover-error.js"; +import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; import { buildModelAliasLines } from "../../model-alias-lines.js"; import { resolveModelAuthMode } from "../../model-auth.js"; @@ -98,6 +98,7 @@ import { applySkillEnvOverridesFromSnapshot, resolveSkillsPromptForRun, } from "../../skills.js"; +import { resolveSystemPromptOverride } from "../../system-prompt-override.js"; import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; @@ -695,10 +696,17 @@ export async function runEmbeddedAttempt( const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined; const ownerDisplay = resolveOwnerDisplaySetting(params.config); const heartbeatPrompt = shouldInjectHeartbeatPrompt({ + config: params.config, + agentId: sessionAgentId, + defaultAgentId, isDefaultAgent, trigger: params.trigger, }) - ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) + ? resolveHeartbeatPromptForSystemPrompt({ + config: params.config, + agentId: sessionAgentId, + defaultAgentId, + }) : undefined; const promptContribution = resolveProviderSystemPromptContribution({ provider: params.provider, @@ -717,35 +725,40 @@ export async function runEmbeddedAttempt( }, }); - const appendPrompt = buildEmbeddedSystemPrompt({ - workspaceDir: effectiveWorkspace, - defaultThinkLevel: params.thinkLevel, - reasoningLevel: params.reasoningLevel ?? "off", - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - ownerDisplay: ownerDisplay.ownerDisplay, - ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, - reasoningTagHint, - heartbeatPrompt, - skillsPrompt: effectiveSkillsPrompt, - docsPath: docsPath ?? undefined, - ttsHint, - workspaceNotes, - reactionGuidance, - promptMode: effectivePromptMode, - acpEnabled: params.config?.acp?.enabled !== false, - runtimeInfo, - messageToolHints, - sandboxInfo, - tools: effectiveTools, - modelAliasLines: buildModelAliasLines(params.config), - userTimezone, - userTime, - userTimeFormat, - contextFiles, - memoryCitationsMode: params.config?.memory?.citations, - promptContribution, - }); + const appendPrompt = + resolveSystemPromptOverride({ + config: params.config, + agentId: sessionAgentId, + }) ?? + buildEmbeddedSystemPrompt({ + workspaceDir: effectiveWorkspace, + defaultThinkLevel: params.thinkLevel, + reasoningLevel: params.reasoningLevel ?? "off", + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + ownerDisplay: ownerDisplay.ownerDisplay, + ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, + reasoningTagHint, + heartbeatPrompt, + skillsPrompt: effectiveSkillsPrompt, + docsPath: docsPath ?? undefined, + ttsHint, + workspaceNotes, + reactionGuidance, + promptMode: effectivePromptMode, + acpEnabled: params.config?.acp?.enabled !== false, + runtimeInfo, + messageToolHints, + sandboxInfo, + tools: effectiveTools, + modelAliasLines: buildModelAliasLines(params.config), + userTimezone, + userTime, + userTimeFormat, + contextFiles, + memoryCitationsMode: params.config?.memory?.citations, + promptContribution, + }); const systemPromptReport = buildSystemPromptReport({ source: "run", generatedAt: Date.now(), diff --git a/src/agents/system-prompt-override.test.ts b/src/agents/system-prompt-override.test.ts new file mode 100644 index 00000000000..1b8625d748a --- /dev/null +++ b/src/agents/system-prompt-override.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveSystemPromptOverride } from "./system-prompt-override.js"; + +describe("resolveSystemPromptOverride", () => { + it("uses defaults when no per-agent override exists", () => { + expect( + resolveSystemPromptOverride({ + config: { + agents: { + defaults: { systemPromptOverride: " default system " }, + list: [{ id: "main" }], + }, + }, + agentId: "main", + }), + ).toBe("default system"); + }); + + it("prefers the per-agent override", () => { + expect( + resolveSystemPromptOverride({ + config: { + agents: { + defaults: { systemPromptOverride: "default system" }, + list: [{ id: "main", systemPromptOverride: " agent system " }], + }, + }, + agentId: "main", + }), + ).toBe("agent system"); + }); + + it("ignores blank override values", () => { + expect( + resolveSystemPromptOverride({ + config: { + agents: { + defaults: { systemPromptOverride: "default system" }, + list: [{ id: "main", systemPromptOverride: " " }], + }, + }, + agentId: "main", + }), + ).toBe("default system"); + }); +}); diff --git a/src/agents/system-prompt-override.ts b/src/agents/system-prompt-override.ts new file mode 100644 index 00000000000..28f52dad636 --- /dev/null +++ b/src/agents/system-prompt-override.ts @@ -0,0 +1,27 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentConfig } from "./agent-scope.js"; + +function trimNonEmpty(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function resolveSystemPromptOverride(params: { + config?: OpenClawConfig; + agentId?: string; +}): string | undefined { + const config = params.config; + if (!config) { + return undefined; + } + const agentOverride = trimNonEmpty( + params.agentId ? resolveAgentConfig(config, params.agentId)?.systemPromptOverride : undefined, + ); + if (agentOverride) { + return agentOverride; + } + return trimNonEmpty(config.agents?.defaults?.systemPromptOverride); +} diff --git a/src/config/heartbeat-config-honor.inventory.test.ts b/src/config/heartbeat-config-honor.inventory.test.ts index b6df00e5583..225ca73f497 100644 --- a/src/config/heartbeat-config-honor.inventory.test.ts +++ b/src/config/heartbeat-config-honor.inventory.test.ts @@ -12,6 +12,7 @@ const EXPECTED_HEARTBEAT_KEYS = [ "every", "model", "prompt", + "includeSystemPromptSection", "ackMaxChars", "suppressToolErrorWarnings", "lightContext", diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index bf6f14e5af0..90c06e2c8a2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -4715,6 +4715,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { prompt: { type: "string", }, + includeSystemPromptSection: { + type: "boolean", + title: "Heartbeat Include System Prompt Section", + description: + "Includes the default agent's ## Heartbeats system prompt section when true. Turn this off to keep heartbeat runtime behavior while omitting the heartbeat prompt instructions from the agent system prompt.", + }, ackMaxChars: { type: "integer", minimum: 0, @@ -5929,6 +5935,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { prompt: { type: "string", }, + includeSystemPromptSection: { + type: "boolean", + title: "Heartbeat Include System Prompt Section", + description: + "Per-agent override for whether the default agent's ## Heartbeats system prompt section is injected. Use false to keep heartbeat runtime behavior but omit the heartbeat prompt instructions from that agent's system prompt.", + }, ackMaxChars: { type: "integer", minimum: 0, @@ -25181,6 +25193,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: 'How embedded Pi handles workspace-local `.pi/config/settings.json`: "sanitize" (default) strips shellPath/shellCommandPrefix, "ignore" disables project settings entirely, and "trusted" applies project settings as-is.', tags: ["access"], }, + "agents.defaults.heartbeat.includeSystemPromptSection": { + label: "Heartbeat Include System Prompt Section", + help: "Includes the default agent's ## Heartbeats system prompt section when true. Turn this off to keep heartbeat runtime behavior while omitting the heartbeat prompt instructions from the agent system prompt.", + tags: ["automation"], + }, + "agents.list.*.heartbeat.includeSystemPromptSection": { + label: "Heartbeat Include System Prompt Section", + help: "Per-agent override for whether the default agent's ## Heartbeats system prompt section is injected. Use false to keep heartbeat runtime behavior but omit the heartbeat prompt instructions from that agent's system prompt.", + tags: ["automation"], + }, "agents.defaults.heartbeat.directPolicy": { label: "Heartbeat Direct Policy", help: 'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.', diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 47851b68990..d521413c2dd 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1490,6 +1490,10 @@ export const FIELD_HELP: Record = { "Shows degraded/error heartbeat alerts when true so operator channels surface problems promptly. Keep enabled in production so broken channel states are visible.", "channels.defaults.heartbeat.useIndicator": "Enables concise indicator-style heartbeat rendering instead of verbose status text where supported. Use indicator mode for dense dashboards with many active channels.", + "agents.defaults.heartbeat.includeSystemPromptSection": + "Includes the default agent's ## Heartbeats system prompt section when true. Turn this off to keep heartbeat runtime behavior while omitting the heartbeat prompt instructions from the agent system prompt.", + "agents.list.*.heartbeat.includeSystemPromptSection": + "Per-agent override for whether the default agent's ## Heartbeats system prompt section is injected. Use false to keep heartbeat runtime behavior but omit the heartbeat prompt instructions from that agent's system prompt.", "agents.defaults.heartbeat.directPolicy": 'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.', "agents.list.*.heartbeat.directPolicy": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index fe15f8d3e6e..b92e0b00e39 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -538,6 +538,8 @@ export const FIELD_LABELS: Record = { "agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt", "agents.defaults.embeddedPi": "Embedded Pi", "agents.defaults.embeddedPi.projectSettingsPolicy": "Embedded Pi Project Settings Policy", + "agents.defaults.heartbeat.includeSystemPromptSection": "Heartbeat Include System Prompt Section", + "agents.list.*.heartbeat.includeSystemPromptSection": "Heartbeat Include System Prompt Section", "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 27de586c07b..3a506584e59 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -153,6 +153,8 @@ export type AgentDefaultsConfig = { skills?: string[]; /** Optional repository root for system prompt runtime line (overrides auto-detect). */ repoRoot?: string; + /** Optional full system prompt replacement. Primarily for prompt debugging and controlled experiments. */ + systemPromptOverride?: string; /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ skipBootstrap?: boolean; /** @@ -273,6 +275,8 @@ export type AgentDefaultsConfig = { accountId?: string; /** Override the heartbeat prompt body (default: "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."). */ prompt?: string; + /** Include the ## Heartbeats system prompt section for the default agent (default: true). */ + includeSystemPromptSection?: boolean; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ ackMaxChars?: number; /** Suppress tool error warning payloads during heartbeat runs. */ diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 0f82511c4d8..c3d19a3bf74 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -64,6 +64,8 @@ export type AgentConfig = { name?: string; workspace?: string; agentDir?: string; + /** Optional per-agent full system prompt replacement. */ + systemPromptOverride?: AgentDefaultsConfig["systemPromptOverride"]; model?: AgentModelConfig; /** Optional per-agent default thinking level (overrides agents.defaults.thinkingDefault). */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 8adc0125a19..2f43aeafdf7 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -44,6 +44,7 @@ export const AgentDefaultsSchema = z workspace: z.string().optional(), skills: z.array(z.string()).optional(), repoRoot: z.string().optional(), + systemPromptOverride: z.string().optional(), skipBootstrap: z.boolean().optional(), contextInjection: z.union([z.literal("always"), z.literal("continuation-skip")]).optional(), bootstrapMaxChars: z.number().int().positive().optional(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 823aba2786b..f640b3167c1 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -31,6 +31,7 @@ export const HeartbeatSchema = z to: z.string().optional(), accountId: z.string().optional(), prompt: z.string().optional(), + includeSystemPromptSection: z.boolean().optional(), ackMaxChars: z.number().int().nonnegative().optional(), suppressToolErrorWarnings: z.boolean().optional(), lightContext: z.boolean().optional(), @@ -777,6 +778,7 @@ export const AgentEntrySchema = z name: z.string().optional(), workspace: z.string().optional(), agentDir: z.string().optional(), + systemPromptOverride: z.string().optional(), model: AgentModelSchema.optional(), thinkingDefault: z .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]) diff --git a/test/helpers/config/heartbeat-config-honor.inventory.ts b/test/helpers/config/heartbeat-config-honor.inventory.ts index 953dd1e3482..ba917d93e19 100644 --- a/test/helpers/config/heartbeat-config-honor.inventory.ts +++ b/test/helpers/config/heartbeat-config-honor.inventory.ts @@ -39,6 +39,21 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ reloadPaths: ["src/gateway/config-reload-plan.ts"], testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], }, + { + key: "includeSystemPromptSection", + schemaPaths: [ + "agents.defaults.heartbeat.includeSystemPromptSection", + "agents.list.*.heartbeat.includeSystemPromptSection", + ], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/agents/heartbeat-system-prompt.ts"], + consumerPaths: [ + "src/agents/heartbeat-system-prompt.ts", + "src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts", + ], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/agents/heartbeat-system-prompt.test.ts"], + }, { key: "ackMaxChars", schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"],