refactor: system prompt sections + docs/tests

This commit is contained in:
Peter Steinberger
2026-01-16 00:28:43 +00:00
parent 8c3cdba21c
commit bca5c0d569
10 changed files with 233 additions and 79 deletions

View File

@@ -26,6 +26,22 @@ The prompt is intentionally compact and uses fixed sections:
- **Runtime**: host, OS, node, model, thinking level (one line). - **Runtime**: host, OS, node, model, thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint. - **Reasoning**: current visibility level + /reasoning toggle hint.
## Prompt modes
Clawdbot can render smaller system prompts for sub-agents. The runtime sets a
`promptMode` for each run (not a user-facing config):
- `full` (default): includes all sections above.
- `minimal`: used for sub-agents; omits **Skills**, **Memory Recall**, **Clawdbot
Self-Update**, **Model Aliases**, **User Identity**, **Reply Tags**,
**Messaging**, **Silent Replies**, and **Heartbeats**. Tooling, Workspace,
Sandbox, Current Date & Time (when known), Runtime, and injected context stay
available.
- `none`: returns only the base identity line.
When `promptMode=minimal`, extra injected prompts are labeled **Subagent
Context** instead of **Group Chat Context**.
## Workspace bootstrap injection ## Workspace bootstrap injection
Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads: Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads:

View File

@@ -44,7 +44,7 @@ Common methods + events:
| --- | --- | --- | | --- | --- | --- |
| Core | `connect`, `health`, `status` | `connect` must be first | | Core | `connect`, `health`, `status` | `connect` must be first |
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` | | Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
| Chat | `chat.history`, `chat.send`, `chat.abort` | WebChat uses these | | Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin | | Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions | | Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push | | Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |

View File

@@ -24,8 +24,8 @@ agent (with a session switcher for other sessions).
## How its wired ## How its wired
- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and - Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`,
events `chat`, `agent`, `presence`, `tick`, `health`. `chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`.
- Session: defaults to the primary session (`main`, or `global` when scope is - Session: defaults to the primary session (`main`, or `global` when scope is
global). The UI can switch between sessions. global). The UI can switch between sessions.
- Onboarding uses a dedicated session to keep firstrun setup separate. - Onboarding uses a dedicated session to keep firstrun setup separate.

View File

