refactor: rebuild agent system prompt

This commit is contained in:
Peter Steinberger
2026-01-08 02:20:18 +01:00
parent e9346e6cf0
commit cad853b547
7 changed files with 184 additions and 131 deletions

View File

@@ -22,6 +22,7 @@
- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`).
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
- Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418)
- Agent: replace SDK base system prompt with ClaudeBot prompt, add skills guidance, and document the layout.
- Signal: reconnect SSE monitor with abortable backoff; log stream errors. Thanks @nexty5870 for PR #430.
- Gateway: pass resolved provider as messageProvider for agent runs so provider-specific tools are available. Thanks @imfing for PR #389.
- Doctor: add state integrity checks + repair prompts for missing sessions/state dirs, transcript mismatches, and permission issues; document full doctor flow and workspace backup tips.

View File

@@ -0,0 +1,64 @@
---
summary: "What the ClaudeBot system prompt contains and how it is assembled"
read_when:
- Editing system prompt text, tools list, or time/heartbeat sections
- Changing workspace bootstrap or skills injection behavior
---
# System Prompt
ClaudeBot builds a custom system prompt for every agent run. The prompt is **Clawdbot-owned** and does not use the p-coding-agent default prompt.
The prompt is assembled in `src/agents/system-prompt.ts` and injected by `src/agents/pi-embedded-runner.ts`.
## Structure
The prompt is intentionally compact and uses fixed sections:
- **Tooling**: current tool list + short descriptions.
- **Skills**: tells the model how to load skill instructions on demand.
- **ClaudeBot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agent.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Time**: UTC default + the users local time (already converted).
- **Reply Tags**: optional reply tag syntax for supported providers.
- **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, thinking level (one line).
## Workspace bootstrap injection
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:
- `AGENTS.md`
- `SOUL.md`
- `TOOLS.md`
- `IDENTITY.md`
- `USER.md`
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (only on brand-new workspaces)
Large files are truncated with a marker. Missing files inject a short missing-file marker.
## Time handling
The Time line is compact and explicit:
- Assume timestamps are **UTC** unless stated.
- The listed **user time** is already converted to `agent.userTimezone` (if set).
Use `agent.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone.
## Skills
Skills are **not** auto-injected. Instead, the prompt instructs the model to use `read` to load skill instructions on demand:
```
<workspace>/skills/<name>/SKILL.md
```
This keeps the base prompt small while still enabling targeted skill usage.
## Code references
- Prompt text: `src/agents/system-prompt.ts`
- Prompt assembly + injection: `src/agents/pi-embedded-runner.ts`
- Bootstrap trimming: `src/agents/pi-embedded-helpers.ts`

View File

@@ -543,6 +543,7 @@
"concepts/architecture",
"concepts/agent",
"concepts/agent-loop",
"concepts/system-prompt",
"concepts/agent-workspace",
"concepts/multi-agent",
"concepts/compaction",

View File

