350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
|
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
|
|
|
export function buildAgentSystemPrompt(params: {
|
|
workspaceDir: string;
|
|
defaultThinkLevel?: ThinkLevel;
|
|
extraSystemPrompt?: string;
|
|
ownerNumbers?: string[];
|
|
reasoningTagHint?: boolean;
|
|
toolNames?: string[];
|
|
modelAliasLines?: string[];
|
|
userTimezone?: string;
|
|
userTime?: string;
|
|
contextFiles?: EmbeddedContextFile[];
|
|
skillsPrompt?: string;
|
|
heartbeatPrompt?: string;
|
|
runtimeInfo?: {
|
|
host?: string;
|
|
os?: string;
|
|
arch?: string;
|
|
node?: string;
|
|
model?: string;
|
|
provider?: string;
|
|
capabilities?: string[];
|
|
};
|
|
sandboxInfo?: {
|
|
enabled: boolean;
|
|
workspaceDir?: string;
|
|
workspaceAccess?: "none" | "ro" | "rw";
|
|
agentWorkspaceMount?: string;
|
|
browserControlUrl?: string;
|
|
browserNoVncUrl?: string;
|
|
};
|
|
}) {
|
|
const toolSummaries: Record<string, string> = {
|
|
read: "Read file contents",
|
|
write: "Create or overwrite files",
|
|
edit: "Make precise edits to files",
|
|
grep: "Search file contents for patterns",
|
|
find: "Find files by glob pattern",
|
|
ls: "List directory contents",
|
|
bash: "Run shell commands",
|
|
process: "Manage background bash sessions",
|
|
whatsapp_login: "Generate and wait for WhatsApp QR login",
|
|
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",
|
|
message: "Send messages and provider actions",
|
|
gateway:
|
|
"Restart, apply config, or run updates on the running Clawdbot process",
|
|
agents_list: "List agent ids allowed for sessions_spawn",
|
|
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",
|
|
session_status:
|
|
"Show a /status-equivalent status card (includes usage + cost when available); optional per-session model override",
|
|
image: "Analyze an image with the configured image model",
|
|
};
|
|
|
|
const toolOrder = [
|
|
"read",
|
|
"write",
|
|
"edit",
|
|
"grep",
|
|
"find",
|
|
"ls",
|
|
"bash",
|
|
"process",
|
|
"whatsapp_login",
|
|
"browser",
|
|
"canvas",
|
|
"nodes",
|
|
"cron",
|
|
"message",
|
|
"gateway",
|
|
"agents_list",
|
|
"sessions_list",
|
|
"sessions_history",
|
|
"sessions_send",
|
|
"session_status",
|
|
"image",
|
|
];
|
|
|
|
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
|
|
const canonicalToolNames = rawToolNames.filter(Boolean);
|
|
const canonicalByNormalized = new Map<string, string>();
|
|
for (const name of canonicalToolNames) {
|
|
const normalized = name.toLowerCase();
|
|
if (!canonicalByNormalized.has(normalized)) {
|
|
canonicalByNormalized.set(normalized, name);
|
|
}
|
|
}
|
|
const resolveToolName = (normalized: string) =>
|
|
canonicalByNormalized.get(normalized) ?? normalized;
|
|
|
|
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
|
|
const availableTools = new Set(normalizedTools);
|
|
const extraTools = Array.from(
|
|
new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))),
|
|
);
|
|
const enabledTools = toolOrder.filter((tool) => availableTools.has(tool));
|
|
const toolLines = enabledTools.map((tool) => {
|
|
const summary = toolSummaries[tool];
|
|
const name = resolveToolName(tool);
|
|
return summary ? `- ${name}: ${summary}` : `- ${name}`;
|
|
});
|
|
for (const tool of extraTools.sort()) {
|
|
toolLines.push(`- ${resolveToolName(tool)}`);
|
|
}
|
|
|
|
const hasGateway = availableTools.has("gateway");
|
|
const readToolName = resolveToolName("read");
|
|
const bashToolName = resolveToolName("bash");
|
|
const processToolName = resolveToolName("process");
|
|
const extraSystemPrompt = params.extraSystemPrompt?.trim();
|
|
const ownerNumbers = (params.ownerNumbers ?? [])
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
const ownerLine =
|
|
ownerNumbers.length > 0
|
|
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
|
|
: undefined;
|
|
const reasoningHint = params.reasoningTagHint
|
|
? [
|
|
"ALL internal reasoning MUST be inside <think>...</think>.",
|
|
"Do not output any analysis outside <think>.",
|
|
"Format every reply as <think>...</think> then <final>...</final>, with no other text.",
|
|
"Only the final user-visible reply may appear inside <final>.",
|
|
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
|
|
"Example:",
|
|
"<think>Short internal reasoning.</think>",
|
|
"<final>Hey there! What would you like to do next?</final>",
|
|
].join(" ")
|
|
: undefined;
|
|
const userTimezone = params.userTimezone?.trim();
|
|
const userTime = params.userTime?.trim();
|
|
const skillsPrompt = params.skillsPrompt?.trim();
|
|
const heartbeatPrompt = params.heartbeatPrompt?.trim();
|
|
const heartbeatPromptLine = heartbeatPrompt
|
|
? `Heartbeat prompt: ${heartbeatPrompt}`
|
|
: "Heartbeat prompt: (configured)";
|
|
const runtimeInfo = params.runtimeInfo;
|
|
const runtimeProvider = runtimeInfo?.provider?.trim().toLowerCase();
|
|
const runtimeCapabilities = (runtimeInfo?.capabilities ?? [])
|
|
.map((cap) => String(cap).trim())
|
|
.filter(Boolean);
|
|
const runtimeCapabilitiesLower = new Set(
|
|
runtimeCapabilities.map((cap) => cap.toLowerCase()),
|
|
);
|
|
const telegramInlineButtonsEnabled =
|
|
runtimeProvider === "telegram" &&
|
|
runtimeCapabilitiesLower.has("inlinebuttons");
|
|
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
|
|
const skillsSection = skillsPrompt
|
|
? [
|
|
"## Skills",
|
|
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
|
|
...skillsLines,
|
|
"",
|
|
]
|
|
: [];
|
|
|
|
const lines = [
|
|
"You are a personal assistant running inside Clawdbot.",
|
|
"",
|
|
"## 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",
|
|
`- ${bashToolName}: run shell commands (supports background via yieldMs/background)`,
|
|
`- ${processToolName}: manage background bash sessions`,
|
|
"- whatsapp_login: generate a WhatsApp QR code and wait for linking",
|
|
"- browser: control clawd'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",
|
|
"- sessions_list: list sessions",
|
|
"- sessions_history: fetch session history",
|
|
"- 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.",
|
|
"",
|
|
...skillsSection,
|
|
hasGateway ? "## Clawdbot Self-Update" : "",
|
|
hasGateway
|
|
? [
|
|
"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.",
|
|
"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.",
|
|
].join("\n")
|
|
: "",
|
|
hasGateway ? "" : "",
|
|
"",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0
|
|
? "## Model Aliases"
|
|
: "",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0
|
|
? "Prefer aliases when specifying model overrides; full provider/model is also accepted."
|
|
: "",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0
|
|
? params.modelAliasLines.join("\n")
|
|
: "",
|
|
params.modelAliasLines && params.modelAliasLines.length > 0 ? "" : "",
|
|
"## Workspace",
|
|
`Your working directory is: ${params.workspaceDir}`,
|
|
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
|
"",
|
|
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
|
params.sandboxInfo?.enabled
|
|
? [
|
|
"Tool execution is isolated in a Docker sandbox.",
|
|
"Some tools may be unavailable due to sandbox policy.",
|
|
params.sandboxInfo.workspaceDir
|
|
? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}`
|
|
: "",
|
|
params.sandboxInfo.workspaceAccess
|
|
? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${
|
|
params.sandboxInfo.agentWorkspaceMount
|
|
? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})`
|
|
: ""
|
|
}`
|
|
: "",
|
|
params.sandboxInfo.browserControlUrl
|
|
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
|
|
: "",
|
|
params.sandboxInfo.browserNoVncUrl
|
|
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "",
|
|
params.sandboxInfo?.enabled ? "" : "",
|
|
ownerLine ? "## User Identity" : "",
|
|
ownerLine ?? "",
|
|
ownerLine ? "" : "",
|
|
"## Workspace Files (injected)",
|
|
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
|
"",
|
|
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:",
|
|
"- [[reply_to_current]] replies to the triggering message.",
|
|
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
|
|
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
|
|
"Tags are stripped before sending; support depends on the current provider config.",
|
|
"",
|
|
"## Messaging",
|
|
"- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)",
|
|
"- Cross-session messaging → use sessions_send(sessionKey, message)",
|
|
"- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.",
|
|
availableTools.has("message")
|
|
? [
|
|
"",
|
|
"### message tool",
|
|
"- Use `message` for proactive sends + provider actions (polls, reactions, etc.).",
|
|
"- If multiple providers are configured, pass `provider` (whatsapp|telegram|discord|slack|signal|imessage|msteams).",
|
|
telegramInlineButtonsEnabled
|
|
? "- Telegram: inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
|
: runtimeProvider === "telegram"
|
|
? '- Telegram: inline buttons NOT enabled. If you need them, ask to add "inlineButtons" to telegram.capabilities or telegram.accounts.<id>.capabilities.'
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "",
|
|
"",
|
|
];
|
|
|
|
if (extraSystemPrompt) {
|
|
lines.push("## Group Chat Context", extraSystemPrompt, "");
|
|
}
|
|
if (reasoningHint) {
|
|
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(
|
|
"## 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}`,
|
|
"",
|
|
"## 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).',
|
|
'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.',
|
|
"",
|
|
"## Runtime",
|
|
`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}` : "",
|
|
runtimeProvider ? `provider=${runtimeProvider}` : "",
|
|
runtimeProvider
|
|
? `capabilities=${
|
|
runtimeCapabilities.length > 0
|
|
? runtimeCapabilities.join(",")
|
|
: "none"
|
|
}`
|
|
: "",
|
|
`thinking=${params.defaultThinkLevel ?? "off"}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ")}`,
|
|
);
|
|
|
|
return lines.filter(Boolean).join("\n");
|
|
}
|