@@ -28,7 +28,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
The onboarding wizard generates a gateway token by default, so paste it here on first connect. The onboarding wizard generates a gateway token by default, so paste it here on first connect.
## What it can do (today) ## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
- Stream tool calls + live tool output cards in Chat (agent events) - Stream tool calls + live tool output cards in Chat (agent events)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`) - Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`) - Instances: presence list + refresh (`system-presence`)
@@ -60,6 +60,7 @@ Notes:
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. - `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion. - Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- Stop: - Stop:
- Click **Stop** (calls `chat.abort`) - Click **Stop** (calls `chat.abort`)
- Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band

View File

@@ -19,7 +19,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
3) Ensure gateway auth is configured if you are not on loopback. 3) Ensure gateway auth is configured if you are not on loopback.
## How it works (behavior) ## How it works (behavior)
- The UI connects to the Gateway WebSocket and uses `chat.history` + `chat.send`. - The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
- History is always fetched from the gateway (no local file watching). - History is always fetched from the gateway (no local file watching).
- If the gateway is unreachable, WebChat is read-only. - If the gateway is unreachable, WebChat is read-only.

View File

@@ -45,6 +45,14 @@ vi.mock("node:child_process", async (importOriginal) => {
}; };
}); });
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
syncSkillsToWorkspace: vi.fn(async () => {}),
};
});
describe("Agent-specific sandbox config", () => { describe("Agent-specific sandbox config", () => {
beforeEach(() => { beforeEach(() => {
spawnCalls.length = 0; spawnCalls.length = 0;

View File

@@ -46,6 +46,14 @@ vi.mock("node:child_process", async (importOriginal) => {
}; };
}); });
vi.mock("../skills.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../skills.js")>();
return {
...actual,
syncSkillsToWorkspace: vi.fn(async () => {}),
};
});
describe("Agent-specific sandbox config", () => { describe("Agent-specific sandbox config", () => {
beforeEach(() => { beforeEach(() => {
spawnCalls.length = 0; spawnCalls.length = 0;

View File

@@ -123,4 +123,54 @@ describe("subagent registry persistence", () => {
); );
expect(match).toBeFalsy(); expect(match).toBeFalsy();
}); });
it("retries cleanup announce after a failed announce", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-subagent-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
const persisted = {
version: 1,
runs: {
"run-3": {
runId: "run-3",
childSessionKey: "agent:main:subagent:three",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "retry announce",
cleanup: "keep",
createdAt: 1,
startedAt: 1,
endedAt: 2,
},
},
};
await fs.mkdir(path.dirname(registryPath), { recursive: true });
await fs.writeFile(registryPath, `${JSON.stringify(persisted)}\n`, "utf8");
announceSpy.mockResolvedValueOnce(false);
vi.resetModules();
const mod1 = await import("./subagent-registry.js");
mod1.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalledTimes(1);
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
};
expect(afterFirst.runs["run-3"].cleanupHandled).toBe(false);
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
announceSpy.mockResolvedValueOnce(true);
vi.resetModules();
const mod2 = await import("./subagent-registry.js");
mod2.initSubagentRegistry();
await new Promise((r) => setTimeout(r, 0));
expect(announceSpy).toHaveBeenCalledTimes(2);
const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
runs: Record<string, { cleanupCompletedAt?: number }>;
};
expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined();
});
}); });

View File

@@ -23,6 +23,30 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("Owner numbers:"); expect(prompt).not.toContain("Owner numbers:");
}); });
it("omits extended sections in minimal prompt mode", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
promptMode: "minimal",
ownerNumbers: ["+123"],
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"],
extraSystemPrompt: "Subagent details",
});
expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies");
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).toContain("## Subagent Context");
expect(prompt).not.toContain("## Group Chat Context");
expect(prompt).toContain("Subagent details");
});
it("adds reasoning tag hint when enabled", () => { it("adds reasoning tag hint when enabled", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",

View File

@@ -12,6 +12,105 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
*/ */
export type PromptMode = "full" | "minimal" | "none"; export type PromptMode = "full" | "minimal" | "none";
function buildSkillsSection(params: {
skillsPrompt?: string;
isMinimal: boolean;
readToolName: string;
}) {
const trimmed = params.skillsPrompt?.trim();
if (!trimmed || params.isMinimal) return [];
return [
"## Skills",
`Skills provide task-specific instructions. Use \`${params.readToolName}\` to load the SKILL.md at the location listed for that skill.`,
trimmed,
"",
];
}
function buildMemorySection(params: {
isMinimal: boolean;
availableTools: Set<string>;
}) {
if (params.isMinimal) return [];
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
return [];
}
return [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
"",
];
}
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
if (!ownerLine || isMinimal) return [];
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: {
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
}) {
if (!params.userTimezone && !params.userTime) return [];
return [
"## Current Date & Time",
params.userTime
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
];
}
function buildReplyTagsSection(isMinimal: boolean) {
if (isMinimal) return [];
return [
"## 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 channel config.",
"",
];
}
function buildMessagingSection(params: {
isMinimal: boolean;
availableTools: Set<string>;
messageChannelOptions: string;
inlineButtonsEnabled: boolean;
runtimeChannel?: string;
}) {
if (params.isMinimal) return [];
return [
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
params.availableTools.has("message")
? [
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
params.inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: params.runtimeChannel
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to add "inlineButtons" to ${params.runtimeChannel}.capabilities or ${params.runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
];
}
export function buildAgentSystemPrompt(params: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -118,6 +217,7 @@ export function buildAgentSystemPrompt(params: {
const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim());
const canonicalToolNames = rawToolNames.filter(Boolean); const canonicalToolNames = rawToolNames.filter(Boolean);
// Preserve caller casing while deduping tool names by lowercase.
const canonicalByNormalized = new Map<string, string>(); const canonicalByNormalized = new Map<string, string>();
for (const name of canonicalToolNames) { for (const name of canonicalToolNames) {
const normalized = name.toLowerCase(); const normalized = name.toLowerCase();
@@ -191,26 +291,12 @@ export function buildAgentSystemPrompt(params: {
const messageChannelOptions = listDeliverableMessageChannels().join("|"); const messageChannelOptions = listDeliverableMessageChannels().join("|");
const promptMode = params.promptMode ?? "full"; const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none"; const isMinimal = promptMode === "minimal" || promptMode === "none";
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; const skillsSection = buildSkillsSection({
// Skip skills section for subagent/none modes skillsPrompt,
const skillsSection = isMinimal,
skillsPrompt && !isMinimal readToolName,
? [ });
"## Skills", const memorySection = buildMemorySection({ isMinimal, availableTools });
`Skills provide task-specific instructions. Use \`${readToolName}\` to load the SKILL.md at the location listed for that skill.`,
...skillsLines,
"",
]
: [];
// Skip memory section for subagent/none modes
const memorySection =
!isMinimal && (availableTools.has("memory_search") || availableTools.has("memory_get"))
? [
"## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
"",
]
: [];
// For "none" mode, return just the basic identity line // For "none" mode, return just the basic identity line
if (promptMode === "none") { if (promptMode === "none") {
@@ -335,63 +421,23 @@ export function buildAgentSystemPrompt(params: {
.join("\n") .join("\n")
: "", : "",
params.sandboxInfo?.enabled ? "" : "", params.sandboxInfo?.enabled ? "" : "",
// Skip user identity for subagent/none modes ...buildUserIdentitySection(ownerLine, isMinimal),
ownerLine && !isMinimal ? "## User Identity" : "", ...buildTimeSection({
ownerLine && !isMinimal ? ownerLine : "", userTimezone,
ownerLine && !isMinimal ? "" : "", userTime,
...(userTimezone || userTime userTimeFormat: params.userTimeFormat,
? [ }),
"## Current Date & Time",
userTime
? `${userTime} (${userTimezone ?? "unknown"})`
: `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
]
: []),
"## Workspace Files (injected)", "## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.", "These user-editable files are loaded by Clawdbot and included below in Project Context.",
"", "",
// Skip reply tags for subagent/none modes ...buildReplyTagsSection(isMinimal),
...(isMinimal ...buildMessagingSection({
? [] isMinimal,
: [ availableTools,
"## Reply Tags", messageChannelOptions,
"To request a native reply/quote on supported surfaces, include one tag in your reply:", inlineButtonsEnabled,
"- [[reply_to_current]] replies to the triggering message.", runtimeChannel,
"- [[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 channel config.",
"",
]),
// Skip messaging section for subagent/none modes
...(isMinimal
? []
: [
"## Messaging",
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
"- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.",
availableTools.has("message")
? [
"",
"### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel
? `- Inline buttons not enabled for ${runtimeChannel}. If you need them, ask to add "inlineButtons" to ${runtimeChannel}.capabilities or ${runtimeChannel}.accounts.<id>.capabilities.`
: "",
]
.filter(Boolean)
.join("\n")
: "",
"",
]),
]; ];
if (extraSystemPrompt) { if (extraSystemPrompt) {