diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 6c4de3544..878e11fa4 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -26,6 +26,22 @@ The prompt is intentionally compact and uses fixed sections: - **Runtime**: host, OS, node, model, thinking level (one line). - **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 Bootstrap files are trimmed and appended under **Project Context** so the model sees identity and profile context without needing explicit reads: diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index 096e692eb..1aa9e54f2 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -44,7 +44,7 @@ Common methods + events: | --- | --- | --- | | Core | `connect`, `health`, `status` | `connect` must be first | | 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 | | Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions | | Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push | diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index d3df84c27..a71780a40 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -24,8 +24,8 @@ agent (with a session switcher for other sessions). ## How it’s wired -- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and - events `chat`, `agent`, `presence`, `tick`, `health`. +- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort`, + `chat.inject` and events `chat`, `agent`, `presence`, `tick`, `health`. - Session: defaults to the primary session (`main`, or `global` when scope is global). The UI can switch between sessions. - Onboarding uses a dedicated session to keep first‑run setup separate. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b7f5d2e18..8f4a1fc9b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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. ## 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) - Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`) - 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. - 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: - Click **Stop** (calls `chat.abort`) - Type `/stop` (or `stop|esc|abort|wait|exit|interrupt`) to abort out-of-band diff --git a/docs/web/webchat.md b/docs/web/webchat.md index 9343ebb51..2abfa67ea 100644 --- a/docs/web/webchat.md +++ b/docs/web/webchat.md @@ -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. ## 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). - If the gateway is unreachable, WebChat is read-only. diff --git a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-allow-agent-specific-docker-settings-beyond.test.ts b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-allow-agent-specific-docker-settings-beyond.test.ts index 6df4bc23a..8f9894395 100644 --- a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-allow-agent-specific-docker-settings-beyond.test.ts +++ b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-allow-agent-specific-docker-settings-beyond.test.ts @@ -45,6 +45,14 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +vi.mock("../skills.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + syncSkillsToWorkspace: vi.fn(async () => {}), + }; +}); + describe("Agent-specific sandbox config", () => { beforeEach(() => { spawnCalls.length = 0; diff --git a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-use-agent-specific-workspaceroot.test.ts b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-use-agent-specific-workspaceroot.test.ts index 4eaf19a6d..2babad8bf 100644 --- a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-use-agent-specific-workspaceroot.test.ts +++ b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.should-use-agent-specific-workspaceroot.test.ts @@ -46,6 +46,14 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +vi.mock("../skills.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + syncSkillsToWorkspace: vi.fn(async () => {}), + }; +}); + describe("Agent-specific sandbox config", () => { beforeEach(() => { spawnCalls.length = 0; diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index bb34489bf..ba861ccf4 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -123,4 +123,54 @@ describe("subagent registry persistence", () => { ); 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; + }; + 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; + }; + expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined(); + }); }); diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 499480089..e63900ef9 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -23,6 +23,30 @@ describe("buildAgentSystemPrompt", () => { 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: + "\n \n demo\n \n", + 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", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/clawd", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index b9b352e4b..962aee57c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -12,6 +12,105 @@ import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; */ 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; +}) { + 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:]] 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; + 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..capabilities.` + : "", + ] + .filter(Boolean) + .join("\n") + : "", + "", + ]; +} + export function buildAgentSystemPrompt(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; @@ -118,6 +217,7 @@ export function buildAgentSystemPrompt(params: { const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); const canonicalToolNames = rawToolNames.filter(Boolean); + // Preserve caller casing while deduping tool names by lowercase. const canonicalByNormalized = new Map(); for (const name of canonicalToolNames) { const normalized = name.toLowerCase(); @@ -191,26 +291,12 @@ export function buildAgentSystemPrompt(params: { const messageChannelOptions = listDeliverableMessageChannels().join("|"); const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; - const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; - // Skip skills section for subagent/none modes - const skillsSection = - skillsPrompt && !isMinimal - ? [ - "## Skills", - `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.", - "", - ] - : []; + const skillsSection = buildSkillsSection({ + skillsPrompt, + isMinimal, + readToolName, + }); + const memorySection = buildMemorySection({ isMinimal, availableTools }); // For "none" mode, return just the basic identity line if (promptMode === "none") { @@ -335,63 +421,23 @@ export function buildAgentSystemPrompt(params: { .join("\n") : "", params.sandboxInfo?.enabled ? "" : "", - // Skip user identity for subagent/none modes - ownerLine && !isMinimal ? "## User Identity" : "", - ownerLine && !isMinimal ? ownerLine : "", - ownerLine && !isMinimal ? "" : "", - ...(userTimezone || userTime - ? [ - "## 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"}` - : "", - "", - ] - : []), + ...buildUserIdentitySection(ownerLine, isMinimal), + ...buildTimeSection({ + userTimezone, + userTime, + userTimeFormat: params.userTimeFormat, + }), "## Workspace Files (injected)", "These user-editable files are loaded by Clawdbot and included below in Project Context.", "", - // Skip reply tags for subagent/none modes - ...(isMinimal - ? [] - : [ - "## 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:]] 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..capabilities.` - : "", - ] - .filter(Boolean) - .join("\n") - : "", - "", - ]), + ...buildReplyTagsSection(isMinimal), + ...buildMessagingSection({ + isMinimal, + availableTools, + messageChannelOptions, + inlineButtonsEnabled, + runtimeChannel, + }), ]; if (extraSystemPrompt) {