@@ -1,14 +1,11 @@
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
import {
buildSystemPrompt,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import {
applyGoogleTurnOrderingFix,
buildEmbeddedSandboxInfo,
createSystemPromptAppender,
createSystemPromptOverride,
splitSdkTools,
} from "./pi-embedded-runner.js";
import type { SandboxContext } from "./sandbox.js";
@@ -109,27 +106,15 @@ describe("splitSdkTools", () => {
});
});
describe("createSystemPromptAppender", () => {
it("appends without duplicating context files", () => {
const sentinel = "CONTEXT_SENTINEL_42";
const defaultPrompt = buildSystemPrompt({
cwd: "/tmp",
contextFiles: [{ path: "/tmp/AGENTS.md", content: sentinel }],
});
const appender = createSystemPromptAppender("APPEND_SECTION");
const finalPrompt = appender(defaultPrompt);
const occurrences = finalPrompt.split(sentinel).length - 1;
const contextHeaders = finalPrompt.split("# Project Context").length - 1;
expect(typeof appender).toBe("function");
expect(occurrences).toBe(1);
expect(contextHeaders).toBe(1);
expect(finalPrompt).toContain("APPEND_SECTION");
describe("createSystemPromptOverride", () => {
it("returns the override prompt regardless of default prompt", () => {
const override = createSystemPromptOverride("OVERRIDE");
expect(override("DEFAULT")).toBe("OVERRIDE");
});
it("returns the default prompt when append text is empty", () => {
const defaultPrompt = buildSystemPrompt({ cwd: "/tmp" });
const appender = createSystemPromptAppender(" \n ");
expect(appender(defaultPrompt)).toBe(defaultPrompt);
it("returns an empty string for blank overrides", () => {
const override = createSystemPromptOverride(" \n ");
expect(override("DEFAULT")).toBe("");
});
});

View File

@@ -15,7 +15,6 @@ import {
discoverModels,
SessionManager,
SettingsManager,
type Skill,
} from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type {
@@ -54,6 +53,7 @@ import {
import { ensureClawdbotModelsJson } from "./models-config.js";
import {
buildBootstrapContextFiles,
type EmbeddedContextFile,
ensureSessionHeader,
formatAssistantErrorText,
isAuthAssistantError,
@@ -85,12 +85,10 @@ import { resolveSandboxContext } from "./sandbox.js";
import {
applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot,
buildWorkspaceSkillSnapshot,
loadWorkspaceSkillEntries,
type SkillEntry,
type SkillSnapshot,
} from "./skills.js";
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import { normalizeUsage, type UsageLike } from "./usage.js";
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
@@ -496,7 +494,7 @@ export function buildEmbeddedSandboxInfo(
};
}
function buildEmbeddedAppendPrompt(params: {
function buildEmbeddedSystemPrompt(params: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
@@ -515,8 +513,9 @@ function buildEmbeddedAppendPrompt(params: {
modelAliasLines: string[];
userTimezone: string;
userTime?: string;
contextFiles?: EmbeddedContextFile[];
}): string {
return buildAgentSystemPromptAppend({
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
@@ -529,17 +528,15 @@ function buildEmbeddedAppendPrompt(params: {
modelAliasLines: params.modelAliasLines,
userTimezone: params.userTimezone,
userTime: params.userTime,
contextFiles: params.contextFiles,
});
}
export function createSystemPromptAppender(
appendPrompt: string,
export function createSystemPromptOverride(
systemPrompt: string,
): (defaultPrompt: string) => string {
const trimmed = appendPrompt.trim();
if (!trimmed) {
return (defaultPrompt) => defaultPrompt;
}
return (defaultPrompt) => `${defaultPrompt}\n\n${appendPrompt}`;
const trimmed = systemPrompt.trim();
return () => trimmed;
}
const BUILT_IN_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
@@ -672,25 +669,6 @@ function resolveModel(
return { model, authStorage, modelRegistry };
}
function resolvePromptSkills(
snapshot: SkillSnapshot,
entries: SkillEntry[],
): Skill[] {
if (snapshot.resolvedSkills?.length) {
return snapshot.resolvedSkills;
}
const snapshotNames = snapshot.skills.map((entry) => entry.name);
if (snapshotNames.length === 0) return [];
const entryByName = new Map(
entries.map((entry) => [entry.skill.name, entry.skill]),
);
return snapshotNames
.map((name) => entryByName.get(name))
.filter((skill): skill is Skill => Boolean(skill));
}
export async function compactEmbeddedPiSession(params: {
sessionId: string;
sessionKey?: string;
@@ -780,12 +758,6 @@ export async function compactEmbeddedPiSession(params: {
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(effectiveWorkspace, {
config: params.config,
entries: skillEntries,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
@@ -799,7 +771,6 @@ export async function compactEmbeddedPiSession(params: {
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(effectiveWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
@@ -825,7 +796,7 @@ export async function compactEmbeddedPiSession(params: {
params.config?.agent?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedAppendPrompt({
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
@@ -840,8 +811,9 @@ export async function compactEmbeddedPiSession(params: {
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
contextFiles,
});
const systemPrompt = createSystemPromptAppender(appendPrompt);
const systemPrompt = createSystemPromptOverride(appendPrompt);
// Pre-warm session file to bring it into OS page cache
await prewarmSessionFile(params.sessionFile);
@@ -878,8 +850,8 @@ export async function compactEmbeddedPiSession(params: {
customTools,
sessionManager,
settingsManager,
skills: promptSkills,
contextFiles,
skills: [],
contextFiles: [],
additionalExtensionPaths,
}));
@@ -1095,12 +1067,6 @@ export async function runEmbeddedPiAgent(params: {
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
const skillsSnapshot =
params.skillsSnapshot ??
buildWorkspaceSkillSnapshot(effectiveWorkspace, {
config: params.config,
entries: skillEntries,
});
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
@@ -1114,10 +1080,6 @@ export async function runEmbeddedPiAgent(params: {
const bootstrapFiles =
await loadWorkspaceBootstrapFiles(effectiveWorkspace);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(
skillsSnapshot,
skillEntries,
);
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
@@ -1145,7 +1107,7 @@ export async function runEmbeddedPiAgent(params: {
params.config?.agent?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedAppendPrompt({
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: thinkLevel,
extraSystemPrompt: params.extraSystemPrompt,
@@ -1160,8 +1122,9 @@ export async function runEmbeddedPiAgent(params: {
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
contextFiles,
});
const systemPrompt = createSystemPromptAppender(appendPrompt);
const systemPrompt = createSystemPromptOverride(appendPrompt);
// Pre-warm session file to bring it into OS page cache
await prewarmSessionFile(params.sessionFile);
@@ -1202,8 +1165,8 @@ export async function runEmbeddedPiAgent(params: {
customTools,
sessionManager,
settingsManager,
skills: promptSkills,
contextFiles,
skills: [],
contextFiles: [],
additionalExtensionPaths,
}));

View File

@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildAgentSystemPromptAppend } from "./system-prompt.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
describe("buildAgentSystemPromptAppend", () => {
describe("buildAgentSystemPrompt", () => {
it("includes owner numbers when provided", () => {
const prompt = buildAgentSystemPromptAppend({
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
ownerNumbers: ["+123", " +456 ", ""],
});
@@ -15,7 +15,7 @@ describe("buildAgentSystemPromptAppend", () => {
});
it("omits owner section when numbers are missing", () => {
const prompt = buildAgentSystemPromptAppend({
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
});
@@ -24,7 +24,7 @@ describe("buildAgentSystemPromptAppend", () => {
});
it("adds reasoning tag hint when enabled", () => {
const prompt = buildAgentSystemPromptAppend({
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
reasoningTagHint: true,
});
@@ -35,7 +35,7 @@ describe("buildAgentSystemPromptAppend", () => {
});
it("lists available tools when provided", () => {
const prompt = buildAgentSystemPromptAppend({
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
toolNames: ["bash", "sessions_list", "sessions_history", "sessions_send"],
});
@@ -47,19 +47,19 @@ describe("buildAgentSystemPromptAppend", () => {
});
it("includes user time when provided", () => {
const prompt = buildAgentSystemPromptAppend({
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTime: "Monday 2026-01-05 15:26",
});
expect(prompt).toContain("## Time");
expect(prompt).toContain("User timezone: America/Chicago");
expect(prompt).toContain("Current user time: Monday 2026-01-05 15:26");
expect(prompt).toContain(
"Time: assume UTC unless stated. User TZ=America/Chicago. Current user time (converted)=Monday 2026-01-05 15:26.",
);
});
it("includes model alias guidance when aliases are provided", () => {
const prompt = buildAgentSystemPromptAppend({
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
modelAliasLines: [
"- Opus: anthropic/claude-opus-4-5",
@@ -72,14 +72,41 @@ describe("buildAgentSystemPromptAppend", () => {
expect(prompt).toContain("- Opus: anthropic/claude-opus-4-5");
});
it("adds gateway self-update guidance when gateway tool is available", () => {
const prompt = buildAgentSystemPromptAppend({
it("adds ClaudeBot self-update guidance when gateway tool is available", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
toolNames: ["gateway", "bash"],
});
expect(prompt).toContain("## Gateway Self-Update");
expect(prompt).toContain("## ClaudeBot Self-Update");
expect(prompt).toContain("config.apply");
expect(prompt).toContain("update.run");
});
it("includes skills guidance with workspace path", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
});
expect(prompt).toContain("## Skills");
expect(prompt).toContain(
"Use `read` to load from /tmp/clawd/skills/<name>/SKILL.md",
);
});
it("renders project context files when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
contextFiles: [
{ path: "AGENTS.md", content: "Alpha" },
{ path: "IDENTITY.md", content: "Bravo" },
],
});
expect(prompt).toContain("# Project Context");
expect(prompt).toContain("## AGENTS.md");
expect(prompt).toContain("Alpha");
expect(prompt).toContain("## IDENTITY.md");
expect(prompt).toContain("Bravo");
});
});

View File

@@ -1,6 +1,7 @@
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
export function buildAgentSystemPromptAppend(params: {
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
extraSystemPrompt?: string;
@@ -10,6 +11,7 @@ export function buildAgentSystemPromptAppend(params: {
modelAliasLines?: string[];
userTimezone?: string;
userTime?: string;
contextFiles?: EmbeddedContextFile[];
heartbeatPrompt?: string;
runtimeInfo?: {
host?: string;
@@ -37,15 +39,16 @@ export function buildAgentSystemPromptAppend(params: {
bash: "Run shell commands",
process: "Manage background bash sessions",
whatsapp_login: "Generate and wait for WhatsApp QR login",
browser: "Control the dedicated clawd browser",
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events",
gateway:
"Restart, apply config, or run updates on the running Gateway process",
sessions_list: "List sessions with filters and last messages",
sessions_history: "Fetch message history for a session",
sessions_send: "Send a message into another session",
"Restart, apply config, or run updates on the running ClaudeBot process",
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
image: "Analyze an image with the configured image model",
discord: "Send Discord reactions/messages and manage threads",
slack: "Send Slack messages and manage channels",
@@ -95,11 +98,6 @@ export function buildAgentSystemPromptAppend(params: {
}
const hasGateway = availableTools.has("gateway");
const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off"
? `Default thinking level: ${params.defaultThinkLevel}.`
: "Default thinking level: off.";
const extraSystemPrompt = params.extraSystemPrompt?.trim();
const ownerNumbers = (params.ownerNumbers ?? [])
.map((value) => value.trim())
@@ -127,19 +125,9 @@ export function buildAgentSystemPromptAppend(params: {
? `Heartbeat prompt: ${heartbeatPrompt}`
: "Heartbeat prompt: (configured)";
const runtimeInfo = params.runtimeInfo;
const runtimeLines: string[] = [];
if (runtimeInfo?.host) runtimeLines.push(`Host: ${runtimeInfo.host}`);
if (runtimeInfo?.os) {
const archSuffix = runtimeInfo.arch ? ` (${runtimeInfo.arch})` : "";
runtimeLines.push(`OS: ${runtimeInfo.os}${archSuffix}`);
} else if (runtimeInfo?.arch) {
runtimeLines.push(`Arch: ${runtimeInfo.arch}`);
}
if (runtimeInfo?.node) runtimeLines.push(`Node: ${runtimeInfo.node}`);
if (runtimeInfo?.model) runtimeLines.push(`Model: ${runtimeInfo.model}`);
const lines = [
"You are Clawd, a personal assistant running inside Clawdbot.",
"You are a personal assistant running inside ClaudeBot.",
"",
"## Tooling",
"Tool availability (filtered by policy):",
@@ -162,13 +150,17 @@ export function buildAgentSystemPromptAppend(params: {
"- sessions_send: send to another session",
].join("\n"),
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
"",
hasGateway ? "## Gateway Self-Update" : "",
"## Skills",
`Skills provide task-specific instructions. Use \`read\` to load from ${params.workspaceDir}/skills/<name>/SKILL.md when needed.`,
"",
hasGateway ? "## ClaudeBot Self-Update" : "",
hasGateway
? [
"Use the gateway tool to update or reconfigure this instance when asked.",
"Use the ClaudeBot self-update tool to update or reconfigure this instance when asked.",
"Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).",
"After restart, Clawdbot pings the last active session automatically.",
"After restart, ClaudeBot pings the last active session automatically.",
].join("\n")
: "",
hasGateway ? "" : "",
@@ -217,15 +209,11 @@ export function buildAgentSystemPromptAppend(params: {
ownerLine ?? "",
ownerLine ? "" : "",
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
"These user-editable files are loaded by ClaudeBot and included below in Project Context.",
"",
"## Messaging Safety",
"Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.",
"Clawdbot handles message transport automatically; respond normally and your reply will be delivered to the current chat.",
"",
userTimezone || userTime ? "## Time" : "",
userTimezone ? `User timezone: ${userTimezone}` : "",
userTime ? `Current user time: ${userTime}` : "",
userTimezone || userTime
? `Time: assume UTC unless stated. User TZ=${userTimezone ?? "unknown"}. Current user time (converted)=${userTime ?? "unknown"}.`
: "",
userTimezone || userTime ? "" : "",
"## Reply Tags",
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
@@ -242,17 +230,41 @@ export function buildAgentSystemPromptAppend(params: {
lines.push("## Reasoning Format", reasoningHint, "");
}
const contextFiles = params.contextFiles ?? [];
if (contextFiles.length > 0) {
lines.push(
"# Project Context",
"",
"The following project context files have been loaded:",
"",
);
for (const file of contextFiles) {
lines.push(`## ${file.path}`, "", file.content, "");
}
}
lines.push(
"## Heartbeats",
heartbeatPromptLine,
"If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:",
"HEARTBEAT_OK",
'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'ClaudeBot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).',
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
"",
"## Runtime",
...runtimeLines,
thinkHint,
`Runtime: ${[
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch
? `arch=${runtimeInfo.arch}`
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
`thinking=${params.defaultThinkLevel ?? "off"}`,
]
.filter(Boolean)
.join(" | ")}`,
);
return lines.filter(Boolean).join("\n");